diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/built-in.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/built-in.tsx
index 5280f68562b..20e8980de12 100644
--- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/built-in.tsx
+++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/built-in.tsx
@@ -1,7 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { useDeployEnvironment } from '@qovery/domains/environments/feature'
import { VariableList } from '@qovery/domains/variables/feature'
-import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes'
import { toast } from '@qovery/shared/ui'
import { useDocumentTitle } from '@qovery/shared/util-hooks'
@@ -16,8 +15,9 @@ function RouteComponent() {
useDocumentTitle('Built-in variables - Environment')
const { mutate: deployEnvironment } = useDeployEnvironment({
+ organizationId,
projectId,
- logsLink: ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) + ENVIRONMENT_STAGES_URL(),
+ environmentId,
})
const toasterCallback = () => {
diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/external-secrets.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/external-secrets.tsx
index 19533863201..0c4cfc3c4c8 100644
--- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/external-secrets.tsx
+++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/external-secrets.tsx
@@ -4,7 +4,6 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useEffect } from 'react'
import { useDeployEnvironment } from '@qovery/domains/environments/feature'
import { ExternalSecretsTab, SecretManagerFeatureFlagEntryPoint } from '@qovery/domains/variables/feature'
-import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes'
import { toast } from '@qovery/shared/ui'
import { useDocumentTitle } from '@qovery/shared/util-hooks'
@@ -29,8 +28,9 @@ function RouteComponent() {
}, [organizationId, projectId, environmentId])
const { mutate: deployEnvironment } = useDeployEnvironment({
+ organizationId,
projectId,
- logsLink: ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) + ENVIRONMENT_STAGES_URL(),
+ environmentId,
})
const redeployEnvironmentAction = () => {
diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/index.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/index.tsx
index fc00f4873d0..0843a1d7d64 100644
--- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/index.tsx
+++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables/index.tsx
@@ -1,7 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { useDeployEnvironment } from '@qovery/domains/environments/feature'
import { VariableList, VariablesActionToolbar } from '@qovery/domains/variables/feature'
-import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes'
import { toast } from '@qovery/shared/ui'
import { useDocumentTitle } from '@qovery/shared/util-hooks'
@@ -16,8 +15,9 @@ function RouteComponent() {
useDocumentTitle('Custom variables - Environment')
const { mutate: deployEnvironment } = useDeployEnvironment({
+ organizationId,
projectId,
- logsLink: ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) + ENVIRONMENT_STAGES_URL(),
+ environmentId,
})
const toasterCallback = () => {
diff --git a/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.spec.tsx b/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.spec.tsx
index 68fa960aebe..c6c0054eeaf 100644
--- a/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.spec.tsx
+++ b/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.spec.tsx
@@ -14,7 +14,6 @@ const mockCancel = jest.fn()
const mockOpenModalConfirmation = jest.fn()
jest.mock('@qovery/domains/services/feature', () => ({
- useDeployAllServices: () => ({ mutate: mockDeployAllServices }),
useRestartAllServices: () => ({ mutate: mockRestartAllServices }),
useStopAllServices: () => ({ mutate: mockStopAllServices }),
useUninstallAllServices: () => ({ mutate: mockUninstallAllServices }),
@@ -24,6 +23,7 @@ jest.mock('@qovery/domains/services/feature', () => ({
jest.mock('@qovery/domains/environments/feature', () => ({
...jest.requireActual('@qovery/domains/environments/feature'),
useCancelDeploymentEnvironment: () => ({ mutate: mockCancel }),
+ useDeployAllServices: () => ({ mutate: mockDeployAllServices }),
}))
jest.mock('@qovery/shared/ui', () => ({
diff --git a/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.tsx b/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.tsx
index b8abda937c0..585bda81570 100644
--- a/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.tsx
+++ b/libs/domains/environment-logs/feature/src/lib/deployment-action-button/deployment-action-button.tsx
@@ -1,14 +1,12 @@
import { type DeploymentHistoryEnvironmentV2, type Environment, type StateEnum } from 'qovery-typescript-axios'
import { match } from 'ts-pattern'
-import { useCancelDeploymentEnvironment } from '@qovery/domains/environments/feature'
+import { useCancelDeploymentEnvironment, useDeployAllServices } from '@qovery/domains/environments/feature'
import {
useDeleteAllServices,
- useDeployAllServices,
useRestartAllServices,
useStopAllServices,
useUninstallAllServices,
} from '@qovery/domains/services/feature'
-import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes'
import { Button, Icon, Tooltip, useModalConfirmation } from '@qovery/shared/ui'
import { isCancelBuildAvailable } from '@qovery/shared/util-js'
@@ -21,12 +19,10 @@ export interface DeploymentActionButtonProps {
export function DeploymentActionButton({ environment, deploymentHistory, state }: DeploymentActionButtonProps) {
const { openModalConfirmation } = useModalConfirmation()
- const logsLink =
- ENVIRONMENT_LOGS_URL(environment.organization.id, environment.project.id, environment.id) + ENVIRONMENT_STAGES_URL()
-
const { mutate: cancelDeploymentEnvironment } = useCancelDeploymentEnvironment({
+ organizationId: environment.organization.id,
projectId: environment.project.id,
- logsLink,
+ environmentId: environment.id,
})
const { mutate: deployAllServices } = useDeployAllServices()
const { mutate: restartAllServices } = useRestartAllServices()
diff --git a/libs/domains/environment-logs/feature/src/lib/environment-pipeline/environment-pipeline.tsx b/libs/domains/environment-logs/feature/src/lib/environment-pipeline/environment-pipeline.tsx
index 7f3b404710e..be15670294d 100644
--- a/libs/domains/environment-logs/feature/src/lib/environment-pipeline/environment-pipeline.tsx
+++ b/libs/domains/environment-logs/feature/src/lib/environment-pipeline/environment-pipeline.tsx
@@ -288,6 +288,7 @@ export function EnvironmentPipeline() {
},
enabled:
Boolean(organizationId) && Boolean(environment?.cluster_id) && Boolean(projectId) && Boolean(environmentId),
+ shouldReconnect: true,
onMessage: messageHandler,
})
diff --git a/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.spec.tsx b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.spec.tsx
new file mode 100644
index 00000000000..e76e0dcc9a2
--- /dev/null
+++ b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.spec.tsx
@@ -0,0 +1,324 @@
+import { environmentFactoryMock } from '@qovery/shared/factories'
+import { renderWithProviders, screen } from '@qovery/shared/util-tests'
+import { type DeployByVersionService } from './deploy-by-version'
+import { DeployByVersionModal } from './deploy-by-version-modal'
+
+const mockEnvironment = environmentFactoryMock(1)[0]
+const mockDeployAllServices = jest.fn()
+const mockCloseModal = jest.fn()
+const mockSetModalDismissible = jest.fn()
+let mockDeployIsLoading = false
+
+const unavailableService: DeployByVersionService = {
+ id: 'unavailable',
+ name: 'Unavailable service',
+ iconUri: 'app://qovery-console/application',
+ serviceType: 'APPLICATION',
+ sourceType: 'git',
+ currentVersion: 'current-commit',
+ versions: [],
+ isSkipped: false,
+ hasVersionError: true,
+}
+
+const defaultServices: DeployByVersionService[] = [
+ {
+ id: 'terraform',
+ name: 'Infrastructure',
+ iconUri: 'app://qovery-console/terraform',
+ serviceType: 'TERRAFORM',
+ sourceType: 'git',
+ currentVersion: 'old-terraform-commit',
+ versions: [
+ { value: 'new-terraform-commit', message: 'Update infrastructure' },
+ { value: 'old-terraform-commit', message: 'Current infrastructure' },
+ ],
+ isSkipped: false,
+ },
+ {
+ id: 'application',
+ name: 'API',
+ iconUri: 'app://qovery-console/application',
+ serviceType: 'APPLICATION',
+ sourceType: 'git',
+ currentVersion: 'current-app-commit',
+ versions: [
+ { value: 'current-app-commit', message: 'Current application' },
+ { value: 'previous-app-commit', message: 'Previous application' },
+ ],
+ isSkipped: false,
+ },
+ unavailableService,
+]
+let mockServices = defaultServices
+
+jest.mock('@qovery/domains/services/feature', () => ({
+ ServiceAvatar: () => ,
+}))
+
+jest.mock('@qovery/shared/ui', () => ({
+ ...jest.requireActual('@qovery/shared/ui'),
+ useModal: () => ({ closeModal: mockCloseModal, setModalDismissible: mockSetModalDismissible }),
+}))
+
+jest.mock('./use-deploy-by-version-services', () => ({
+ useDeployByVersionServices: () => ({ data: mockServices, isLoading: false, isError: false }),
+}))
+
+jest.mock('../hooks/use-deploy-all-services/use-deploy-all-services', () => ({
+ useDeployAllServices: () => ({ mutate: mockDeployAllServices, isLoading: mockDeployIsLoading }),
+}))
+
+describe('DeployByVersionModal', () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ jest.clearAllMocks()
+ mockServices = defaultServices
+ mockDeployIsLoading = false
+ mockDeployAllServices.mockImplementation((_variables: unknown, options: { onSuccess?: () => void }) => {
+ options.onSuccess?.()
+ })
+ })
+
+ afterEach(() => {
+ jest.clearAllTimers()
+ jest.useRealTimers()
+ })
+
+ it('preselects outdated services and submits an explicit Terraform commit', async () => {
+ const { userEvent } = renderWithProviders()
+
+ expect(screen.getByRole('button', { name: 'Update 1 service' })).toBeEnabled()
+ expect(screen.getByRole('checkbox', { name: 'Infrastructure' })).toBeChecked()
+ expect(screen.getByRole('checkbox', { name: 'API' })).not.toBeChecked()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Update 1 service' }))
+
+ expect(mockDeployAllServices).toHaveBeenCalledWith(
+ {
+ environment: mockEnvironment,
+ payload: { terraforms: [{ id: 'terraform', git_commit_id: 'new-terraform-commit' }] },
+ },
+ { onSuccess: mockCloseModal }
+ )
+ expect(mockSetModalDismissible).toHaveBeenLastCalledWith(false)
+ expect(mockCloseModal).toHaveBeenCalledTimes(1)
+ })
+
+ it('allows an up-to-date service to be selected explicitly', async () => {
+ const { userEvent } = renderWithProviders()
+
+ await userEvent.click(screen.getByRole('checkbox', { name: 'API' }))
+
+ expect(screen.getByRole('button', { name: 'Update 2 services' })).toBeEnabled()
+ expect(screen.getByRole('button', { name: 'Select a version for API' })).toBeEnabled()
+ })
+
+ it('keeps the version selector enabled and selects the service when its target changes', async () => {
+ const { userEvent } = renderWithProviders()
+
+ const selector = screen.getByRole('button', { name: 'Select a version for API' })
+ expect(selector).toBeEnabled()
+ expect(screen.getByRole('checkbox', { name: 'API' })).not.toBeChecked()
+
+ await userEvent.click(selector)
+ await userEvent.click(screen.getByRole('menuitem', { name: /previou Previous application/ }))
+
+ expect(screen.getByRole('checkbox', { name: 'API' })).toBeChecked()
+ expect(selector).toHaveTextContent('previou')
+ expect(screen.getByRole('button', { name: 'Update 2 services' })).toBeEnabled()
+ })
+
+ it('sizes version icons with their adjacent text', () => {
+ renderWithProviders()
+
+ const selector = screen.getByRole('button', { name: 'Select a version for Infrastructure' })
+ const row = selector.closest('[data-testid="service-version-row"]')
+
+ expect(selector.querySelector('.fa-code-commit')).toHaveClass('text-ssm')
+ expect(selector.querySelector('.fa-chevron-down')).toHaveClass('text-ssm')
+ expect(selector.querySelector('.fa-chevron-down')).not.toHaveClass('text-neutral-subtle')
+ expect(selector.querySelector('.fa-chevron-down')).toHaveClass('group-data-[state=open]:rotate-180')
+ expect(row?.querySelector('.fa-arrow-right')).toHaveClass('text-ssm')
+ })
+
+ it('lets version controls hug their content without width constraints', () => {
+ renderWithProviders()
+
+ const selector = screen.getByRole('button', { name: 'Select a version for Infrastructure' })
+ const row = selector.closest('[data-testid="service-version-row"]')
+ const currentVersion = screen.getByText('old-ter').closest('[class~="h-6"]')
+
+ expect(selector).not.toHaveClass('min-w-28', 'max-w-44')
+ expect(row?.querySelector('.max-w-36')).not.toBeInTheDocument()
+ expect(currentVersion).toHaveClass('px-1.5')
+ })
+
+ it('renders commit details using the Figma dropdown hierarchy', async () => {
+ const { userEvent } = renderWithProviders()
+
+ const selector = screen.getByRole('button', { name: 'Select a version for Infrastructure' })
+ await userEvent.click(selector)
+
+ expect(selector).toHaveAttribute('data-state', 'open')
+ expect(screen.getByRole('menuitem', { name: /new-ter Update infrastructure/ })).toHaveClass(
+ 'hover:bg-surface-neutral-subtle'
+ )
+ expect(screen.getByText('Update infrastructure')).toBeInTheDocument()
+ expect(screen.queryByText('main')).not.toBeInTheDocument()
+ expect(screen.getAllByText('Latest').length).toBeGreaterThan(0)
+ expect(screen.getByTestId('version-options')).toHaveClass('z-dropdown')
+ expect(screen.getByTestId('version-options')).not.toHaveClass('font-code', 'font-mono')
+ expect(screen.getByTestId('version-options').querySelector('.fa-code-commit')).not.toBeInTheDocument()
+ })
+
+ it('renders complete non-Git versions and prevents selecting the mutable latest tag', async () => {
+ mockServices = [
+ {
+ id: 'container',
+ name: 'Container',
+ iconUri: 'app://qovery-console/container',
+ serviceType: 'CONTAINER',
+ sourceType: 'container',
+ currentVersion: 'release-2026-09-alpha',
+ containerRegistryId: 'registry',
+ imageName: 'image',
+ versions: [
+ { value: 'release-2026-09-alpha' },
+ { value: 'release-2026-09-beta' },
+ {
+ value: 'latest',
+ isDisabled: true,
+ disabledReason: 'Image tag cannot be latest to ensure consistent deployment',
+ },
+ ],
+ isSkipped: false,
+ },
+ ]
+ const { userEvent } = renderWithProviders()
+ const checkbox = screen.getByRole('checkbox', { name: 'Container' })
+
+ await userEvent.click(screen.getByRole('button', { name: 'Select a version for Container' }))
+
+ expect(screen.getByRole('menuitem', { name: 'release-2026-09-alpha' })).toBeInTheDocument()
+ expect(screen.getByRole('menuitem', { name: 'release-2026-09-beta' })).toBeInTheDocument()
+ const latest = screen.getByRole('menuitem', {
+ name: 'latest Image tag cannot be latest to ensure consistent deployment',
+ })
+ expect(latest).toHaveAttribute('data-disabled')
+
+ await userEvent.click(latest)
+
+ expect(checkbox).not.toBeChecked()
+ })
+
+ it('renders every fetched version in a natively scrollable four-row menu', async () => {
+ mockServices = [
+ {
+ ...defaultServices[0],
+ versions: Array.from({ length: 6 }, (_, index) => ({
+ value: `commit-${index}`,
+ message: `Commit message ${index}`,
+ })),
+ },
+ ]
+ const { userEvent } = renderWithProviders()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Select a version for Infrastructure' }))
+
+ const versionItems = screen.getAllByRole('menuitem')
+ expect(versionItems).toHaveLength(6)
+ versionItems.forEach((item) => expect(item).toHaveClass('h-[62px]', 'shrink-0'))
+ expect(screen.getByTestId('version-options')).toHaveClass(
+ 'max-h-[248px]',
+ 'overflow-x-hidden',
+ 'overflow-y-auto',
+ 'overscroll-contain'
+ )
+ })
+
+ it('selects a different version from the dropdown', async () => {
+ const { userEvent } = renderWithProviders()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Select a version for Infrastructure' }))
+ await userEvent.click(screen.getByRole('menuitem', { name: /old-ter Current infrastructure/ }))
+
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Select a version for Infrastructure' })).toHaveTextContent('old-ter')
+
+ await userEvent.click(screen.getByRole('button', { name: 'Update 1 service' }))
+
+ expect(mockDeployAllServices).toHaveBeenCalledWith(
+ {
+ environment: mockEnvironment,
+ payload: { terraforms: [{ id: 'terraform', git_commit_id: 'old-terraform-commit' }] },
+ },
+ { onSuccess: mockCloseModal }
+ )
+ })
+
+ it('locks modal dismissal and prevents duplicate submission while deployment is pending', async () => {
+ mockDeployAllServices.mockImplementation(() => undefined)
+ const { rerender, userEvent } = renderWithProviders()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Update 1 service' }))
+ expect(mockDeployAllServices).toHaveBeenCalledTimes(1)
+ expect(mockSetModalDismissible).toHaveBeenLastCalledWith(false)
+
+ mockDeployIsLoading = true
+ rerender()
+
+ const submit = screen.getByRole('button', { name: 'Update 1 service' })
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled()
+ expect(submit).toBeDisabled()
+ submit.focus()
+ await userEvent.keyboard('{Enter}')
+ expect(mockDeployAllServices).toHaveBeenCalledTimes(1)
+ expect(mockCloseModal).not.toHaveBeenCalled()
+
+ mockDeployIsLoading = false
+ rerender()
+ expect(mockSetModalDismissible).toHaveBeenLastCalledWith(true)
+ })
+
+ it('does not show services without selectable versions', () => {
+ renderWithProviders()
+
+ expect(screen.getAllByTestId('service-version-row')).toHaveLength(2)
+ expect(screen.queryByText('Unavailable service')).not.toBeInTheDocument()
+ })
+
+ it('keeps the actions visible while the modal content scrolls', () => {
+ renderWithProviders()
+
+ const modalMainContent = screen.getByTestId('modal-main-content')
+ const form = modalMainContent.closest('form')
+ const actions = screen.getByRole('button', { name: 'Cancel' }).parentElement
+
+ expect(form).toHaveClass('flex', 'max-h-[80vh]', 'flex-col')
+ expect(modalMainContent).toHaveClass('min-h-0', 'flex-1', 'overflow-y-auto', 'p-6')
+ expect(actions).toHaveClass('shrink-0', 'border-t', 'bg-background')
+ expect(modalMainContent).toContainElement(screen.getByRole('heading', { name: 'Deploy by version' }))
+ expect(modalMainContent).toContainElement(screen.getByText('1 outdated service'))
+ expect(modalMainContent).toContainElement(screen.getByText('1 up to date service'))
+ expect(screen.queryByTestId('scroll-shadow-wrapper')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('scroll-shadow-bottom')).not.toBeInTheDocument()
+ })
+
+ it('shows an error when every version lookup failed', () => {
+ mockServices = [unavailableService]
+
+ renderWithProviders()
+
+ expect(screen.getByText('Service versions could not be loaded.')).toBeInTheDocument()
+ expect(screen.queryByText('Unavailable service')).not.toBeInTheDocument()
+ })
+
+ it('renders the dedicated empty state when only excluded services remain', () => {
+ mockServices = []
+
+ renderWithProviders()
+
+ expect(screen.getByText('No services support version selection.')).toBeInTheDocument()
+ })
+})
diff --git a/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.tsx b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.tsx
new file mode 100644
index 00000000000..bca385b9712
--- /dev/null
+++ b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version-modal.tsx
@@ -0,0 +1,238 @@
+import { type Environment } from 'qovery-typescript-axios'
+import { useEffect, useMemo, useState } from 'react'
+import { Button, Icon, LoaderSpinner, useModal } from '@qovery/shared/ui'
+import { useDeployAllServices } from '../hooks/use-deploy-all-services/use-deploy-all-services'
+import {
+ type DeployByVersionService,
+ type ServiceVersionSelections,
+ buildDeployByVersionPayload,
+ countDeployByVersionServices,
+ createInitialSelections,
+ getFirstDeployableVersion,
+} from './deploy-by-version'
+import { ServiceVersionRow } from './service-version-row'
+import { useDeployByVersionServices } from './use-deploy-by-version-services'
+
+export interface DeployByVersionModalProps {
+ environment: Environment
+}
+
+function ServiceSection({
+ title,
+ services,
+ selections,
+ action,
+ onToggle,
+ onVersionChange,
+}: {
+ title: string
+ services: DeployByVersionService[]
+ selections: ServiceVersionSelections
+ action?: React.ReactNode
+ onToggle: (serviceId: string) => void
+ onVersionChange: (serviceId: string, version: string) => void
+}) {
+ if (services.length === 0) return null
+
+ return (
+
+
+
+ {services.length} {title}
+
+ {action}
+
+
+ {services.map((service) => (
+ onToggle(service.id)}
+ onVersionChange={(version) => onVersionChange(service.id, version)}
+ />
+ ))}
+
+
+ )
+}
+
+function DeployByVersionHeader() {
+ return (
+
+
Deploy by version
+
+ Select the service and choose a specific version to deploy on each of those
+
+
+ )
+}
+
+function DeployByVersionForm({
+ environment,
+ services,
+}: DeployByVersionModalProps & { services: DeployByVersionService[] }) {
+ const { closeModal, setModalDismissible } = useModal()
+ const { mutate: deployAllServices, isLoading } = useDeployAllServices()
+ const [selections, setSelections] = useState(() => createInitialSelections(services))
+
+ const outdatedServices = services.filter(
+ (service) => getFirstDeployableVersion(service)?.value !== service.currentVersion
+ )
+ const upToDateServices = services.filter(
+ (service) => getFirstDeployableVersion(service)?.value === service.currentVersion
+ )
+ const payload = useMemo(() => buildDeployByVersionPayload(services, selections), [selections, services])
+ const selectedCount = countDeployByVersionServices(payload)
+ const allOutdatedSelected = outdatedServices
+ .filter(({ isSkipped }) => !isSkipped)
+ .every(({ id }) => selections[id].selected)
+
+ useEffect(() => {
+ setModalDismissible(!isLoading)
+ }, [isLoading, setModalDismissible])
+
+ useEffect(
+ () => () => {
+ setModalDismissible(true)
+ },
+ [setModalDismissible]
+ )
+
+ const toggleService = (serviceId: string) => {
+ setSelections((current) => ({
+ ...current,
+ [serviceId]: { ...current[serviceId], selected: !current[serviceId].selected },
+ }))
+ }
+
+ const changeVersion = (serviceId: string, version: string) => {
+ setSelections((current) => ({
+ ...current,
+ [serviceId]: { ...current[serviceId], selected: true, version },
+ }))
+ }
+
+ const toggleOutdatedServices = () => {
+ setSelections((current) =>
+ Object.fromEntries(
+ Object.entries(current).map(([serviceId, selection]) => {
+ const service = outdatedServices.find(({ id }) => id === serviceId)
+ return [
+ serviceId,
+ service && !service.isSkipped ? { ...selection, selected: !allOutdatedSelected } : selection,
+ ]
+ })
+ )
+ )
+ }
+
+ const onSubmit = (event: React.FormEvent) => {
+ event.preventDefault()
+ if (selectedCount === 0 || isLoading) return
+ setModalDismissible(false)
+ deployAllServices(
+ { environment, payload },
+ {
+ onSuccess: closeModal,
+ }
+ )
+ }
+
+ return (
+
+ )
+}
+
+export function DeployByVersionModal({ environment }: DeployByVersionModalProps) {
+ const { closeModal } = useModal()
+ const {
+ data: services,
+ isLoading,
+ isError,
+ } = useDeployByVersionServices({
+ environmentId: environment.id,
+ organizationId: environment.organization.id,
+ })
+ const servicesWithVersions = services.filter((service) => getFirstDeployableVersion(service))
+ const hasVersionErrors = services.some(({ hasVersionError }) => hasVersionError)
+
+ const formKey = servicesWithVersions
+ .map((service) =>
+ [service.id, service.currentVersion, getFirstDeployableVersion(service)?.value, service.isSkipped].join(':')
+ )
+ .join('|')
+
+ if (!isLoading && !isError && servicesWithVersions.length > 0) {
+ return
+ }
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+ ) : isError || (servicesWithVersions.length === 0 && hasVersionErrors) ? (
+
+
+
Service versions could not be loaded.
+
Close the modal and try again.
+
+
+ ) : servicesWithVersions.length === 0 ? (
+
+
+
No services support version selection.
+
+ Blueprint, database, Argo CD, and agentic workflow services are not included.
+
+
+
+ ) : null}
+
+ )
+}
+
+export default DeployByVersionModal
diff --git a/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.spec.ts b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.spec.ts
new file mode 100644
index 00000000000..0fb4ba493d8
--- /dev/null
+++ b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.spec.ts
@@ -0,0 +1,206 @@
+import { type Commit } from 'qovery-typescript-axios'
+import { helmFactoryMock, terraformFactoryMock } from '@qovery/shared/factories'
+import {
+ type DeployByVersionService,
+ buildDeployByVersionPayload,
+ commitsToVersionOptions,
+ containerVersionsToOptions,
+ countDeployByVersionServices,
+ createInitialSelections,
+ sortVersions,
+ toVersionedService,
+ versionsToOptions,
+} from './deploy-by-version'
+
+const services: DeployByVersionService[] = [
+ {
+ id: 'application',
+ name: 'Application',
+ iconUri: 'app://qovery-console/application',
+ serviceType: 'APPLICATION',
+ sourceType: 'git',
+ currentVersion: 'app-old',
+ versions: [{ value: 'app-new' }, { value: 'app-old' }],
+ isSkipped: false,
+ },
+ {
+ id: 'container',
+ name: 'Container',
+ iconUri: 'app://qovery-console/container',
+ serviceType: 'CONTAINER',
+ sourceType: 'container',
+ currentVersion: '1.0.0',
+ containerRegistryId: 'registry',
+ imageName: 'nginx',
+ versions: [{ value: '2.0.0' }, { value: '1.0.0' }],
+ isSkipped: false,
+ },
+ {
+ id: 'git-job',
+ name: 'Git job',
+ iconUri: 'app://qovery-console/cron-job',
+ serviceType: 'JOB',
+ sourceType: 'git',
+ jobType: 'CRON',
+ currentVersion: 'job-old',
+ versions: [{ value: 'job-new' }],
+ isSkipped: false,
+ },
+ {
+ id: 'container-job',
+ name: 'Container job',
+ iconUri: 'app://qovery-console/lifecycle-job',
+ serviceType: 'JOB',
+ sourceType: 'container',
+ jobType: 'LIFECYCLE',
+ currentVersion: '1.0.0',
+ containerRegistryId: 'registry',
+ imageName: 'job',
+ versions: [{ value: '2.0.0' }],
+ isSkipped: false,
+ },
+ {
+ id: 'git-helm',
+ name: 'Git Helm',
+ iconUri: 'app://qovery-console/helm',
+ serviceType: 'HELM',
+ sourceType: 'git',
+ currentVersion: 'helm-old',
+ versions: [{ value: 'helm-new' }],
+ isSkipped: false,
+ },
+ {
+ id: 'repository-helm',
+ name: 'Repository Helm',
+ iconUri: 'app://qovery-console/helm',
+ serviceType: 'HELM',
+ sourceType: 'helm-repository',
+ currentVersion: '1.0.0',
+ helmRepositoryId: 'helm-repository',
+ chartName: 'chart',
+ versions: [{ value: '2.0.0' }],
+ isSkipped: false,
+ },
+ {
+ id: 'terraform',
+ name: 'Terraform',
+ iconUri: 'app://qovery-console/terraform',
+ serviceType: 'TERRAFORM',
+ sourceType: 'git',
+ currentVersion: 'terraform-old',
+ versions: [{ value: 'terraform-new' }],
+ isSkipped: false,
+ },
+ {
+ id: 'skipped-terraform',
+ name: 'Skipped Terraform',
+ iconUri: 'app://qovery-console/terraform',
+ serviceType: 'TERRAFORM',
+ sourceType: 'git',
+ currentVersion: 'terraform-old',
+ versions: [{ value: 'terraform-new' }],
+ isSkipped: true,
+ },
+]
+
+describe('deploy by version', () => {
+ it('builds a typed payload for every supported source, including Terraform', () => {
+ const selections = createInitialSelections(services)
+ expect(selections['skipped-terraform'].selected).toBe(false)
+ selections['skipped-terraform'].selected = true
+ const payload = buildDeployByVersionPayload(services, selections)
+
+ expect(payload).toEqual({
+ applications: [{ application_id: 'application', git_commit_id: 'app-new' }],
+ containers: [{ id: 'container', image_tag: '2.0.0' }],
+ jobs: [
+ { id: 'git-job', git_commit_id: 'job-new' },
+ { id: 'container-job', image_tag: '2.0.0' },
+ ],
+ helms: [
+ { id: 'git-helm', git_commit_id: 'helm-new' },
+ { id: 'repository-helm', chart_version: '2.0.0' },
+ ],
+ terraforms: [{ id: 'terraform', git_commit_id: 'terraform-new' }],
+ })
+ expect(countDeployByVersionServices(payload)).toBe(7)
+ expect(payload.terraforms).not.toContainEqual(expect.objectContaining({ id: 'skipped-terraform' }))
+ })
+
+ it('excludes all blueprint-backed services before source classification', () => {
+ const terraform = { ...terraformFactoryMock(1)[0], blueprint_id: 'blueprint-id' }
+ const helm = { ...helmFactoryMock(1)[0], blueprint_id: 'blueprint-id' }
+
+ expect(toVersionedService(terraform)).toBeUndefined()
+ expect(toVersionedService(helm)).toBeUndefined()
+ })
+
+ it('includes a regular Terraform service and reads its deployed commit', () => {
+ const terraform = terraformFactoryMock(1)[0]
+ const gitRepository = terraform.terraform_files_source?.git?.git_repository
+ if (!gitRepository) throw new Error('The Terraform factory must have a Git repository')
+ gitRepository.deployed_commit_id = 'deployed-commit'
+
+ expect(toVersionedService(terraform)).toMatchObject({
+ id: terraform.id,
+ serviceType: 'TERRAFORM',
+ sourceType: 'git',
+ currentVersion: 'deployed-commit',
+ })
+ })
+
+ it('keeps API version arrays immutable while sorting semantic versions first', () => {
+ const versions = ['latest', '1.9.0', 'v2.0.0']
+
+ expect(sortVersions(versions)).toEqual(['v2.0.0', '1.9.0', 'latest'])
+ expect(versions).toEqual(['latest', '1.9.0', 'v2.0.0'])
+ })
+
+ it('disables the mutable latest tag only for container versions', () => {
+ expect(containerVersionsToOptions(['latest', '1.0.0'])).toEqual([
+ { value: '1.0.0' },
+ {
+ value: 'latest',
+ isDisabled: true,
+ disabledReason: 'Image tag cannot be latest to ensure consistent deployment',
+ },
+ ])
+ expect(versionsToOptions(['latest'])).toEqual([{ value: 'latest' }])
+ })
+
+ it('selects the first deployable version and rejects disabled selections from the payload', () => {
+ const container = {
+ ...services[1],
+ versions: [{ value: 'latest', isDisabled: true }, { value: '2.0.0' }],
+ } satisfies DeployByVersionService
+ const containerJob = {
+ ...services[3],
+ versions: [{ value: 'latest', isDisabled: true }],
+ } satisfies DeployByVersionService
+ const selections = createInitialSelections([container, containerJob])
+
+ expect(selections).toEqual({
+ container: { selected: true, version: '2.0.0' },
+ 'container-job': { selected: false, version: '' },
+ })
+
+ selections.container = { selected: true, version: 'latest' }
+ selections['container-job'] = { selected: true, version: 'latest' }
+
+ expect(buildDeployByVersionPayload([container, containerJob], selections)).toEqual({})
+ })
+
+ it('preserves commit metadata for the version menu', () => {
+ const commit: Commit = {
+ git_commit_id: 'abc123',
+ created_at: '2026-01-01T00:00:00Z',
+ message: 'Add deployment support',
+ tag: 'v1.0.0',
+ author_name: 'Ada',
+ }
+
+ expect(commitsToVersionOptions([commit])).toEqual([
+ { value: 'abc123', message: 'Add deployment support', tag: 'v1.0.0' },
+ ])
+ })
+})
diff --git a/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.ts b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.ts
new file mode 100644
index 00000000000..ca4934ee053
--- /dev/null
+++ b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/deploy-by-version.ts
@@ -0,0 +1,276 @@
+import { type Commit, type DeployAllRequest } from 'qovery-typescript-axios'
+import { match } from 'ts-pattern'
+import {
+ type AnyService,
+ isApplication,
+ isBlueprintService,
+ isContainer,
+ isHelm,
+ isJob,
+} from '@qovery/domains/services/data-access'
+import { isHelmGitSource, isHelmRepositorySource, isJobContainerSource, isJobGitSource } from '@qovery/shared/enums'
+
+interface VersionedServiceBase {
+ id: string
+ name: string
+ iconUri: string
+ currentVersion?: string
+ jobType?: 'CRON' | 'LIFECYCLE'
+}
+
+export type GitVersionedService = VersionedServiceBase & {
+ sourceType: 'git'
+ serviceType: 'APPLICATION' | 'JOB' | 'HELM' | 'TERRAFORM'
+}
+
+export type ContainerVersionedService = VersionedServiceBase & {
+ sourceType: 'container'
+ serviceType: 'CONTAINER' | 'JOB'
+ containerRegistryId: string
+ imageName: string
+}
+
+export type HelmRepositoryVersionedService = VersionedServiceBase & {
+ sourceType: 'helm-repository'
+ serviceType: 'HELM'
+ helmRepositoryId: string
+ chartName: string
+}
+
+export type VersionedService = GitVersionedService | ContainerVersionedService | HelmRepositoryVersionedService
+
+export interface VersionOption {
+ value: string
+ message?: string
+ tag?: string
+ isDisabled?: boolean
+ disabledReason?: string
+}
+
+export type DeployByVersionService = VersionedService & {
+ versions: VersionOption[]
+ isSkipped: boolean
+ hasVersionError?: boolean
+}
+
+export interface ServiceVersionSelection {
+ selected: boolean
+ version: string
+}
+
+export type ServiceVersionSelections = Record
+
+function baseService(service: AnyService): VersionedServiceBase {
+ return {
+ id: service.id,
+ name: service.name,
+ iconUri: service.icon_uri,
+ jobType: isJob(service) ? service.job_type : undefined,
+ }
+}
+
+export function toVersionedService(service: AnyService): VersionedService | undefined {
+ // Blueprint-backed services are deployed through their blueprint flow.
+ if (isBlueprintService(service)) return undefined
+
+ if (isApplication(service)) {
+ if (!service.git_repository) return undefined
+
+ return {
+ ...baseService(service),
+ sourceType: 'git',
+ serviceType: 'APPLICATION',
+ currentVersion: service.git_repository.deployed_commit_id,
+ }
+ }
+
+ if (isContainer(service)) {
+ const containerRegistryId = service.registry_id ?? service.registry.id
+ if (!containerRegistryId) return undefined
+
+ return {
+ ...baseService(service),
+ sourceType: 'container',
+ serviceType: 'CONTAINER',
+ currentVersion: service.tag,
+ containerRegistryId,
+ imageName: service.image_name,
+ }
+ }
+
+ if (isJob(service)) {
+ if (isJobGitSource(service.source) && service.source.docker?.git_repository) {
+ return {
+ ...baseService(service),
+ sourceType: 'git',
+ serviceType: 'JOB',
+ currentVersion: service.source.docker.git_repository.deployed_commit_id,
+ }
+ }
+
+ if (isJobContainerSource(service.source)) {
+ const containerRegistryId = service.source.image.registry_id ?? service.source.image.registry.id
+ if (!containerRegistryId) return undefined
+
+ return {
+ ...baseService(service),
+ sourceType: 'container',
+ serviceType: 'JOB',
+ currentVersion: service.source.image.tag,
+ containerRegistryId,
+ imageName: service.source.image.image_name,
+ }
+ }
+
+ return undefined
+ }
+
+ if (isHelm(service)) {
+ if (isHelmGitSource(service.source) && service.source.git?.git_repository) {
+ return {
+ ...baseService(service),
+ sourceType: 'git',
+ serviceType: 'HELM',
+ currentVersion: service.source.git.git_repository.deployed_commit_id,
+ }
+ }
+
+ if (isHelmRepositorySource(service.source) && service.source.repository) {
+ return {
+ ...baseService(service),
+ sourceType: 'helm-repository',
+ serviceType: 'HELM',
+ currentVersion: service.source.repository.chart_version,
+ helmRepositoryId: service.source.repository.repository.id,
+ chartName: service.source.repository.chart_name,
+ }
+ }
+
+ return undefined
+ }
+
+ if (service.serviceType === 'TERRAFORM') {
+ const gitRepository = service.terraform_files_source?.git?.git_repository
+ if (!gitRepository) return undefined
+
+ return {
+ ...baseService(service),
+ sourceType: 'git',
+ serviceType: 'TERRAFORM',
+ currentVersion: gitRepository.deployed_commit_id,
+ }
+ }
+
+ return undefined
+}
+
+export function commitsToVersionOptions(commits: Commit[]): VersionOption[] {
+ return commits.map(({ git_commit_id, message, tag }) => ({
+ value: git_commit_id,
+ message,
+ tag,
+ }))
+}
+
+export function sortVersions(versions: string[]): string[] {
+ const semanticVersion = /^v?\d+\.\d+\.\d+/
+
+ return [...versions].sort((a, b) => {
+ const aIsSemantic = semanticVersion.test(a)
+ const bIsSemantic = semanticVersion.test(b)
+
+ if (aIsSemantic && bIsSemantic) {
+ return b.replace(/^v/i, '').localeCompare(a.replace(/^v/i, ''), undefined, { numeric: true })
+ }
+ if (aIsSemantic) return -1
+ if (bIsSemantic) return 1
+ return a.localeCompare(b)
+ })
+}
+
+export function versionsToOptions(versions: string[]): VersionOption[] {
+ return sortVersions(versions).map((value) => ({ value }))
+}
+
+export function containerVersionsToOptions(versions: string[]): VersionOption[] {
+ return versionsToOptions(versions).map((option) =>
+ option.value === 'latest'
+ ? {
+ ...option,
+ isDisabled: true,
+ disabledReason: 'Image tag cannot be latest to ensure consistent deployment',
+ }
+ : option
+ )
+}
+
+export function getFirstDeployableVersion(service: DeployByVersionService): VersionOption | undefined {
+ return service.versions.find(({ isDisabled }) => !isDisabled)
+}
+
+export function createInitialSelections(services: DeployByVersionService[]): ServiceVersionSelections {
+ return Object.fromEntries(
+ services.map((service) => {
+ const latestVersion = getFirstDeployableVersion(service)?.value ?? ''
+ return [
+ service.id,
+ {
+ selected: !service.isSkipped && Boolean(latestVersion) && latestVersion !== service.currentVersion,
+ version: latestVersion,
+ },
+ ]
+ })
+ )
+}
+
+export function buildDeployByVersionPayload(
+ services: DeployByVersionService[],
+ selections: ServiceVersionSelections
+): DeployAllRequest {
+ return services.reduce((payload, service) => {
+ const selection = selections[service.id]
+ const selectedVersion = service.versions.find(({ value }) => value === selection?.version)
+ if (!selection?.selected || !selectedVersion || selectedVersion.isDisabled || service.isSkipped) return payload
+
+ return match(service)
+ .with({ serviceType: 'APPLICATION', sourceType: 'git' }, ({ id }) => ({
+ ...payload,
+ applications: [...(payload.applications ?? []), { application_id: id, git_commit_id: selection.version }],
+ }))
+ .with({ serviceType: 'CONTAINER', sourceType: 'container' }, ({ id }) => ({
+ ...payload,
+ containers: [...(payload.containers ?? []), { id, image_tag: selection.version }],
+ }))
+ .with({ serviceType: 'JOB', sourceType: 'git' }, ({ id }) => ({
+ ...payload,
+ jobs: [...(payload.jobs ?? []), { id, git_commit_id: selection.version }],
+ }))
+ .with({ serviceType: 'JOB', sourceType: 'container' }, ({ id }) => ({
+ ...payload,
+ jobs: [...(payload.jobs ?? []), { id, image_tag: selection.version }],
+ }))
+ .with({ serviceType: 'HELM', sourceType: 'git' }, ({ id }) => ({
+ ...payload,
+ helms: [...(payload.helms ?? []), { id, git_commit_id: selection.version }],
+ }))
+ .with({ serviceType: 'HELM', sourceType: 'helm-repository' }, ({ id }) => ({
+ ...payload,
+ helms: [...(payload.helms ?? []), { id, chart_version: selection.version }],
+ }))
+ .with({ serviceType: 'TERRAFORM', sourceType: 'git' }, ({ id }) => ({
+ ...payload,
+ terraforms: [...(payload.terraforms ?? []), { id, git_commit_id: selection.version }],
+ }))
+ .exhaustive()
+ }, {})
+}
+
+export function countDeployByVersionServices(payload: DeployAllRequest): number {
+ return (
+ (payload.applications?.length ?? 0) +
+ (payload.containers?.length ?? 0) +
+ (payload.jobs?.length ?? 0) +
+ (payload.helms?.length ?? 0) +
+ (payload.terraforms?.length ?? 0)
+ )
+}
diff --git a/libs/domains/environments/feature/src/lib/deploy-by-version-modal/service-version-row.tsx b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/service-version-row.tsx
new file mode 100644
index 00000000000..af7f1722cff
--- /dev/null
+++ b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/service-version-row.tsx
@@ -0,0 +1,230 @@
+// eslint-disable-next-line @nx/enforce-module-boundaries
+import { ServiceAvatar } from '@qovery/domains/services/feature'
+import { Button, Checkbox, DropdownMenu, Icon, Tooltip, Truncate } from '@qovery/shared/ui'
+import { twMerge } from '@qovery/shared/util-js'
+import {
+ type DeployByVersionService,
+ type ServiceVersionSelections,
+ type VersionOption,
+ getFirstDeployableVersion,
+} from './deploy-by-version'
+
+function Version({ service, value, latest }: { service: DeployByVersionService; value?: string; latest?: boolean }) {
+ if (!value) return Not deployed
+
+ return (
+
+ {service.sourceType === 'git' && (
+
+ )}
+
+ {latest ? 'Latest' : service.sourceType === 'git' ? value.slice(0, 7) : value}
+
+
+ )
+}
+
+function VersionServiceAvatar({ service }: { service: DeployByVersionService }) {
+ const props = {
+ size: 'custom' as const,
+ radius: 'none' as const,
+ serviceAvatarRadius: 'sm' as const,
+ className: 'h-5 w-5',
+ }
+
+ if (service.serviceType === 'JOB') {
+ if (!service.jobType) return null
+ return (
+
+ )
+ }
+
+ return
+}
+
+function VersionOptionButton({
+ option,
+ isGit,
+ selected,
+ onChange,
+}: {
+ option: VersionOption
+ isGit: boolean
+ selected: boolean
+ onChange: (version: string) => void
+}) {
+ return (
+ onChange(option.value)}
+ >
+
+ {isGit ? option.value.slice(0, 7) : option.value}
+ {selected && }
+
+ {(option.message || option.disabledReason) && (
+
+ {option.message ?? option.disabledReason}
+
+ )}
+
+ )
+}
+
+function VersionOptions({
+ service,
+ value,
+ onChange,
+}: {
+ service: DeployByVersionService
+ value: string
+ onChange: (version: string) => void
+}) {
+ return service.versions.map((option) => (
+
+ ))
+}
+
+function VersionSelector({
+ service,
+ value,
+ selected,
+ disabled,
+ onChange,
+}: {
+ service: DeployByVersionService
+ value: string
+ selected: boolean
+ disabled: boolean
+ onChange: (version: string) => void
+}) {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+export function ServiceVersionRow({
+ service,
+ selection,
+ onToggle,
+ onVersionChange,
+}: {
+ service: DeployByVersionService
+ selection: ServiceVersionSelections[string]
+ onToggle: () => void
+ onVersionChange: (version: string) => void
+}) {
+ const hasVersions = Boolean(getFirstDeployableVersion(service))
+ const disabled = service.isSkipped || !hasVersions
+ const tooltip = service.isSkipped
+ ? 'This service is skipped and cannot be deployed at environment level.'
+ : service.hasVersionError
+ ? 'Versions could not be loaded for this service.'
+ : 'No versions are available for this service.'
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/libs/domains/environments/feature/src/lib/deploy-by-version-modal/use-deploy-by-version-services.ts b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/use-deploy-by-version-services.ts
new file mode 100644
index 00000000000..90c68554ee1
--- /dev/null
+++ b/libs/domains/environments/feature/src/lib/deploy-by-version-modal/use-deploy-by-version-services.ts
@@ -0,0 +1,150 @@
+import { useQueries, useQuery } from '@tanstack/react-query'
+import { useMemo } from 'react'
+import { match } from 'ts-pattern'
+import { queries } from '@qovery/state/util-queries'
+import { useListDeploymentStages } from '../hooks/use-list-deployment-stages/use-list-deployment-stages'
+import {
+ type ContainerVersionedService,
+ type DeployByVersionService,
+ type GitVersionedService,
+ type HelmRepositoryVersionedService,
+ type VersionedService,
+ commitsToVersionOptions,
+ containerVersionsToOptions,
+ toVersionedService,
+ versionsToOptions,
+} from './deploy-by-version'
+
+interface UseDeployByVersionServicesProps {
+ environmentId: string
+ organizationId: string
+}
+
+const queryOptions = {
+ staleTime: 3 * 60 * 1000,
+ retry: false,
+ retryOnMount: true,
+ refetchOnWindowFocus: false,
+} as const
+
+function isGitService(service: VersionedService): service is GitVersionedService {
+ return service.sourceType === 'git'
+}
+
+function isContainerService(service: VersionedService): service is ContainerVersionedService {
+ return service.sourceType === 'container'
+}
+
+function isHelmRepositoryService(service: VersionedService): service is HelmRepositoryVersionedService {
+ return service.sourceType === 'helm-repository'
+}
+
+export function useDeployByVersionServices({ environmentId, organizationId }: UseDeployByVersionServicesProps) {
+ const servicesQuery = useQuery({
+ ...queries.services.list(environmentId),
+ refetchOnWindowFocus: false,
+ })
+ const deploymentStagesQuery = useListDeploymentStages({ environmentId })
+
+ const versionedServices = useMemo(
+ () => servicesQuery.data?.map(toVersionedService).filter((service) => service !== undefined) ?? [],
+ [servicesQuery.data]
+ )
+ const gitServices = versionedServices.filter(isGitService)
+ const containerServices = versionedServices.filter(isContainerService)
+ const helmRepositoryServices = versionedServices.filter(isHelmRepositoryService)
+
+ const gitQueries = useQueries({
+ queries: gitServices.map((service) => ({
+ ...match(service.serviceType)
+ .with('HELM', () => queries.services.listCommits({ serviceId: service.id, serviceType: 'HELM', of: 'chart' }))
+ .with('APPLICATION', () => queries.services.listCommits({ serviceId: service.id, serviceType: 'APPLICATION' }))
+ .with('JOB', () => queries.services.listCommits({ serviceId: service.id, serviceType: 'JOB' }))
+ .with('TERRAFORM', () => queries.services.listCommits({ serviceId: service.id, serviceType: 'TERRAFORM' }))
+ .exhaustive(),
+ ...queryOptions,
+ })),
+ })
+
+ const containerQueries = useQueries({
+ queries: containerServices.map(({ containerRegistryId, imageName }) => ({
+ ...queries.organizations.containerVersions({ organizationId, containerRegistryId, imageName }),
+ ...queryOptions,
+ })),
+ })
+
+ const helmRepositoryQueries = useQueries({
+ queries: helmRepositoryServices.map(({ helmRepositoryId, chartName }) => ({
+ ...queries.serviceHelm.helmCharts({ organizationId, helmRepositoryId, chartName }),
+ ...queryOptions,
+ })),
+ })
+
+ const data = useMemo(() => {
+ const skippedServiceIds = new Set(
+ deploymentStagesQuery.data?.flatMap(({ services = [] }) =>
+ services.filter(({ is_skipped }) => is_skipped).map(({ service_id }) => service_id)
+ ) ?? []
+ )
+
+ const gitResults = new Map(gitServices.map((service, index) => [service.id, gitQueries[index]]))
+ const containerResults = new Map(containerServices.map((service, index) => [service.id, containerQueries[index]]))
+ const helmResults = new Map(
+ helmRepositoryServices.map((service, index) => [service.id, helmRepositoryQueries[index]])
+ )
+
+ return versionedServices.map((service): DeployByVersionService => {
+ const result = match(service)
+ .with({ sourceType: 'git' }, ({ id }) => {
+ const query = gitResults.get(id)
+ return {
+ versions: commitsToVersionOptions(query?.data ?? []),
+ hasVersionError: query?.isError,
+ }
+ })
+ .with({ sourceType: 'container' }, ({ id, imageName }) => {
+ const query = containerResults.get(id)
+ const versions = query?.data?.find(({ image_name }) => image_name === imageName)?.versions ?? []
+ return {
+ versions: containerVersionsToOptions(versions),
+ hasVersionError: query?.isError,
+ }
+ })
+ .with({ sourceType: 'helm-repository' }, ({ id, chartName }) => {
+ const query = helmResults.get(id)
+ const versions = query?.data?.find(({ chart_name }) => chart_name === chartName)?.versions ?? []
+ return {
+ versions: versionsToOptions(versions),
+ hasVersionError: query?.isError,
+ }
+ })
+ .exhaustive()
+
+ return {
+ ...service,
+ ...result,
+ isSkipped: skippedServiceIds.has(service.id),
+ }
+ })
+ }, [
+ containerQueries,
+ containerServices,
+ deploymentStagesQuery.data,
+ gitQueries,
+ gitServices,
+ helmRepositoryQueries,
+ helmRepositoryServices,
+ versionedServices,
+ ])
+
+ return {
+ data,
+ isLoading:
+ servicesQuery.isLoading ||
+ deploymentStagesQuery.isLoading ||
+ gitQueries.some(({ isLoading }) => isLoading) ||
+ containerQueries.some(({ isLoading }) => isLoading) ||
+ helmRepositoryQueries.some(({ isLoading }) => isLoading),
+ isError: servicesQuery.isError || deploymentStagesQuery.isError,
+ }
+}
diff --git a/libs/domains/environments/feature/src/lib/environment-action-toolbar/__snapshots__/environment-action-toolbar.spec.tsx.snap b/libs/domains/environments/feature/src/lib/environment-action-toolbar/__snapshots__/environment-action-toolbar.spec.tsx.snap
index f42fbb9b6ef..611e772a52b 100644
--- a/libs/domains/environments/feature/src/lib/environment-action-toolbar/__snapshots__/environment-action-toolbar.spec.tsx.snap
+++ b/libs/domains/environments/feature/src/lib/environment-action-toolbar/__snapshots__/environment-action-toolbar.spec.tsx.snap
@@ -171,9 +171,9 @@ exports[`EnvironmentActionToolbar should match manage deployment snapshot 1`] =
>
- Deploy latest version for..
+ Deploy by version
diff --git a/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.spec.tsx b/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.spec.tsx
index cdfe39a3fd1..32123939fcc 100644
--- a/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.spec.tsx
+++ b/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.spec.tsx
@@ -149,6 +149,22 @@ describe('EnvironmentActionToolbar', () => {
expect(mockOpenModalConfirmation).not.toHaveBeenCalled()
})
+ it('should open deploy by version with the fake-modal option so the dropdown is not scroll-locked', async () => {
+ const { userEvent } = renderWithProviders()
+
+ await userEvent.click(screen.getByLabelText(/manage deployment/i))
+ await userEvent.click(screen.getByRole('menuitem', { name: /deploy by version/i }))
+
+ expect(mockOpenModal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ options: expect.objectContaining({
+ fakeModal: true,
+ width: 676,
+ }),
+ })
+ )
+ })
+
it('should disable manage deployment when the environment has no services', async () => {
mockServices = []
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 382625e2250..56fb578ea28 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
@@ -12,7 +12,7 @@ import { isArgoCd, isEditableService } from '@qovery/domains/services/data-acces
// eslint-disable-next-line @nx/enforce-module-boundaries
import { useServices } from '@qovery/domains/services/feature'
import { useVariables } from '@qovery/domains/variables/feature'
-import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL, OVERVIEW_URL } from '@qovery/shared/routes'
+import { OVERVIEW_URL } from '@qovery/shared/routes'
import { Button, DropdownMenu, Icon, Link, Skeleton, Tooltip, useModal, useModalConfirmation } from '@qovery/shared/ui'
import { useCopyToClipboard } from '@qovery/shared/util-hooks'
import {
@@ -23,6 +23,7 @@ import {
isStopAvailable,
} from '@qovery/shared/util-js'
import { CreateCloneEnvironmentModal } from '../create-clone-environment-modal/create-clone-environment-modal'
+import { DeployByVersionModal } from '../deploy-by-version-modal/deploy-by-version-modal'
import { useCancelDeploymentEnvironment } from '../hooks/use-cancel-deployment-environment/use-cancel-deployment-environment'
import { useDeleteEnvironment } from '../hooks/use-delete-environment/use-delete-environment'
import { useDeployEnvironment } from '../hooks/use-deploy-environment/use-deploy-environment'
@@ -31,7 +32,6 @@ import { useEnvironmentServices } from '../hooks/use-environment-services/use-en
import { useStopEnvironment } from '../hooks/use-stop-environment/use-stop-environment'
import useUninstallEnvironment from '../hooks/use-uninstall-environment/use-uninstall-environment'
import { TerraformExportModal } from '../terraform-export-modal/terraform-export-modal'
-import { UpdateAllModal } from '../update-all-modal/update-all-modal'
type ActionToolbarVariant = 'default' | 'header'
const NAMESPACE_VARIABLE_NAME = 'QOVERY_KUBERNETES_NAMESPACE_NAME'
@@ -94,18 +94,25 @@ export function MenuManageDeployment({
const { openModal } = useModal()
const { openModalConfirmation } = useModalConfirmation()
- const logsLink =
- ENVIRONMENT_LOGS_URL(environment.organization.id, environment.project.id, environment.id) + ENVIRONMENT_STAGES_URL()
-
const { mutate: deployEnvironment } = useDeployEnvironment({
+ organizationId: environment.organization.id,
+ projectId: environment.project.id,
+ environmentId: environment.id,
+ })
+ const { mutate: stopEnvironment } = useStopEnvironment({
+ organizationId: environment.organization.id,
+ projectId: environment.project.id,
+ environmentId: environment.id,
+ })
+ const { mutate: uninstallEnvironment } = useUninstallEnvironment({
+ organizationId: environment.organization.id,
projectId: environment.project.id,
- logsLink,
+ environmentId: environment.id,
})
- const { mutate: stopEnvironment } = useStopEnvironment({ projectId: environment.project.id, logsLink })
- const { mutate: uninstallEnvironment } = useUninstallEnvironment({ projectId: environment.project.id, logsLink })
const { mutate: cancelDeploymentEnvironment } = useCancelDeploymentEnvironment({
+ organizationId: environment.organization.id,
projectId: environment.project.id,
- logsLink,
+ environmentId: environment.id,
})
// XXX: Required to display a warning for managed Database
// https://qovery.atlassian.net/jira/software/projects/FRT/boards/23?selectedIssue=FRT-1416
@@ -177,10 +184,11 @@ export function MenuManageDeployment({
})
}
- const openUpdateAllModal = () => {
+ const openDeployByVersionModal = () => {
openModal({
- content: ,
+ content: ,
options: {
+ fakeModal: true,
width: 676,
},
})
@@ -283,8 +291,8 @@ export function MenuManageDeployment({
.otherwise(() => (
<>
- } onSelect={openUpdateAllModal}>
- Deploy latest version for..
+ } onSelect={openDeployByVersionModal}>
+ Deploy by version
>
))}
diff --git a/libs/domains/environments/feature/src/lib/environment-deployment-list/dropdown-services/dropdown-services.tsx b/libs/domains/environments/feature/src/lib/environment-deployment-list/dropdown-services/dropdown-services.tsx
index c49ae235b1c..b61b9362d62 100644
--- a/libs/domains/environments/feature/src/lib/environment-deployment-list/dropdown-services/dropdown-services.tsx
+++ b/libs/domains/environments/feature/src/lib/environment-deployment-list/dropdown-services/dropdown-services.tsx
@@ -15,7 +15,6 @@ import { type AnyService } from '@qovery/domains/services/data-access'
// eslint-disable-next-line @nx/enforce-module-boundaries
import { ServiceAvatar } from '@qovery/domains/services/feature'
import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/context'
-import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes'
import { Indicator, StageStatusChip, StatusChip, Tooltip, TriggerActionIcon, Truncate } from '@qovery/shared/ui'
import { Icon } from '@qovery/shared/ui'
import { dateUTCString, formatDurationMinutesSeconds } from '@qovery/shared/util-dates'
@@ -271,13 +270,12 @@ export function DropdownServices({ environment, deploymentHistory, stages, size
asChild
>
{
event.stopPropagation()
}}
diff --git a/libs/domains/environments/feature/src/lib/environment-deployment-list/environment-deployment-list.tsx b/libs/domains/environments/feature/src/lib/environment-deployment-list/environment-deployment-list.tsx
index c3c1c75e7e4..018e14550d9 100644
--- a/libs/domains/environments/feature/src/lib/environment-deployment-list/environment-deployment-list.tsx
+++ b/libs/domains/environments/feature/src/lib/environment-deployment-list/environment-deployment-list.tsx
@@ -23,7 +23,6 @@ import { P, match } from 'ts-pattern'
// Keep in mind for future refactoring if possible.
// eslint-disable-next-line @nx/enforce-module-boundaries
import { DevopsCopilotTroubleshootTrigger } from '@qovery/shared/devops-copilot/feature'
-import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes'
import {
Button,
CopyToClipboardButtonIcon,
@@ -79,16 +78,13 @@ export function EnvironmentDeploymentList() {
})
const { data: environment } = useEnvironment({ environmentId, suspense: true })
- const logsLink =
- ENVIRONMENT_LOGS_URL(environment?.organization.id, environment?.project.id, environment?.id) +
- ENVIRONMENT_STAGES_URL()
-
const { data: deploymentHistory = [] } = useDeploymentHistory({ environmentId, suspense: true })
const { data: deploymentHistoryQueue = [] } = useDeploymentQueue({ environmentId, suspense: true })
const { mutate: cancelDeploymentEnvironment } = useCancelDeploymentEnvironment({
+ organizationId: environment?.organization.id ?? '',
projectId: environment?.project.id ?? '',
- logsLink,
+ environmentId,
})
const { mutate: cancelDeploymentQueueService } = useCancelDeploymentQueueEnvironment({
environmentId,
diff --git a/libs/domains/environments/feature/src/lib/environment-last-deployment-section/environment-last-deployment-section.tsx b/libs/domains/environments/feature/src/lib/environment-last-deployment-section/environment-last-deployment-section.tsx
index f4923313785..143b1f51465 100644
--- a/libs/domains/environments/feature/src/lib/environment-last-deployment-section/environment-last-deployment-section.tsx
+++ b/libs/domains/environments/feature/src/lib/environment-last-deployment-section/environment-last-deployment-section.tsx
@@ -1,4 +1,4 @@
-import { useLinkProps, useParams } from '@tanstack/react-router'
+import { useParams } from '@tanstack/react-router'
import posthog from 'posthog-js'
import { Suspense, useContext, useMemo } from 'react'
import { P, match } from 'ts-pattern'
@@ -88,15 +88,7 @@ const EnvironmentLastDeploymentContent = () => {
? ''
: `${timeAgo(new Date(lastDeployment.auditing_data.created_at))} ago`
- const logsLink = useLinkProps({
- to: '/organization/$organizationId/project/$projectId/environment/$environmentId/deployments',
- params: { organizationId, projectId, environmentId },
- })
-
- const { mutate: deployEnvironment } = useDeployEnvironment({
- projectId,
- logsLink: logsLink.href,
- })
+ const { mutate: deployEnvironment } = useDeployEnvironment({ organizationId, projectId, environmentId })
const handleDeploy = () => {
if (!environment?.id) return
diff --git a/libs/domains/environments/feature/src/lib/hooks/get-latest-environment-deployment-id.ts b/libs/domains/environments/feature/src/lib/hooks/get-latest-environment-deployment-id.ts
new file mode 100644
index 00000000000..a389b3d4b64
--- /dev/null
+++ b/libs/domains/environments/feature/src/lib/hooks/get-latest-environment-deployment-id.ts
@@ -0,0 +1,37 @@
+import { type QueryClient } from '@tanstack/react-query'
+import { queries } from '@qovery/state/util-queries'
+
+export function isEnvironmentVersionedId(deploymentId?: string | null) {
+ // Environment mutation responses can contain a VersionedId for the environment itself,
+ // which is not the execution ID expected by the pipeline route.
+ return Boolean(deploymentId && /^VersionedId\(id=.+, version=\d+\)$/.test(deploymentId))
+}
+
+function getUsableEnvironmentExecutionId(deploymentId?: string | null) {
+ if (!deploymentId || isEnvironmentVersionedId(deploymentId)) return undefined
+
+ return deploymentId
+}
+
+export async function getLatestEnvironmentDeploymentId(
+ queryClient: QueryClient,
+ environmentId: string,
+ deploymentId?: string | null
+) {
+ try {
+ const deployments = await queryClient.fetchQuery({
+ ...queries.environments.deploymentHistoryV2({ environmentId, pageSize: 100 }),
+ staleTime: 0,
+ })
+
+ const latestDeploymentId = [...(deployments ?? [])].sort(
+ (a, b) => new Date(b.auditing_data.created_at).getTime() - new Date(a.auditing_data.created_at).getTime()
+ )[0]?.identifier.execution_id
+
+ if (latestDeploymentId) return getUsableEnvironmentExecutionId(latestDeploymentId)
+ } catch {
+ // Use the mutation response if deployment history is temporarily unavailable.
+ }
+
+ return getUsableEnvironmentExecutionId(deploymentId)
+}
diff --git a/libs/domains/environments/feature/src/lib/hooks/use-cancel-deployment-environment/use-cancel-deployment-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-cancel-deployment-environment/use-cancel-deployment-environment.ts
index df0e67ec633..6c7139cc28c 100644
--- a/libs/domains/environments/feature/src/lib/hooks/use-cancel-deployment-environment/use-cancel-deployment-environment.ts
+++ b/libs/domains/environments/feature/src/lib/hooks/use-cancel-deployment-environment/use-cancel-deployment-environment.ts
@@ -1,11 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { useNavigate } from '@tanstack/react-router'
+import { type EnvironmentStatus } from 'qovery-typescript-axios'
import { mutations } from '@qovery/domains/environments/data-access'
import { queries } from '@qovery/state/util-queries'
+import { useNavigateToEnvironmentPipeline } from '../use-navigate-to-environment-pipeline'
-export function useCancelDeploymentEnvironment({ projectId, logsLink }: { projectId: string; logsLink?: string }) {
+export function useCancelDeploymentEnvironment({
+ organizationId,
+ projectId,
+ environmentId,
+}: {
+ organizationId: string
+ projectId: string
+ environmentId: string
+}) {
const queryClient = useQueryClient()
- const navigate = useNavigate()
+ const navigateToEnvironmentPipeline = useNavigateToEnvironmentPipeline()
return useMutation(mutations.cancelDeploymentEnvironment, {
onSuccess(_, { environmentId }) {
@@ -21,14 +30,13 @@ export function useCancelDeploymentEnvironment({ projectId, logsLink }: { projec
})
},
meta: {
- notifyOnSuccess: {
- title: 'Your environment deployment is cancelling',
- ...(logsLink
- ? {
- labelAction: 'See deployment logs',
- callback: () => navigate({ to: logsLink }),
- }
- : {}),
+ notifyOnSuccess(data: unknown) {
+ const { last_deployment_id: deploymentId } = data as EnvironmentStatus
+ return {
+ title: 'Your environment deployment is cancelling',
+ labelAction: 'See pipeline',
+ callback: () => navigateToEnvironmentPipeline({ organizationId, projectId, environmentId, deploymentId }),
+ }
},
notifyOnError: true,
},
diff --git a/libs/domains/environments/feature/src/lib/hooks/use-delete-environment/use-delete-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-delete-environment/use-delete-environment.ts
index c52d70772f4..32327a47365 100644
--- a/libs/domains/environments/feature/src/lib/hooks/use-delete-environment/use-delete-environment.ts
+++ b/libs/domains/environments/feature/src/lib/hooks/use-delete-environment/use-delete-environment.ts
@@ -1,11 +1,9 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { useNavigate } from '@tanstack/react-router'
import { mutations } from '@qovery/domains/environments/data-access'
import { queries } from '@qovery/state/util-queries'
-export function useDeleteEnvironment({ projectId, logsLink }: { projectId: string; logsLink?: string }) {
+export function useDeleteEnvironment({ projectId }: { projectId: string }) {
const queryClient = useQueryClient()
- const navigate = useNavigate()
return useMutation(mutations.deleteEnvironment, {
onSuccess() {
@@ -22,12 +20,6 @@ export function useDeleteEnvironment({ projectId, logsLink }: { projectId: strin
meta: {
notifyOnSuccess: {
title: 'Your environment is being deleted',
- ...(logsLink
- ? {
- labelAction: 'See deployment logs',
- callback: () => navigate({ to: logsLink }),
- }
- : {}),
},
notifyOnError: true,
},
diff --git a/libs/domains/environments/feature/src/lib/hooks/use-deploy-all-services/use-deploy-all-services.spec.ts b/libs/domains/environments/feature/src/lib/hooks/use-deploy-all-services/use-deploy-all-services.spec.ts
new file mode 100644
index 00000000000..e8bd07b350b
--- /dev/null
+++ b/libs/domains/environments/feature/src/lib/hooks/use-deploy-all-services/use-deploy-all-services.spec.ts
@@ -0,0 +1,54 @@
+import { environmentFactoryMock } from '@qovery/shared/factories'
+import { renderHook } from '@qovery/shared/util-tests'
+import { queries } from '@qovery/state/util-queries'
+import { useDeployAllServices } from './use-deploy-all-services'
+
+const mockInvalidateQueries = jest.fn()
+const mockUseMutation = jest.fn()
+
+interface MutationOptions {
+ onSuccess: (
+ data: unknown,
+ variables: {
+ environment: ReturnType[number]
+ payload: { terraforms: { id: string; git_commit_id: string }[] }
+ }
+ ) => void
+}
+
+let mockMutationOptions: MutationOptions
+
+jest.mock('@tanstack/react-query', () => ({
+ ...jest.requireActual('@tanstack/react-query'),
+ useMutation: (...args: unknown[]) => mockUseMutation(...args),
+ useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
+}))
+
+jest.mock('@tanstack/react-router', () => ({
+ ...jest.requireActual('@tanstack/react-router'),
+ useNavigate: () => jest.fn(),
+}))
+
+describe('useDeployAllServices', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseMutation.mockImplementation((_mutation: unknown, options: MutationOptions) => {
+ mockMutationOptions = options
+ return {}
+ })
+ })
+
+ it('invalidates Terraform details after a successful deployment', () => {
+ const environment = environmentFactoryMock(1)[0]
+ renderHook(() => useDeployAllServices())
+
+ mockMutationOptions.onSuccess(undefined, {
+ environment,
+ payload: { terraforms: [{ id: 'terraform-1', git_commit_id: 'commit-1' }] },
+ })
+
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({
+ queryKey: queries.services.details({ serviceId: 'terraform-1', serviceType: 'TERRAFORM' }).queryKey,
+ })
+ })
+})
diff --git a/libs/domains/environments/feature/src/lib/hooks/use-deploy-all-services/use-deploy-all-services.ts b/libs/domains/environments/feature/src/lib/hooks/use-deploy-all-services/use-deploy-all-services.ts
index 3bf3640f3aa..765405034bf 100644
--- a/libs/domains/environments/feature/src/lib/hooks/use-deploy-all-services/use-deploy-all-services.ts
+++ b/libs/domains/environments/feature/src/lib/hooks/use-deploy-all-services/use-deploy-all-services.ts
@@ -1,14 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { useNavigate } from '@tanstack/react-router'
+import { type EnvironmentStatus } from 'qovery-typescript-axios'
import { mutations } from '@qovery/domains/services/data-access'
-import { ENVIRONMENT_LOGS_URL } from '@qovery/shared/routes'
import { queries } from '@qovery/state/util-queries'
+import { useNavigateToEnvironmentPipeline } from '../use-navigate-to-environment-pipeline'
// XXX: Duplicate with the one in the Services domain
// Necessary to avoid circular dependencies
export function useDeployAllServices() {
const queryClient = useQueryClient()
- const navigate = useNavigate()
+ const navigateToEnvironmentPipeline = useNavigateToEnvironmentPipeline()
return useMutation(mutations.deployAllServices, {
onSuccess(_, { environment, payload }) {
@@ -45,9 +45,17 @@ export function useDeployAllServices() {
})
}
}
+ for (const { id: serviceId } of payload.terraforms ?? []) {
+ if (serviceId) {
+ queryClient.invalidateQueries({
+ queryKey: queries.services.details({ serviceId, serviceType: 'TERRAFORM' }).queryKey,
+ })
+ }
+ }
},
meta: {
- notifyOnSuccess(_: unknown, variables: unknown) {
+ notifyOnSuccess(data: unknown, variables: unknown) {
+ const { last_deployment_id: deploymentId } = data as EnvironmentStatus
const {
environment: {
id: environmentId,
@@ -56,13 +64,9 @@ export function useDeployAllServices() {
},
} = variables as Parameters[0]
return {
- title: 'Your services are being updated',
- labelAction: 'See deployment logs',
- callback() {
- navigate({
- to: ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId),
- })
- },
+ title: 'Your services are being deployed',
+ labelAction: 'See pipeline',
+ callback: () => navigateToEnvironmentPipeline({ organizationId, projectId, environmentId, deploymentId }),
}
},
notifyOnError: true,
diff --git a/libs/domains/environments/feature/src/lib/hooks/use-deploy-environment/use-deploy-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-deploy-environment/use-deploy-environment.ts
index bc528ccbd24..13191796178 100644
--- a/libs/domains/environments/feature/src/lib/hooks/use-deploy-environment/use-deploy-environment.ts
+++ b/libs/domains/environments/feature/src/lib/hooks/use-deploy-environment/use-deploy-environment.ts
@@ -1,11 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { useNavigate } from '@tanstack/react-router'
+import { type Status } from 'qovery-typescript-axios'
import { mutations } from '@qovery/domains/environments/data-access'
import { queries } from '@qovery/state/util-queries'
+import { useNavigateToEnvironmentPipeline } from '../use-navigate-to-environment-pipeline'
-export function useDeployEnvironment({ projectId, logsLink }: { projectId: string; logsLink?: string }) {
+export function useDeployEnvironment({
+ organizationId,
+ projectId,
+ environmentId,
+}: {
+ organizationId: string
+ projectId: string
+ environmentId: string
+}) {
const queryClient = useQueryClient()
- const navigate = useNavigate()
+ const navigateToEnvironmentPipeline = useNavigateToEnvironmentPipeline()
return useMutation(mutations.deployEnvironment, {
onSuccess(_, { environmentId }) {
@@ -21,14 +30,13 @@ export function useDeployEnvironment({ projectId, logsLink }: { projectId: strin
})
},
meta: {
- notifyOnSuccess: {
- title: 'Your environment is redeploying',
- ...(logsLink
- ? {
- labelAction: 'See deployment logs',
- callback: () => navigate({ to: logsLink }),
- }
- : {}),
+ notifyOnSuccess(data: unknown) {
+ const { execution_id: deploymentId } = data as Status
+ return {
+ title: 'Your environment is redeploying',
+ labelAction: 'See pipeline',
+ callback: () => navigateToEnvironmentPipeline({ organizationId, projectId, environmentId, deploymentId }),
+ }
},
notifyOnError: true,
},
diff --git a/libs/domains/environments/feature/src/lib/hooks/use-navigate-to-environment-pipeline.ts b/libs/domains/environments/feature/src/lib/hooks/use-navigate-to-environment-pipeline.ts
new file mode 100644
index 00000000000..df35810f49f
--- /dev/null
+++ b/libs/domains/environments/feature/src/lib/hooks/use-navigate-to-environment-pipeline.ts
@@ -0,0 +1,43 @@
+import { useQueryClient } from '@tanstack/react-query'
+import { useNavigate } from '@tanstack/react-router'
+import { toast } from '@qovery/shared/ui'
+import { getLatestEnvironmentDeploymentId, isEnvironmentVersionedId } from './get-latest-environment-deployment-id'
+
+interface NavigateToEnvironmentPipelineProps {
+ organizationId: string
+ projectId: string
+ environmentId: string
+ deploymentId?: string | null
+}
+
+const PIPELINE_LOOKUP_ATTEMPTS = 4
+const PIPELINE_LOOKUP_RETRY_DELAY_MS = 500
+
+export function useNavigateToEnvironmentPipeline() {
+ const queryClient = useQueryClient()
+ const navigate = useNavigate()
+
+ return async ({ organizationId, projectId, environmentId, deploymentId }: NavigateToEnvironmentPipelineProps) => {
+ const waitForHistoryToCatchUp = isEnvironmentVersionedId(deploymentId)
+ let resolvedDeploymentId: string | undefined
+
+ for (let attempt = 0; attempt < PIPELINE_LOOKUP_ATTEMPTS; attempt++) {
+ resolvedDeploymentId = await getLatestEnvironmentDeploymentId(queryClient, environmentId, deploymentId)
+ if (resolvedDeploymentId && !waitForHistoryToCatchUp) break
+
+ if (attempt < PIPELINE_LOOKUP_ATTEMPTS - 1) {
+ await new Promise((resolve) => setTimeout(resolve, PIPELINE_LOOKUP_RETRY_DELAY_MS))
+ }
+ }
+
+ if (!resolvedDeploymentId) {
+ toast('warning', 'Pipeline is not available yet', 'Try again in a moment.')
+ return
+ }
+
+ await navigate({
+ to: '/organization/$organizationId/project/$projectId/environment/$environmentId/deployment/$deploymentId',
+ params: { organizationId, projectId, environmentId, deploymentId: resolvedDeploymentId },
+ })
+ }
+}
diff --git a/libs/domains/environments/feature/src/lib/hooks/use-outdated-services/use-outdated-services.ts b/libs/domains/environments/feature/src/lib/hooks/use-outdated-services/use-outdated-services.ts
deleted file mode 100644
index 5ca64996ca9..00000000000
--- a/libs/domains/environments/feature/src/lib/hooks/use-outdated-services/use-outdated-services.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useQueries, useQuery } from '@tanstack/react-query'
-import { type Commit } from 'qovery-typescript-axios'
-import { P, match } from 'ts-pattern'
-import { type Application, type Job, isApplication, isJob } from '@qovery/domains/services/data-access'
-import { isJobGitSource } from '@qovery/shared/enums'
-import { unionTypeGuard } from '@qovery/shared/util-types'
-import { queries } from '@qovery/state/util-queries'
-
-export interface UseOutdatedServicesProps {
- environmentId?: string
-}
-
-export type OutdatedService = (Application | Job) & { commits: Commit[] }
-
-export function useOutdatedServices({ environmentId }: UseOutdatedServicesProps) {
- const { data: services = [], isLoading: isServicesLoading } = useQuery({
- ...queries.services.list(environmentId!),
- enabled: Boolean(environmentId),
- })
-
- const getGitRepository = (service: Application | Job) =>
- match(service)
- .with({ serviceType: 'APPLICATION' }, ({ git_repository }) => git_repository)
- .with({ serviceType: 'JOB', source: P.when(isJobGitSource) }, ({ source }) => source.docker?.git_repository)
- .otherwise(() => undefined)
-
- const gitServices = services
- .filter(unionTypeGuard([isApplication, isJob]))
- .filter((service) => Boolean(getGitRepository(service)))
- const queryResults = useQueries({
- queries: gitServices.map(({ id, serviceType }) => ({
- ...queries.services.listCommits({ serviceId: id, serviceType }),
- staleTime: 3 * 60 * 1000, // 3 minutes - commit history doesn't change frequently
- retry: false,
- retryOnMount: false,
- refetchOnWindowFocus: false,
- })),
- })
-
- const outdatedServices: OutdatedService[] = []
-
- for (let i = 0; i < queryResults.length; i++) {
- const { data: commits = [] } = queryResults[i]
- if (commits.length === 0) continue
- const service = gitServices[i]
- const gitRepository = getGitRepository(service)
- if (gitRepository?.deployed_commit_id !== commits[0]?.git_commit_id) {
- outdatedServices.push({
- ...service,
- commits,
- })
- }
- }
-
- return { data: outdatedServices, isLoading: queryResults.some(({ isLoading }) => isLoading) || isServicesLoading }
-}
-
-export default useOutdatedServices
diff --git a/libs/domains/environments/feature/src/lib/hooks/use-stop-environment/use-stop-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-stop-environment/use-stop-environment.ts
index 360c5a2e47e..b1a1b8b9b1e 100644
--- a/libs/domains/environments/feature/src/lib/hooks/use-stop-environment/use-stop-environment.ts
+++ b/libs/domains/environments/feature/src/lib/hooks/use-stop-environment/use-stop-environment.ts
@@ -1,19 +1,22 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { useNavigate } from '@tanstack/react-router'
+import { type EnvironmentStatus } from 'qovery-typescript-axios'
import { mutations } from '@qovery/domains/environments/data-access'
import { queries } from '@qovery/state/util-queries'
+import { useNavigateToEnvironmentPipeline } from '../use-navigate-to-environment-pipeline'
export function useStopEnvironment({
+ organizationId,
projectId,
- logsLink,
+ environmentId,
notifyOnSuccess = true,
}: {
+ organizationId?: string
projectId: string
- logsLink?: string
+ environmentId?: string
notifyOnSuccess?: boolean
}) {
const queryClient = useQueryClient()
- const navigate = useNavigate()
+ const navigateToEnvironmentPipeline = useNavigateToEnvironmentPipeline()
return useMutation(mutations.stopEnvironment, {
onSuccess(_, { environmentId }) {
@@ -30,14 +33,18 @@ export function useStopEnvironment({
},
meta: {
notifyOnSuccess: notifyOnSuccess
- ? {
- title: 'Your environment is being stopped',
- ...(logsLink
- ? {
- labelAction: 'See deployment logs',
- callback: () => navigate({ to: logsLink }),
- }
- : {}),
+ ? (data: unknown) => {
+ const { last_deployment_id: deploymentId } = data as EnvironmentStatus
+ return {
+ title: 'Your environment is being stopped',
+ ...(organizationId && environmentId
+ ? {
+ labelAction: 'See pipeline',
+ callback: () =>
+ navigateToEnvironmentPipeline({ organizationId, projectId, environmentId, deploymentId }),
+ }
+ : {}),
+ }
}
: false,
notifyOnError: true,
diff --git a/libs/domains/environments/feature/src/lib/hooks/use-uninstall-environment/use-uninstall-environment.ts b/libs/domains/environments/feature/src/lib/hooks/use-uninstall-environment/use-uninstall-environment.ts
index d7ed7a345d3..1de55b87151 100644
--- a/libs/domains/environments/feature/src/lib/hooks/use-uninstall-environment/use-uninstall-environment.ts
+++ b/libs/domains/environments/feature/src/lib/hooks/use-uninstall-environment/use-uninstall-environment.ts
@@ -1,11 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { useNavigate } from '@tanstack/react-router'
+import { type EnvironmentStatus } from 'qovery-typescript-axios'
import { mutations } from '@qovery/domains/environments/data-access'
import { queries } from '@qovery/state/util-queries'
+import { useNavigateToEnvironmentPipeline } from '../use-navigate-to-environment-pipeline'
-export function useUninstallEnvironment({ projectId, logsLink }: { projectId: string; logsLink?: string }) {
+export function useUninstallEnvironment({
+ organizationId,
+ projectId,
+ environmentId,
+}: {
+ organizationId: string
+ projectId: string
+ environmentId: string
+}) {
const queryClient = useQueryClient()
- const navigate = useNavigate()
+ const navigateToEnvironmentPipeline = useNavigateToEnvironmentPipeline()
return useMutation(mutations.uninstallEnvironment, {
onSuccess(_, { environmentId }) {
@@ -21,14 +30,13 @@ export function useUninstallEnvironment({ projectId, logsLink }: { projectId: st
})
},
meta: {
- notifyOnSuccess: {
- title: 'Your environment is being uninstalled',
- ...(logsLink
- ? {
- labelAction: 'See deployment logs',
- callback: () => navigate({ to: logsLink }),
- }
- : {}),
+ notifyOnSuccess(data: unknown) {
+ const { last_deployment_id: deploymentId } = data as EnvironmentStatus
+ return {
+ title: 'Your environment is being uninstalled',
+ labelAction: 'See pipeline',
+ callback: () => navigateToEnvironmentPipeline({ organizationId, projectId, environmentId, deploymentId }),
+ }
},
notifyOnError: true,
},
diff --git a/libs/domains/environments/feature/src/lib/need-redeploy-flag/need-redeploy-flag.spec.tsx b/libs/domains/environments/feature/src/lib/need-redeploy-flag/need-redeploy-flag.spec.tsx
index 01e90dcba72..5fe3b1b7b9a 100644
--- a/libs/domains/environments/feature/src/lib/need-redeploy-flag/need-redeploy-flag.spec.tsx
+++ b/libs/domains/environments/feature/src/lib/need-redeploy-flag/need-redeploy-flag.spec.tsx
@@ -6,14 +6,13 @@ import NeedRedeployFlag from './need-redeploy-flag'
const useDeployEnvironmentSpy = jest.spyOn(useDeployEnvironmentImport, 'useDeployEnvironment') as jest.Mock
-jest.mock('react-router-dom', () => ({
- ...jest.requireActual('react-router-dom'),
+jest.mock('@tanstack/react-router', () => ({
+ ...jest.requireActual('@tanstack/react-router'),
useParams: () => ({
organizationId: 'organization-id',
projectId: 'project-id',
environmentId: 'environment-id',
}),
- useNavigate: () => jest.fn(),
}))
describe('NeedRedeployFlag', () => {
diff --git a/libs/domains/environments/feature/src/lib/need-redeploy-flag/need-redeploy-flag.tsx b/libs/domains/environments/feature/src/lib/need-redeploy-flag/need-redeploy-flag.tsx
index 2ede0f64c10..44de9a9a56c 100644
--- a/libs/domains/environments/feature/src/lib/need-redeploy-flag/need-redeploy-flag.tsx
+++ b/libs/domains/environments/feature/src/lib/need-redeploy-flag/need-redeploy-flag.tsx
@@ -1,17 +1,17 @@
+import { useParams } from '@tanstack/react-router'
import { EnvironmentDeploymentStatusEnum } from 'qovery-typescript-axios'
-import { useNavigate, useParams } from 'react-router-dom'
-import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes'
import { Banner } from '@qovery/shared/ui'
import { useDeployEnvironment } from '../hooks/use-deploy-environment/use-deploy-environment'
import { useDeploymentStatus } from '../hooks/use-deployment-status/use-deployment-status'
export function NeedRedeployFlag() {
- const { organizationId = '', projectId = '', environmentId = '' } = useParams()
- const navigate = useNavigate()
+ const { organizationId = '', projectId = '', environmentId = '' } = useParams({ strict: false })
const { data: environmentDeploymentStatus } = useDeploymentStatus({ environmentId })
const { mutate: deployEnvironment } = useDeployEnvironment({
+ organizationId,
projectId,
+ environmentId,
})
if (!environmentDeploymentStatus) return null
@@ -26,7 +26,6 @@ export function NeedRedeployFlag() {
const mutationDeployEnvironment = () => {
deployEnvironment({ environmentId })
- navigate(ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) + ENVIRONMENT_STAGES_URL())
}
return (
diff --git a/libs/domains/environments/feature/src/lib/settings-danger-zone/settings-danger-zone.tsx b/libs/domains/environments/feature/src/lib/settings-danger-zone/settings-danger-zone.tsx
index 394303c9a23..013efa45cd3 100644
--- a/libs/domains/environments/feature/src/lib/settings-danger-zone/settings-danger-zone.tsx
+++ b/libs/domains/environments/feature/src/lib/settings-danger-zone/settings-danger-zone.tsx
@@ -1,6 +1,6 @@
import { useNavigate, useParams } from '@tanstack/react-router'
import { type Environment } from 'qovery-typescript-axios'
-import { ENVIRONMENTS_GENERAL_URL, ENVIRONMENTS_URL, OVERVIEW_URL } from '@qovery/shared/routes'
+import { OVERVIEW_URL } from '@qovery/shared/routes'
import { BlockContentDelete, Section } from '@qovery/shared/ui'
import { useDeleteEnvironment } from '../hooks/use-delete-environment/use-delete-environment'
import { useEnvironment } from '../hooks/use-environment/use-environment'
@@ -49,7 +49,6 @@ export function PageSettingsDangerZoneFeature() {
const { data: environment } = useEnvironment({ environmentId })
const { mutateAsync: deleteEnvironment } = useDeleteEnvironment({
projectId,
- logsLink: ENVIRONMENTS_URL(organizationId, projectId) + ENVIRONMENTS_GENERAL_URL,
})
return (
diff --git a/libs/domains/environments/feature/src/lib/update-all-modal/__snapshots__/update-all-modal.spec.tsx.snap b/libs/domains/environments/feature/src/lib/update-all-modal/__snapshots__/update-all-modal.spec.tsx.snap
deleted file mode 100644
index 032ac287df3..00000000000
--- a/libs/domains/environments/feature/src/lib/update-all-modal/__snapshots__/update-all-modal.spec.tsx.snap
+++ /dev/null
@@ -1,398 +0,0 @@
-// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
-
-exports[`UpdateAllModal should reduce opacity of commit blocks 1`] = `
-
-
-
-
- Deploy latest version for..
-
-
- Select the services you want to update to the latest version
-
-
-
- For
-
-
- Barry Lyons
-
-
-
-
-
-
-
-
-
-
-
-
-`;
diff --git a/libs/domains/environments/feature/src/lib/update-all-modal/update-all-modal.spec.tsx b/libs/domains/environments/feature/src/lib/update-all-modal/update-all-modal.spec.tsx
deleted file mode 100644
index 70240b2413d..00000000000
--- a/libs/domains/environments/feature/src/lib/update-all-modal/update-all-modal.spec.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import { type Commit, ServiceTypeEnum } from 'qovery-typescript-axios'
-import { applicationFactoryMock, environmentFactoryMock } from '@qovery/shared/factories'
-import { renderWithProviders, screen } from '@qovery/shared/util-tests'
-import { useDeployAllServices } from '../hooks/use-deploy-all-services/use-deploy-all-services'
-import { type OutdatedService } from '../hooks/use-outdated-services/use-outdated-services'
-import { UpdateAllModal, type UpdateAllModalProps } from './update-all-modal'
-
-const [mockEnvironment1, mockEnvironment2] = environmentFactoryMock(2).map((env, index) => ({
- ...env,
- id: `env${index + 1}`,
-}))
-
-const mockApplications = applicationFactoryMock(3).map((app): OutdatedService => {
- return {
- ...app,
- environment: {
- id: '1',
- },
- healthchecks: {},
- git_repository: {
- ...app.git_repository,
- url: '',
- deployed_commit_contributor: '',
- deployed_commit_date: '',
- deployed_commit_id: 'commit0',
- deployed_commit_tag: '',
- },
- serviceType: ServiceTypeEnum.APPLICATION,
- commits: [
- { git_commit_id: 'commit1' } as Commit,
- { git_commit_id: 'commit2' } as Commit,
- { git_commit_id: 'commit3' } as Commit,
- ],
- }
-})
-
-jest.mock('../hooks/use-outdated-services/use-outdated-services', () => ({
- useOutdatedServices: ({ environmentId }: { environmentId: string }) => ({
- data: environmentId === 'env1' ? mockApplications : [],
- isLoading: false,
- error: {},
- }),
-}))
-
-jest.mock('../hooks/use-list-deployment-stages/use-list-deployment-stages', () => ({
- useListDeploymentStages: () => ({
- data: [],
- isLoading: false,
- error: {},
- }),
-}))
-
-jest.mock('../hooks/use-deploy-all-services/use-deploy-all-services', () => {
- const mutate = jest.fn()
- return {
- ...jest.requireActual('../hooks/use-deploy-all-services/use-deploy-all-services'),
- useDeployAllServices: () => ({
- mutate,
- isLoading: false,
- error: {},
- }),
- }
-})
-
-describe('UpdateAllModal', () => {
- const props: UpdateAllModalProps = {
- environment: mockEnvironment1,
- }
-
- it('should render successfully', () => {
- const { baseElement } = renderWithProviders()
- expect(baseElement).toBeTruthy()
- })
-
- it('should useDeployAllServices have been called on submit with good payload', async () => {
- const { userEvent } = renderWithProviders()
-
- const deselectAll = screen.getByTestId('deselect-all')
-
- await userEvent.click(deselectAll)
-
- const firstRow = screen.getAllByTestId('outdated-service-row')[0]
-
- await userEvent.click(firstRow)
-
- const submitButton = screen.getByTestId('submit-button')
-
- await userEvent.click(submitButton)
-
- expect(useDeployAllServices().mutate).toHaveBeenCalledWith({
- environment: mockEnvironment1,
- payload: {
- applications: [
- {
- application_id: mockApplications[0].id,
- git_commit_id: mockApplications[0].commits[0].git_commit_id,
- },
- ],
- jobs: [],
- },
- })
- })
-
- it('should useDeployAllServices have been called on submit with selectAll', async () => {
- const { userEvent } = renderWithProviders()
-
- const deselectAll = screen.getByTestId('deselect-all')
-
- await userEvent.click(deselectAll)
-
- await userEvent.click(screen.getByTestId('select-all'))
-
- const submitButton = screen.getByTestId('submit-button')
-
- await userEvent.click(submitButton)
-
- expect(useDeployAllServices().mutate).toHaveBeenCalledWith({
- environment: mockEnvironment1,
- payload: {
- applications: [
- {
- application_id: mockApplications[0].id,
- git_commit_id: mockApplications[0].commits[0].git_commit_id,
- },
- {
- application_id: mockApplications[1].id,
- git_commit_id: mockApplications[1].commits[0].git_commit_id,
- },
- {
- application_id: mockApplications[2].id,
- git_commit_id: mockApplications[2].commits[0].git_commit_id,
- },
- ],
- jobs: [],
- },
- })
- })
-
- it('should display 3 rows', () => {
- renderWithProviders()
- expect(screen.getAllByTestId('outdated-service-row')).toHaveLength(3)
- })
-
- it('should have 1 row checked and next sibling color border top', async () => {
- const { userEvent } = renderWithProviders()
-
- await userEvent.click(screen.getByTestId('deselect-all'))
-
- const rows = screen.getAllByTestId('outdated-service-row')
- const firstRow = rows[0]
- await userEvent.click(firstRow)
-
- expect(firstRow).toHaveClass('bg-surface-brand-subtle', 'border-brand-strong')
-
- const secondRow = rows[1]
- expect(secondRow).toHaveClass('border-t-brand-strong')
-
- const submitButton = screen.getByTestId('submit-button')
-
- expect(submitButton).toHaveTextContent('Update 1 service')
- })
-
- it('should disabled submit button if no selected', async () => {
- const { userEvent } = renderWithProviders()
-
- await userEvent.click(screen.getByTestId('deselect-all'))
-
- const submitButton = screen.getByTestId('submit-button')
- expect(submitButton).toHaveTextContent('Update 0 service')
- expect(submitButton).toBeDisabled()
- })
-
- it('should display empty state', async () => {
- renderWithProviders()
-
- screen.getByTestId('empty-state')
- })
-
- it('should reduce opacity of commit blocks', async () => {
- const { baseElement, userEvent } = renderWithProviders()
-
- await userEvent.click(screen.getByTestId('deselect-all'))
-
- const rows = screen.getAllByTestId('outdated-service-row')
- const firstRow = rows[0]
- await userEvent.click(firstRow)
-
- expect(baseElement).toMatchSnapshot()
- })
-})
diff --git a/libs/domains/environments/feature/src/lib/update-all-modal/update-all-modal.tsx b/libs/domains/environments/feature/src/lib/update-all-modal/update-all-modal.tsx
deleted file mode 100644
index 23643357d7d..00000000000
--- a/libs/domains/environments/feature/src/lib/update-all-modal/update-all-modal.tsx
+++ /dev/null
@@ -1,286 +0,0 @@
-import { clsx } from 'clsx'
-import { type Commit, type DeployAllRequest, type Environment } from 'qovery-typescript-axios'
-import { useEffect, useState } from 'react'
-import { P, match } from 'ts-pattern'
-import { isJobGitSource } from '@qovery/shared/enums'
-import {
- Avatar,
- Button,
- Checkbox,
- Icon,
- LoaderSpinner,
- ScrollShadowWrapper,
- TagCommit,
- Tooltip,
- Truncate,
- useModal,
-} from '@qovery/shared/ui'
-import { twMerge } from '@qovery/shared/util-js'
-import { useDeployAllServices } from '../hooks/use-deploy-all-services/use-deploy-all-services'
-import { useListDeploymentStages } from '../hooks/use-list-deployment-stages/use-list-deployment-stages'
-import { type OutdatedService, useOutdatedServices } from '../hooks/use-outdated-services/use-outdated-services'
-
-export interface UpdateAllModalProps {
- environment: Environment
-}
-
-export function UpdateAllModal({ environment }: UpdateAllModalProps) {
- const { closeModal } = useModal()
- const { data: outdatedServices = [], isLoading: isOutdatedServicesLoading } = useOutdatedServices({
- environmentId: environment.id,
- })
- const { data: deploymentStages } = useListDeploymentStages({ environmentId: environment.id })
- const [selectedServiceIds, setSelectedServiceIds] = useState([])
- const { mutate: deployAllServices, isLoading: isDeployAllServicesLoading } = useDeployAllServices()
-
- const isSkipped = (serviceId: string) =>
- deploymentStages?.some((stage) => stage.services?.some((s) => s.service_id === serviceId && s.is_skipped)) ?? false
-
- const checkService = (serviceId: string) => {
- if (isSkipped(serviceId)) return
- if (selectedServiceIds.includes(serviceId)) {
- setSelectedServiceIds(selectedServiceIds.filter((id) => id !== serviceId))
- } else {
- setSelectedServiceIds([...selectedServiceIds, serviceId])
- }
- }
-
- const onSubmit = () => {
- if (selectedServiceIds.length > 0) {
- const appsToUpdate: DeployAllRequest['applications'] = []
- const jobsToUpdate: DeployAllRequest['jobs'] = []
-
- const servicesDictionary = outdatedServices.reduce(
- (acc, service) => {
- acc[service.id] = service
- return acc
- },
- {} as Record
- )
-
- selectedServiceIds.forEach((serviceId) => {
- const { serviceType, id, commits } = servicesDictionary[serviceId]
- if (serviceType === 'APPLICATION') {
- appsToUpdate.push({
- application_id: id,
- git_commit_id: commits[0]?.git_commit_id ?? '',
- })
- }
-
- if (serviceType === 'JOB') {
- jobsToUpdate.push({
- id,
- git_commit_id: commits[0]?.git_commit_id ?? '',
- })
- }
- })
-
- const deployRequest: DeployAllRequest = {
- applications: appsToUpdate,
- jobs: jobsToUpdate,
- }
-
- deployAllServices({ environment, payload: deployRequest })
- closeModal()
- }
- }
-
- const unselectAll = () => {
- setSelectedServiceIds([])
- }
-
- const selectAll = () => {
- setSelectedServiceIds(outdatedServices.filter(({ id }) => !isSkipped(id)).map(({ id }) => id))
- }
-
- useEffect(() => {
- if (!isOutdatedServicesLoading) {
- selectAll()
- }
- }, [isOutdatedServicesLoading])
-
- const findCommitById = (application: OutdatedService, commitId?: string): Commit | undefined => {
- return application.commits.find((commit) => commit.git_commit_id === commitId) as Commit
- }
-
- const getAvatarForCommit = (application: OutdatedService, commitId?: string): string | undefined => {
- return findCommitById(application, commitId)?.author_avatar_url
- }
-
- const getNameForCommit = (application: OutdatedService, commitId?: string): string | undefined => {
- return findCommitById(application, commitId)?.author_name
- }
-
- const getInitials = (fullName: string) =>
- fullName
- .split(' ')
- .filter(Boolean)
- .slice(0, 2)
- .map((name) => name.charAt(0).toUpperCase())
- .join('')
-
- const isChecked = (serviceId: string) => selectedServiceIds.includes(serviceId)
-
- return (
-
-
Deploy latest version for..
-
Select the services you want to update to the latest version
-
-
-
- For{' '}
-
-
-
-
-
- {outdatedServices.length > 0 &&
- (selectedServiceIds.length > 0 ? (
-
- ) : (
-
- ))}
-
- {isOutdatedServicesLoading ? (
-
- ) : outdatedServices.length ? (
-
-
- {outdatedServices.map((application, index) => {
- const gitRepository = match(application)
- .with({ serviceType: 'APPLICATION' }, ({ git_repository }) => git_repository)
- .with(
- { serviceType: 'JOB', source: P.when(isJobGitSource) },
- ({ source }) => source.docker?.git_repository
- )
- .otherwise(() => undefined)
-
- const skipped = isSkipped(application.id)
- const deployedCommitAuthorName =
- getNameForCommit(application, gitRepository?.deployed_commit_id) || 'Unknown'
- const deployedCommitAuthorAvatarUrl = getAvatarForCommit(application, gitRepository?.deployed_commit_id)
- const latestCommitAuthorName = application.commits[0].author_name || ''
- const latestCommitAuthorAvatarUrl = application.commits[0].author_avatar_url || ''
-
- return (
-
- - checkService(application.id)}
- className={twMerge(
- 'flex justify-between border border-b-0 border-neutral p-4',
- index === 0 && 'rounded-t',
- outdatedServices.length - 1 === index && 'rounded-b !border-b',
- clsx({
- 'cursor-default opacity-50': skipped,
- 'cursor-pointer': !skipped,
- 'border-brand-strong bg-surface-brand-subtle': !skipped && isChecked(application.id),
- 'border-t-brand-strong': !skipped && isChecked(outdatedServices[index - 1]?.id),
- })
- )}
- >
-
- e.stopPropagation()}>
- {
- if (checked === 'indeterminate') {
- return
- }
- checkService(application.id)
- }}
- />
-
-
-
-
-
e.stopPropagation()}
- >
- {}
-
-
- {getInitials(deployedCommitAuthorName)}
-
- }
- />
-
-
-
-
- {application.commits && Boolean(application.commits.length) && (
-
e.stopPropagation()}
- >
-
-
- {getInitials(latestCommitAuthorName)}
-
- }
- />
-
-
-
- )}
-
-
-
- )
- })}
-
-
- ) : (
-
-
-
No outdated services found
-
- )}
-
-
-
-
-
-
- )
-}
-
-export default UpdateAllModal