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 ? ( + + Deselect All + + ) : ( + + Select All + + )} + + )} + + + {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)} + /> + ) + })} + + + ) + })} + + )} + + + + Cancel + + + Deploy {selectedCount} service{selectedCount !== 1 ? 's' : ''} + + + + ) +} + +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 && ( + setIsExpanded(!isExpanded)} + className="ml-2" + > + + {selectedVersion ? 'Change' : 'Select version'} + + )} + + + + {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.. + + + Deploy with version selection.. + @@ -265,7 +278,7 @@ exports[`EnvironmentActionToolbar should match other actions snapshot 1`] = ` /> @@ -294,7 +307,7 @@ exports[`EnvironmentActionToolbar should match other actions snapshot 1`] = ` style="position: fixed; left: 0px; top: 0px; transform: translate(0px, 8px); min-width: max-content; --radix-popper-available-width: 0px; --radix-popper-available-height: -8px; --radix-popper-anchor-width: 0px; --radix-popper-anchor-height: 0px; --radix-popper-transform-origin: 0% 0px;" > { + openModal({ + content: , + options: { + width: 800, + fakeModal: true, // Required for InputSelect scroll to work inside modal + }, + }) + } + return ( @@ -237,6 +248,9 @@ function MenuManageDeployment({ } onSelect={openUpdateAllModal}> Deploy latest version for.. + } onSelect={openDeployWithVersionModal}> + Deploy with version selection.. + > ))} @@ -245,7 +259,6 @@ function MenuManageDeployment({ } function MenuOtherActions({ state, environment }: { state: StateEnum; environment: Environment }) { - const { pathname } = useLocation() const { openModal, closeModal } = useModal() const { openModalConfirmation } = useModalConfirmation() const { mutate: deleteEnvironment } = useDeleteEnvironment({ projectId: environment.project.id }) diff --git a/libs/domains/environments/feature/src/lib/hooks/use-services-for-deploy/use-services-for-deploy.spec.tsx b/libs/domains/environments/feature/src/lib/hooks/use-services-for-deploy/use-services-for-deploy.spec.tsx new file mode 100644 index 00000000000..a1650e56f98 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/hooks/use-services-for-deploy/use-services-for-deploy.spec.tsx @@ -0,0 +1,48 @@ +import { renderHook } from '@testing-library/react' +import { type PropsWithChildren } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useServicesForDeploy } from './use-services-for-deploy' + +// Mock the queries module +jest.mock('@qovery/state/util-queries', () => ({ + queries: { + services: { + list: jest.fn(() => ({ + queryKey: ['services', 'list', 'env-1'], + queryFn: jest.fn(), + })), + }, + }, +})) + +describe('useServicesForDeploy', () => { + const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return ({ children }: PropsWithChildren) => ( + {children} + ) + } + + it('should return empty array when no environmentId', () => { + const { result } = renderHook(() => useServicesForDeploy({ environmentId: '' }), { + wrapper: createWrapper(), + }) + + expect(result.current.data).toEqual([]) + }) + + it('should return isLoading state', () => { + const { result } = renderHook(() => useServicesForDeploy({ environmentId: 'env-1' }), { + wrapper: createWrapper(), + }) + + // Initially should be loading or have data + expect(typeof result.current.isLoading).toBe('boolean') + }) +}) diff --git a/libs/domains/environments/feature/src/lib/hooks/use-services-for-deploy/use-services-for-deploy.ts b/libs/domains/environments/feature/src/lib/hooks/use-services-for-deploy/use-services-for-deploy.ts new file mode 100644 index 00000000000..bcc563c420e --- /dev/null +++ b/libs/domains/environments/feature/src/lib/hooks/use-services-for-deploy/use-services-for-deploy.ts @@ -0,0 +1,282 @@ +import { useQuery } from '@tanstack/react-query' +import { type ContainerRegistryProviderDetailsResponse, type ContainerSource } from 'qovery-typescript-axios' +import { useMemo } from 'react' +import { + type AnyService, + isApplication, + isContainer, + isDatabase, + isHelm, + isJob, + type ServiceType, +} from '@qovery/domains/services/data-access' +import { + isHelmGitSource, + isHelmGitValuesOverride, + isHelmRepositorySource, + isJobContainerSource, + isJobGitSource, +} from '@qovery/shared/enums' +import { queries } from '@qovery/state/util-queries' + +export type VersionSourceType = + | 'git' // Application, Job (docker), Helm (git), Terraform + | 'container' // Container, Job (image) + | 'helm-repository' // Helm (repository) + | 'database' // No version selection + +export type VersionType = 'commit' | 'tag' | 'chart-version' + +export interface ServiceVersionInfo { + type: VersionType + value: string + displayValue: string +} + +export interface ServiceForDeploy { + id: string + name: string + serviceType: ServiceType + sourceType: VersionSourceType + currentVersion?: ServiceVersionInfo + + // For containers/jobs with image source - registry info needed for version fetch + containerSource?: ContainerSource + + // For Helm with repository source - chart info needed for version fetch + helmRepository?: { + repositoryId: string + chartName: string + } + + // For git-based services - needed for commit fetch + gitRepository?: { + deployedCommitId?: string + deployedCommitDate?: string + } + + // For Helm with git values override + hasValuesOverrideGit?: boolean +} + +function mapServiceToDeployInfo(service: AnyService): ServiceForDeploy { + if (isApplication(service)) { + const gitRepo = service.git_repository + return { + id: service.id, + name: service.name, + serviceType: 'APPLICATION', + sourceType: 'git', + currentVersion: gitRepo?.deployed_commit_id + ? { + type: 'commit', + value: gitRepo.deployed_commit_id, + displayValue: gitRepo.deployed_commit_id.slice(0, 7), + } + : undefined, + gitRepository: gitRepo + ? { + deployedCommitId: gitRepo.deployed_commit_id, + deployedCommitDate: gitRepo.deployed_commit_date, + } + : undefined, + } + } + + if (isContainer(service)) { + return { + id: service.id, + name: service.name, + serviceType: 'CONTAINER', + sourceType: 'container', + currentVersion: service.tag + ? { + type: 'tag', + value: service.tag, + displayValue: service.tag, + } + : undefined, + containerSource: { + image_name: service.image_name, + tag: service.tag, + registry: service.registry as ContainerRegistryProviderDetailsResponse, + }, + } + } + + if (isJob(service)) { + const source = service.source + if (isJobGitSource(source)) { + const gitRepo = source.docker?.git_repository + return { + id: service.id, + name: service.name, + serviceType: 'JOB', + sourceType: 'git', + currentVersion: gitRepo?.deployed_commit_id + ? { + type: 'commit', + value: gitRepo.deployed_commit_id, + displayValue: gitRepo.deployed_commit_id.slice(0, 7), + } + : undefined, + gitRepository: gitRepo + ? { + deployedCommitId: gitRepo.deployed_commit_id, + deployedCommitDate: gitRepo.deployed_commit_date, + } + : undefined, + } + } + + if (isJobContainerSource(source)) { + return { + id: service.id, + name: service.name, + serviceType: 'JOB', + sourceType: 'container', + currentVersion: source.image?.tag + ? { + type: 'tag', + value: source.image.tag, + displayValue: source.image.tag, + } + : undefined, + containerSource: source.image + ? { + image_name: source.image.image_name ?? '', + tag: source.image.tag, + registry: source.image.registry as ContainerRegistryProviderDetailsResponse, + } + : undefined, + } + } + + // Fallback for unknown job source + return { + id: service.id, + name: service.name, + serviceType: 'JOB', + sourceType: 'git', + } + } + + if (isHelm(service)) { + const source = service.source + + if (isHelmGitSource(source)) { + const gitRepo = source.git?.git_repository + return { + id: service.id, + name: service.name, + serviceType: 'HELM', + sourceType: 'git', + currentVersion: gitRepo?.deployed_commit_id + ? { + type: 'commit', + value: gitRepo.deployed_commit_id, + displayValue: gitRepo.deployed_commit_id.slice(0, 7), + } + : undefined, + gitRepository: gitRepo + ? { + deployedCommitId: gitRepo.deployed_commit_id, + deployedCommitDate: gitRepo.deployed_commit_date, + } + : undefined, + hasValuesOverrideGit: isHelmGitValuesOverride(service.values_override), + } + } + + if (isHelmRepositorySource(source)) { + const repo = source.repository + return { + id: service.id, + name: service.name, + serviceType: 'HELM', + sourceType: 'helm-repository', + currentVersion: repo?.chart_version + ? { + type: 'chart-version', + value: repo.chart_version, + displayValue: repo.chart_version, + } + : undefined, + helmRepository: repo?.repository?.id + ? { + repositoryId: repo.repository.id, + chartName: repo.chart_name ?? '', + } + : undefined, + hasValuesOverrideGit: isHelmGitValuesOverride(service.values_override), + } + } + + // Fallback for unknown helm source + return { + id: service.id, + name: service.name, + serviceType: 'HELM', + sourceType: 'helm-repository', + } + } + + if (isDatabase(service)) { + return { + id: service.id, + name: service.name, + serviceType: 'DATABASE', + sourceType: 'database', + currentVersion: service.version + ? { + type: 'tag', + value: service.version, + displayValue: service.version, + } + : undefined, + } + } + + // Terraform + const terraform = service + const tfGitRepo = terraform.terraform_files_source?.git?.git_repository + return { + id: service.id, + name: service.name, + serviceType: 'TERRAFORM', + sourceType: 'git', + currentVersion: tfGitRepo?.deployed_commit_id + ? { + type: 'commit', + value: tfGitRepo.deployed_commit_id, + displayValue: tfGitRepo.deployed_commit_id.slice(0, 7), + } + : undefined, + gitRepository: tfGitRepo + ? { + deployedCommitId: tfGitRepo.deployed_commit_id, + deployedCommitDate: tfGitRepo.deployed_commit_date, + } + : undefined, + } +} + +export interface UseServicesForDeployProps { + environmentId: string +} + +export function useServicesForDeploy({ environmentId }: UseServicesForDeployProps) { + const { data: services = [], isLoading } = useQuery({ + ...queries.services.list(environmentId), + enabled: Boolean(environmentId), + }) + + const servicesForDeploy = useMemo(() => services.map(mapServiceToDeployInfo), [services]) + + return { + data: servicesForDeploy, + isLoading, + } +} + +export default useServicesForDeploy diff --git a/libs/shared/interfaces/src/lib/common/value.interface.ts b/libs/shared/interfaces/src/lib/common/value.interface.ts index 3971fbd556b..67d9a3becc2 100644 --- a/libs/shared/interfaces/src/lib/common/value.interface.ts +++ b/libs/shared/interfaces/src/lib/common/value.interface.ts @@ -8,4 +8,6 @@ export interface Value { onClickEditable?: () => void description?: string searchText?: string + /** Optional label to display when this option is selected (in the closed dropdown). Falls back to `label` if not provided. */ + selectedLabel?: ReactNode } diff --git a/libs/shared/ui/src/lib/components/inputs/input-select/input-select.tsx b/libs/shared/ui/src/lib/components/inputs/input-select/input-select.tsx index 4aa1633e145..0be284a8d3d 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-select/input-select.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-select/input-select.tsx @@ -98,6 +98,10 @@ export function InputSelect({ if (values) { onChange && onChange((values as Value).value) setSelectedValue((values as Value).value) + } else { + // Handle clearing the selection + onChange && onChange('') + setSelectedValue('') } } } @@ -183,7 +187,7 @@ export function InputSelect({ const SingleValue = (props: SingleValueProps) => ( - {props.data.label} + {props.data.selectedLabel ?? props.data.label} {props.data.description ? `: ${props.data.description}` : ''} ) diff --git a/libs/shared/ui/src/lib/styles/components/select.scss b/libs/shared/ui/src/lib/styles/components/select.scss index 70f1524f675..1ab36f03e90 100644 --- a/libs/shared/ui/src/lib/styles/components/select.scss +++ b/libs/shared/ui/src/lib/styles/components/select.scss @@ -51,9 +51,27 @@ // buttons .input-select__indicators { + display: flex !important; + margin-right: 1.5rem; // Leave space for the custom chevron icon +} + +.input-select__dropdown-indicator { + display: none !important; +} + +.input-select__indicator-separator { display: none !important; } +.input-select__clear-indicator { + @apply cursor-pointer text-neutral-350; + padding: 4px !important; + + &:hover { + @apply text-neutral-400; + } +} + // menu .input-select__menu { width: calc(100% + 2rem) !important; @@ -78,6 +96,7 @@ label { @apply static translate-y-0 text-ssm font-medium text-neutral-400; + cursor: pointer !important; } .input-select__checkbox {
+ Select services and choose specific versions to deploy +
+ For{' '} + + + +
No services found in this environment