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 ( +
+
+ + !isSkipped) ? ( + + ) : undefined + } + onToggle={toggleService} + onVersionChange={changeVersion} + /> + +
+ +
+ + +
+
+ ) +} + +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`] = >