diff --git a/libs/domains/environments/feature/src/index.ts b/libs/domains/environments/feature/src/index.ts index e2295044e2f..f7fa608b9f3 100644 --- a/libs/domains/environments/feature/src/index.ts +++ b/libs/domains/environments/feature/src/index.ts @@ -35,3 +35,5 @@ export * from './lib/hooks/use-lifecycle-templates/use-lifecycle-templates' export * from './lib/hooks/use-lifecycle-template/use-lifecycle-template' export * from './lib/hooks/use-deploy-all-services/use-deploy-all-services' export * from './lib/hooks/use-service-count/use-service-count' +export * from './lib/hooks/use-services-for-deploy/use-services-for-deploy' +export * from './lib/deploy-with-version-modal/deploy-with-version-modal' diff --git a/libs/domains/environments/feature/src/lib/deploy-with-version-modal/deploy-with-version-modal.spec.tsx b/libs/domains/environments/feature/src/lib/deploy-with-version-modal/deploy-with-version-modal.spec.tsx new file mode 100644 index 00000000000..ed607fef8ad --- /dev/null +++ b/libs/domains/environments/feature/src/lib/deploy-with-version-modal/deploy-with-version-modal.spec.tsx @@ -0,0 +1,54 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { environmentFactoryMock } from '@qovery/shared/factories' +import { DeployWithVersionModal } from './deploy-with-version-modal' + +const mockCloseModal = jest.fn() +const mockDeployAllServices = jest.fn() + +jest.mock('@qovery/shared/ui', () => ({ + ...jest.requireActual('@qovery/shared/ui'), + useModal: () => ({ + closeModal: mockCloseModal, + }), +})) + +jest.mock('../hooks/use-deploy-all-services/use-deploy-all-services', () => ({ + useDeployAllServices: () => ({ + mutate: mockDeployAllServices, + isLoading: false, + }), +})) + +jest.mock('../hooks/use-services-for-deploy/use-services-for-deploy', () => ({ + useServicesForDeploy: () => ({ + data: [], + isLoading: false, + }), +})) + +describe('DeployWithVersionModal', () => { + const environment = environmentFactoryMock(1)[0] + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render empty state when no services', () => { + renderWithProviders() + + expect(screen.getByTestId('empty-state')).toBeInTheDocument() + }) + + it('should disable deploy button when no services selected', () => { + renderWithProviders() + + expect(screen.getByTestId('submit-button')).toBeDisabled() + }) + + it('should call closeModal when cancel is clicked', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByTestId('cancel-button')) + expect(mockCloseModal).toHaveBeenCalled() + }) +}) diff --git a/libs/domains/environments/feature/src/lib/deploy-with-version-modal/deploy-with-version-modal.tsx b/libs/domains/environments/feature/src/lib/deploy-with-version-modal/deploy-with-version-modal.tsx new file mode 100644 index 00000000000..8cf57be00b4 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/deploy-with-version-modal/deploy-with-version-modal.tsx @@ -0,0 +1,294 @@ +import { type DeployAllRequest, type Environment } from 'qovery-typescript-axios' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { match } from 'ts-pattern' +import { Button, Icon, LoaderSpinner, ScrollShadowWrapper, Truncate, useModal } from '@qovery/shared/ui' +import { useDeployAllServices } from '../hooks/use-deploy-all-services/use-deploy-all-services' +import { + type ServiceForDeploy, + type ServiceVersionInfo, + useServicesForDeploy, +} from '../hooks/use-services-for-deploy/use-services-for-deploy' +import { ServiceVersionRow, type ServiceVersionSelection } from './service-version-row' + +export interface DeployWithVersionModalProps { + environment: Environment +} + +type ServiceSelectionState = Map + +function buildDeployAllPayload(selections: ServiceVersionSelection[]): DeployAllRequest { + const payload: DeployAllRequest = { + applications: [], + containers: [], + jobs: [], + helms: [], + databases: [], + } + + for (const service of selections) { + if (!service.isSelected) continue + + match(service.serviceType) + .with('APPLICATION', () => { + payload.applications!.push({ + application_id: service.id, + git_commit_id: service.selectedVersion?.value, + }) + }) + .with('CONTAINER', () => { + payload.containers!.push({ + id: service.id, + image_tag: service.selectedVersion?.value, + }) + }) + .with('JOB', () => { + const isGitSource = service.sourceType === 'git' + payload.jobs!.push({ + id: service.id, + git_commit_id: isGitSource ? service.selectedVersion?.value : undefined, + image_tag: !isGitSource ? service.selectedVersion?.value : undefined, + }) + }) + .with('HELM', () => { + const isGitSource = service.sourceType === 'git' + payload.helms!.push({ + id: service.id, + chart_version: !isGitSource ? service.selectedVersion?.value : undefined, + git_commit_id: isGitSource ? service.selectedVersion?.value : undefined, + }) + }) + .with('DATABASE', () => { + payload.databases!.push(service.id) + }) + .with('TERRAFORM', () => { + // Terraform is not supported in DeployAllRequest yet + // This is a placeholder for future support + }) + // CRON_JOB and LIFECYCLE_JOB are handled by the JOB case since API returns 'JOB' + .otherwise(() => { + // No-op for unhandled service types + }) + } + + // Remove empty arrays to keep payload clean + if (payload.applications?.length === 0) delete payload.applications + if (payload.containers?.length === 0) delete payload.containers + if (payload.jobs?.length === 0) delete payload.jobs + if (payload.helms?.length === 0) delete payload.helms + if (payload.databases?.length === 0) delete payload.databases + + return payload +} + +function initializeSelections(services: ServiceForDeploy[]): ServiceSelectionState { + return new Map( + services.map((service) => [ + service.id, + { + ...service, + isSelected: false, + selectedVersion: undefined, + }, + ]) + ) +} + +export function DeployWithVersionModal({ environment }: DeployWithVersionModalProps) { + const { closeModal } = useModal() + const { data: services, isLoading: isLoadingServices } = useServicesForDeploy({ + environmentId: environment.id, + }) + const { mutate: deployAllServices, isLoading: isDeploying } = useDeployAllServices() + + const [selections, setSelections] = useState(new Map()) + + // Initialize selections when services are loaded + useEffect(() => { + if (services.length > 0 && selections.size === 0) { + setSelections(initializeSelections(services)) + } + }, [services, selections.size]) + + const selectedServices = useMemo(() => Array.from(selections.values()).filter((s) => s.isSelected), [selections]) + + const selectedCount = selectedServices.length + const totalCount = services.length + + const handleToggle = useCallback((serviceId: string) => { + setSelections((prev) => { + const newSelections = new Map(prev) + const service = newSelections.get(serviceId) + if (service) { + newSelections.set(serviceId, { + ...service, + isSelected: !service.isSelected, + }) + } + return newSelections + }) + }, []) + + const handleVersionChange = useCallback((serviceId: string, version: ServiceVersionInfo | undefined) => { + setSelections((prev) => { + const newSelections = new Map(prev) + const service = newSelections.get(serviceId) + if (service) { + newSelections.set(serviceId, { + ...service, + selectedVersion: version, + }) + } + return newSelections + }) + }, []) + + const handleSelectAll = useCallback(() => { + setSelections((prev) => { + const newSelections = new Map(prev) + newSelections.forEach((service, id) => { + newSelections.set(id, { ...service, isSelected: true }) + }) + return newSelections + }) + }, []) + + const handleDeselectAll = useCallback(() => { + setSelections((prev) => { + const newSelections = new Map(prev) + newSelections.forEach((service, id) => { + newSelections.set(id, { ...service, isSelected: false }) + }) + return newSelections + }) + }, []) + + const handleSubmit = () => { + if (selectedCount === 0) return + + const payload = buildDeployAllPayload(selectedServices) + deployAllServices({ environment, payload }) + closeModal() + } + + // Group services by type for display + const servicesByType = useMemo(() => { + const groups: Record = { + APPLICATION: [], + CONTAINER: [], + JOB: [], + HELM: [], + DATABASE: [], + TERRAFORM: [], + } + + Array.from(selections.values()).forEach((service) => { + groups[service.serviceType].push(service) + }) + + return groups + }, [selections]) + + const groupLabels: Record = { + APPLICATION: 'Applications', + CONTAINER: 'Containers', + JOB: 'Jobs', + HELM: 'Helm Charts', + DATABASE: 'Databases', + TERRAFORM: 'Terraform', + } + + return ( +
+

Deploy with version selection

+

+ Select services and choose specific versions to deploy +

+ +
+

+ For{' '} + + + +

+ + {totalCount > 0 && ( +
+ + {selectedCount} of {totalCount} selected + + {selectedCount > 0 ? ( + + ) : ( + + )} +
+ )} +
+ + {isLoadingServices ? ( +
+ +
+ ) : totalCount === 0 ? ( +
+ +

No services found in this environment

+
+ ) : ( + + {Object.entries(servicesByType).map(([type, services]) => { + if (services.length === 0) return null + + return ( +
+

{groupLabels[type]}

+
+ {services.map((service, index) => { + const prevService = index > 0 ? services[index - 1] : undefined + + return ( + handleToggle(service.id)} + onVersionChange={(version) => handleVersionChange(service.id, version)} + /> + ) + })} +
+
+ ) + })} +
+ )} + +
+ + +
+
+ ) +} + +export default DeployWithVersionModal diff --git a/libs/domains/environments/feature/src/lib/deploy-with-version-modal/index.ts b/libs/domains/environments/feature/src/lib/deploy-with-version-modal/index.ts new file mode 100644 index 00000000000..5619272ef86 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/deploy-with-version-modal/index.ts @@ -0,0 +1,2 @@ +export * from './deploy-with-version-modal' +export * from './service-version-row' diff --git a/libs/domains/environments/feature/src/lib/deploy-with-version-modal/service-version-row.spec.tsx b/libs/domains/environments/feature/src/lib/deploy-with-version-modal/service-version-row.spec.tsx new file mode 100644 index 00000000000..583ef5f8e1d --- /dev/null +++ b/libs/domains/environments/feature/src/lib/deploy-with-version-modal/service-version-row.spec.tsx @@ -0,0 +1,92 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { ServiceVersionRow, type ServiceVersionSelection } from './service-version-row' + +const mockService: ServiceVersionSelection = { + id: 'service-1', + name: 'Test Application', + serviceType: 'APPLICATION', + sourceType: 'git', + isSelected: false, + selectedVersion: undefined, + currentVersion: { + type: 'commit', + value: 'abc1234567890', + displayValue: 'abc1234', + }, +} + +describe('ServiceVersionRow', () => { + const defaultProps = { + service: mockService, + organizationId: 'org-1', + isChecked: false, + onToggle: jest.fn(), + onVersionChange: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render service name and type', () => { + renderWithProviders() + + expect(screen.getByText('Test Application')).toBeInTheDocument() + expect(screen.getByText('Application')).toBeInTheDocument() + }) + + it('should render current version', () => { + renderWithProviders() + + expect(screen.getByText('Current:')).toBeInTheDocument() + }) + + it('should call onToggle when row is clicked', async () => { + const onToggle = jest.fn() + const { userEvent } = renderWithProviders() + + // Get the row button (not the "Select version" button) + const rowButton = screen.getAllByRole('button')[0] + await userEvent.click(rowButton) + expect(onToggle).toHaveBeenCalled() + }) + + it('should render select version button for git-based services', () => { + renderWithProviders() + + expect(screen.getByText('Select version')).toBeInTheDocument() + }) + + it('should not render select version button for database services', () => { + const databaseService: ServiceVersionSelection = { + ...mockService, + serviceType: 'DATABASE', + sourceType: 'database', + } + + renderWithProviders() + + expect(screen.queryByText('Select version')).not.toBeInTheDocument() + }) + + it('should show Change button when version is selected', () => { + const serviceWithVersion: ServiceVersionSelection = { + ...mockService, + selectedVersion: { + type: 'commit', + value: 'def4567890123', + displayValue: 'def4567', + }, + } + + renderWithProviders() + + expect(screen.getByText('Change')).toBeInTheDocument() + }) + + it('should apply checked styling when isChecked is true', () => { + const { container } = renderWithProviders() + + expect(container.firstChild).toHaveClass('border-brand-500') + }) +}) diff --git a/libs/domains/environments/feature/src/lib/deploy-with-version-modal/service-version-row.tsx b/libs/domains/environments/feature/src/lib/deploy-with-version-modal/service-version-row.tsx new file mode 100644 index 00000000000..95355fefc12 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/deploy-with-version-modal/service-version-row.tsx @@ -0,0 +1,396 @@ +import { type IconName } from '@fortawesome/fontawesome-common-types' +import { useQuery } from '@tanstack/react-query' +import { type Commit } from 'qovery-typescript-axios' +import { useMemo, useState } from 'react' +import { match } from 'ts-pattern' +import { sortVersions } from '@qovery/domains/organizations/feature' +import { type ServiceType } from '@qovery/domains/services/data-access' +import { Button, Icon, InputCheckbox, InputSelect, LoaderSpinner, TagCommit, Truncate } from '@qovery/shared/ui' +import { twMerge } from '@qovery/shared/util-js' +import { queries } from '@qovery/state/util-queries' +import { + type ServiceForDeploy, + type ServiceVersionInfo, + type VersionSourceType, + type VersionType, +} from '../hooks/use-services-for-deploy/use-services-for-deploy' + +export interface ServiceVersionSelection extends ServiceForDeploy { + isSelected: boolean + selectedVersion?: ServiceVersionInfo +} + +export interface ServiceVersionRowProps { + service: ServiceVersionSelection + organizationId: string + isChecked: boolean + isSiblingChecked?: boolean + isFirst?: boolean + isLast?: boolean + onToggle: () => void + onVersionChange: (version: ServiceVersionInfo | undefined) => void +} + +function getServiceTypeIcon(serviceType: ServiceType): IconName { + return match(serviceType) + .with('APPLICATION', () => 'code' as IconName) + .with('CONTAINER', () => 'box' as IconName) + .with('DATABASE', () => 'database' as IconName) + .with('JOB', 'CRON_JOB', 'LIFECYCLE_JOB', () => 'clock' as IconName) + .with('HELM', () => 'dharmachakra' as IconName) + .with('TERRAFORM', () => 'layer-group' as IconName) + .exhaustive() +} + +function getServiceTypeLabel(serviceType: ServiceType, sourceType: VersionSourceType): string { + return match({ serviceType, sourceType }) + .with({ serviceType: 'APPLICATION' }, () => 'Application') + .with({ serviceType: 'CONTAINER' }, () => 'Container') + .with({ serviceType: 'DATABASE' }, () => 'Database') + .with({ serviceType: 'JOB', sourceType: 'git' }, () => 'Job (Git)') + .with({ serviceType: 'JOB', sourceType: 'container' }, () => 'Job (Image)') + .with({ serviceType: 'HELM', sourceType: 'git' }, () => 'Helm (Git)') + .with({ serviceType: 'HELM', sourceType: 'helm-repository' }, () => 'Helm (Chart)') + .with({ serviceType: 'TERRAFORM' }, () => 'Terraform') + .otherwise(() => serviceType) +} + +function CurrentBadge() { + return ( + + Current + + ) +} + +function VersionSelector({ + service, + organizationId, + selectedVersion, + onVersionChange, +}: { + service: ServiceVersionSelection + organizationId: string + selectedVersion?: ServiceVersionInfo + onVersionChange: (version: ServiceVersionInfo | undefined) => void +}) { + const { sourceType, serviceType, containerSource, helmRepository, currentVersion } = service + + // Map serviceType to the correct type for commits query + const commitsServiceType = match({ serviceType, sourceType }) + .with({ sourceType: 'git', serviceType: 'APPLICATION' }, () => 'APPLICATION' as const) + .with({ sourceType: 'git', serviceType: 'JOB' }, () => 'JOB' as const) + .with({ sourceType: 'git', serviceType: 'HELM' }, () => 'HELM' as const) + .with({ sourceType: 'git', serviceType: 'TERRAFORM' }, () => 'TERRAFORM' as const) + .otherwise(() => undefined) + + // Determine if each query should be enabled + const isCommitsEnabled = sourceType === 'git' && !!commitsServiceType + const isContainerVersionsEnabled = + sourceType === 'container' && !!containerSource?.registry?.id && !!containerSource?.image_name + const isHelmVersionsEnabled = + sourceType === 'helm-repository' && !!helmRepository?.repositoryId && !!helmRepository?.chartName + + // Fetch commits for git-based services + // Use a default service type to satisfy TypeScript when the query is disabled + const safeCommitsServiceType = commitsServiceType ?? 'APPLICATION' + const { + data: commits, + isFetching: isFetchingCommits, + isError: isCommitsError, + } = useQuery({ + ...queries.services.listCommits({ + serviceId: service.id, + serviceType: safeCommitsServiceType, + }), + staleTime: 3 * 60 * 1000, + retry: false, + refetchOnWindowFocus: false, + enabled: isCommitsEnabled, + }) + + // Fetch container versions for container-based services + const { + data: containerVersions, + isFetching: isFetchingContainerVersions, + isError: isContainerVersionsError, + } = useQuery({ + ...queries.organizations.containerVersions({ + organizationId, + containerRegistryId: containerSource?.registry?.id ?? '', + imageName: containerSource?.image_name ?? '', + }), + select: (data) => + data?.map(({ image_name, versions }) => ({ + image_name, + versions: sortVersions(versions), + })), + refetchOnWindowFocus: false, + enabled: isContainerVersionsEnabled, + }) + + // Fetch helm chart versions for helm repository services + const { + data: helmVersions, + isFetching: isFetchingHelmVersions, + isError: isHelmVersionsError, + } = useQuery({ + ...queries.serviceHelm.helmCharts({ + organizationId, + helmRepositoryId: helmRepository?.repositoryId ?? '', + chartName: helmRepository?.chartName, + }), + select: (data) => data?.sort((a, b) => a.chart_name!.localeCompare(b.chart_name!)), + enabled: isHelmVersionsEnabled, + }) + + // Only check loading/error state for the relevant query based on source type + const isLoading = match(sourceType) + .with('git', () => isFetchingCommits) + .with('container', () => isFetchingContainerVersions) + .with('helm-repository', () => isFetchingHelmVersions) + .with('database', () => false) + .exhaustive() + + const isError = match(sourceType) + .with('git', () => isCommitsError) + .with('container', () => isContainerVersionsError) + .with('helm-repository', () => isHelmVersionsError) + .with('database', () => false) + .exhaustive() + + const options = useMemo(() => { + return match(sourceType) + .with('git', () => + (commits ?? []).map((commit: Commit) => { + const isCurrent = commit.git_commit_id === currentVersion?.value + return { + value: commit.git_commit_id, + label: ( +
+
e.stopPropagation()} className="shrink-0"> + +
+ {commit.message || 'No message'} + {isCurrent && ( + + )} +
+ ), + selectedLabel: commit.message || 'No message', + } + }) + ) + .with('container', () => { + const versions = containerVersions?.[0]?.versions ?? [] + return versions.map((version) => { + const isCurrent = version === currentVersion?.value + return { + value: version, + label: ( +
+ {version} + {isCurrent && ( + + )} +
+ ), + selectedLabel: version, + isDisabled: version === 'latest', + } + }) + }) + .with('helm-repository', () => { + const chartVersions = + helmVersions?.find(({ chart_name }) => chart_name === helmRepository?.chartName)?.versions ?? [] + return chartVersions.map((version) => { + const isCurrent = version === currentVersion?.value + return { + value: version, + label: ( +
+ {version} + {isCurrent && ( + + )} +
+ ), + selectedLabel: version, + } + }) + }) + .with('database', () => []) + .exhaustive() + }, [sourceType, commits, containerVersions, helmVersions, helmRepository?.chartName, currentVersion?.value]) + + const getVersionType = (sourceType: VersionSourceType): VersionType => { + return match(sourceType) + .with('git', () => 'commit' as const) + .with('container', () => 'tag' as const) + .with('helm-repository', () => 'chart-version' as const) + .with('database', () => 'tag' as const) + .exhaustive() + } + + const handleChange = (value: string | null) => { + if (!value) { + onVersionChange(undefined) + return + } + const versionType = getVersionType(sourceType) + onVersionChange({ + type: versionType, + value, + displayValue: versionType === 'commit' ? value.slice(0, 7) : value, + }) + } + + if (isLoading) { + return ( +
+ + Loading versions... +
+ ) + } + + if (isError) { + return ( +
+ + Failed to load versions +
+ ) + } + + if (options.length === 0) { + return ( +
+ + No versions available +
+ ) + } + + return ( + handleChange(value as string | null)} + isSearchable + isClearable + portal + filterOption="startsWith" + /> + ) +} + +export function ServiceVersionRow({ + service, + organizationId, + isChecked, + isSiblingChecked, + isFirst, + isLast, + onToggle, + onVersionChange, +}: ServiceVersionRowProps) { + const [isExpanded, setIsExpanded] = useState(false) + const { sourceType, serviceType, name, currentVersion, selectedVersion } = service + + const canSelectVersion = sourceType !== 'database' + + const borderClasses = twMerge( + 'border border-b-0 dark:border-neutral-400', + isFirst && 'rounded-t', + isLast && 'rounded-b !border-b', + isChecked ? 'border-brand-500 bg-brand-50 dark:bg-neutral-500' : 'border-neutral-250', + isSiblingChecked && 'border-t-brand-500' + ) + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + }} + > +
+ + +
+ + + + {getServiceTypeLabel(serviceType, sourceType)} +
+
+ +
e.stopPropagation()}> + {currentVersion && ( +
+ Current: + {currentVersion.type === 'commit' ? ( + + ) : ( + + {currentVersion.displayValue} + + )} +
+ )} + + {selectedVersion && ( + <> + +
+ {selectedVersion.type === 'commit' ? ( + + ) : ( + + {selectedVersion.displayValue} + + )} +
+ + )} + + {canSelectVersion && ( + + )} +
+
+ + {isExpanded && canSelectVersion && ( +
+ +
+ )} +
+ ) +} + +export default ServiceVersionRow 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 25f1b1fd11c..eeb627dcc2e 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 @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`EnvironmentActionToolbar should match manage deployment snapshot 1`] = ` Deploy latest version for.. +