Skip to content

Commit 42cbf69

Browse files
authored
feat(argocd): add ArgoCD associated services modal (#2717)
* feat(argocd): add ArgoCD associated services modal * fix(dependencies): pin qovery-typescript-axios version to 1.1.895 in package.json and yarn.lock
1 parent c3d5730 commit 42cbf69

9 files changed

Lines changed: 370 additions & 18 deletions

File tree

libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@ export const organizations = createQueryKeys('organizations', {
202202
return response.data.results
203203
},
204204
}),
205+
argoCdAssociatedServices: ({ clusterId }: { clusterId: string }) => ({
206+
queryKey: [clusterId],
207+
async queryFn() {
208+
const response = await argoCdApi.getArgoCdAssociatedServices(clusterId)
209+
return response.data.results
210+
},
211+
}),
205212
authProviders: ({ organizationId }: { organizationId: string }) => ({
206213
queryKey: [organizationId],
207214
async queryFn() {

libs/domains/organizations/feature/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export * from './lib/label-create-edit-modal/label-create-edit-modal'
1919
export * from './lib/label-setting/label-setting'
2020
export * from './lib/helm-repository-services-list-modal/helm-repository-services-list-modal'
2121
export * from './lib/container-registry-services-list-modal/container-registry-services-list-modal'
22+
export * from './lib/argocd-associated-services-list-modal/argocd-associated-services-list-modal'
2223
export * from './lib/organization-overview/organization-overview'
2324
export * from './lib/hooks/use-add-credit-card/use-add-credit-card'
2425
export * from './lib/hooks/use-add-credit-code/use-add-credit-code'
@@ -87,6 +88,7 @@ export * from './lib/hooks/use-container-images/use-container-images'
8788
export * from './lib/hooks/use-container-versions/use-container-versions'
8889
export * from './lib/hooks/use-helm-repository-associated-services/use-helm-repository-associated-services'
8990
export * from './lib/hooks/use-container-registry-associated-services/use-container-registry-associated-services'
91+
export * from './lib/hooks/use-argocd-associated-services/use-argocd-associated-services'
9092
export * from './lib/hooks/use-organization-credentials/use-organization-credentials'
9193
export * from './lib/hooks/use-organization-argocd-integrations/use-organization-argocd-integrations'
9294
export * from './lib/hooks/use-save-argocd-destination-cluster-mapping/use-save-argocd-destination-cluster-mapping'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { ArgocdAssociatedServiceType } from 'qovery-typescript-axios'
2+
import { renderWithProviders } from '@qovery/shared/util-tests'
3+
import * as useArgoCdAssociatedServices from '../hooks/use-argocd-associated-services/use-argocd-associated-services'
4+
import ArgoCdAssociatedServicesListModal, {
5+
type ArgoCdAssociatedServicesListModalProps,
6+
argoCdGroupByProjectEnvironmentsServices,
7+
} from './argocd-associated-services-list-modal'
8+
9+
const useArgoCdAssociatedServicesMockSpy = jest.spyOn(
10+
useArgoCdAssociatedServices,
11+
'useArgoCdAssociatedServices'
12+
) as jest.Mock
13+
14+
const props: ArgoCdAssociatedServicesListModalProps = {
15+
organizationId: 'organization-id',
16+
clusterId: 'cluster-id',
17+
onClose: jest.fn(),
18+
associatedServicesCount: 3,
19+
}
20+
21+
const data = [
22+
{
23+
project_id: '1',
24+
project_name: 'Project 1',
25+
environment_id: '1',
26+
environment_name: 'Development',
27+
service_id: '101',
28+
service_name: 'Service 1',
29+
service_type: ArgocdAssociatedServiceType.ARGOCD_APP,
30+
},
31+
{
32+
project_id: '1',
33+
project_name: 'Project 1',
34+
environment_id: '1',
35+
environment_name: 'Development',
36+
service_id: '102',
37+
service_name: 'Service 2',
38+
service_type: ArgocdAssociatedServiceType.ARGOCD_APP,
39+
},
40+
{
41+
project_id: '2',
42+
project_name: 'Project 2',
43+
environment_id: '1',
44+
environment_name: 'Staging',
45+
service_id: '201',
46+
service_name: 'Service 3',
47+
service_type: ArgocdAssociatedServiceType.ARGOCD_APP,
48+
},
49+
]
50+
51+
describe('ArgoCdAssociatedServicesListModal', () => {
52+
beforeEach(() => {
53+
useArgoCdAssociatedServicesMockSpy.mockReturnValue({
54+
data,
55+
})
56+
})
57+
58+
it('should render successfully', () => {
59+
const { baseElement } = renderWithProviders(<ArgoCdAssociatedServicesListModal {...props} />)
60+
expect(baseElement).toBeTruthy()
61+
})
62+
63+
it('should group data by projects, environments, and services correctly', () => {
64+
const result = argoCdGroupByProjectEnvironmentsServices(data)
65+
66+
expect(result).toHaveLength(2)
67+
68+
expect(result[0].project_id).toBe('1')
69+
expect(result[0].project_name).toBe('Project 1')
70+
expect(result[0].environments).toHaveLength(1)
71+
expect(result[0].environments[0].environment_id).toBe('1')
72+
expect(result[0].environments[0].environment_name).toBe('Development')
73+
expect(result[0].environments[0].services).toHaveLength(2)
74+
75+
expect(result[1].project_id).toBe('2')
76+
expect(result[1].project_name).toBe('Project 2')
77+
expect(result[1].environments).toHaveLength(1)
78+
expect(result[1].environments[0].environment_id).toBe('1')
79+
expect(result[1].environments[0].environment_name).toBe('Staging')
80+
expect(result[1].environments[0].services).toHaveLength(1)
81+
})
82+
})
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { type ArgocdAssociatedServicesResponse } from 'qovery-typescript-axios'
2+
import { Suspense, useState } from 'react'
3+
import { IconEnum } from '@qovery/shared/enums'
4+
import { Heading, Icon, InputSearch, Link, LoaderSpinner, Section, TreeView } from '@qovery/shared/ui'
5+
import { useArgoCdAssociatedServices } from '../hooks/use-argocd-associated-services/use-argocd-associated-services'
6+
7+
export interface ArgoCdAssociatedServicesListModalProps {
8+
organizationId: string
9+
clusterId: string
10+
onClose: () => void
11+
associatedServicesCount: number
12+
}
13+
14+
interface Service {
15+
service_id: string
16+
service_name: string
17+
}
18+
19+
interface Environment {
20+
environment_id: string
21+
environment_name: string
22+
services: Service[]
23+
}
24+
25+
interface Project {
26+
project_id: string
27+
project_name: string
28+
environments: Environment[]
29+
}
30+
31+
export function argoCdGroupByProjectEnvironmentsServices(
32+
data: ArgocdAssociatedServicesResponse[],
33+
searchValue?: string
34+
) {
35+
const projects: Project[] = []
36+
37+
data.forEach(({ project_id, project_name, environment_id, environment_name, service_id, service_name }) => {
38+
if (
39+
searchValue === undefined ||
40+
project_name.toLowerCase().includes(searchValue.toLowerCase()) ||
41+
environment_name.toLowerCase().includes(searchValue.toLowerCase()) ||
42+
service_name.toLowerCase().includes(searchValue.toLowerCase())
43+
) {
44+
let project = projects.find((proj) => proj.project_id === project_id)
45+
if (!project) {
46+
project = {
47+
project_id,
48+
project_name,
49+
environments: [],
50+
}
51+
projects.push(project)
52+
}
53+
54+
let environment = project.environments.find((env) => env.environment_id === environment_id)
55+
if (!environment) {
56+
environment = {
57+
environment_id,
58+
environment_name,
59+
services: [],
60+
}
61+
project.environments.push(environment)
62+
}
63+
64+
environment.services.push({ service_id, service_name })
65+
}
66+
})
67+
68+
return projects
69+
}
70+
71+
export function ArgoCdAssociatedServicesListModal({
72+
organizationId,
73+
clusterId,
74+
associatedServicesCount,
75+
onClose,
76+
}: ArgoCdAssociatedServicesListModalProps) {
77+
return (
78+
<Section className="p-6">
79+
<Heading className="mb-6 text-2xl text-neutral">Associated services ({associatedServicesCount})</Heading>
80+
<Suspense fallback={<ArgoCdAssociatedServicesListModalSkeleton />}>
81+
<ArgoCdAssociatedServicesListModalContent
82+
organizationId={organizationId}
83+
clusterId={clusterId}
84+
onClose={onClose}
85+
/>
86+
</Suspense>
87+
</Section>
88+
)
89+
}
90+
91+
function ArgoCdAssociatedServicesListModalSkeleton() {
92+
return (
93+
<div className="flex h-40 items-start justify-center p-5">
94+
<LoaderSpinner className="w-5" />
95+
</div>
96+
)
97+
}
98+
99+
function ArgoCdAssociatedServicesListModalContent({
100+
organizationId,
101+
clusterId,
102+
onClose,
103+
}: Omit<ArgoCdAssociatedServicesListModalProps, 'associatedServicesCount'>) {
104+
const { data: argoCdAssociatedServices = [] } = useArgoCdAssociatedServices({
105+
clusterId,
106+
suspense: true,
107+
})
108+
const [searchValue, setSearchValue] = useState<string | undefined>()
109+
110+
const data = argoCdGroupByProjectEnvironmentsServices(argoCdAssociatedServices, searchValue)
111+
112+
return (
113+
<>
114+
<InputSearch
115+
className="mb-3"
116+
placeholder="Search by project, environment, service name"
117+
onChange={(value) => setSearchValue(value)}
118+
/>
119+
{data.length > 0 ? (
120+
<TreeView.Root
121+
type="single"
122+
collapsible
123+
className="rounded border border-neutral bg-surface-neutral-subtle px-4 py-2"
124+
>
125+
{data.map((project) => (
126+
<TreeView.Item key={project.project_id} value={project.project_name}>
127+
<TreeView.Trigger>{project.project_name}</TreeView.Trigger>
128+
<TreeView.Content>
129+
{project.environments.map((environment) => (
130+
<TreeView.Root key={environment.environment_id} type="single" collapsible>
131+
<TreeView.Item value={environment.environment_name}>
132+
<TreeView.Trigger>
133+
<Link
134+
color="brand"
135+
onClick={() => onClose()}
136+
to="/organization/$organizationId/project/$projectId/environment/$environmentId"
137+
params={{
138+
organizationId,
139+
environmentId: environment.environment_id,
140+
projectId: project.project_id,
141+
}}
142+
className="text-sm"
143+
>
144+
{environment.environment_name}
145+
</Link>
146+
</TreeView.Trigger>
147+
<TreeView.Content>
148+
<ul>
149+
{environment.services.map((service) => (
150+
<li key={service.service_id} className="border-l border-neutral">
151+
<Link
152+
color="brand"
153+
onClick={() => onClose()}
154+
to="/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId"
155+
params={{
156+
organizationId,
157+
environmentId: environment.environment_id,
158+
serviceId: service.service_id,
159+
projectId: project.project_id,
160+
}}
161+
className="flex items-center py-1.5 pl-5 text-sm"
162+
>
163+
<Icon name={IconEnum.ARGOCD} width={20} className="mr-2" />
164+
{service.service_name}
165+
</Link>
166+
</li>
167+
))}
168+
</ul>
169+
</TreeView.Content>
170+
</TreeView.Item>
171+
</TreeView.Root>
172+
))}
173+
</TreeView.Content>
174+
</TreeView.Item>
175+
))}
176+
</TreeView.Root>
177+
) : (
178+
<div className="px-5 py-4 text-center">
179+
<Icon iconName="wave-pulse" className="text-neutral-subtle" />
180+
<p className="mt-1 text-xs font-medium text-neutral-subtle">No value found</p>
181+
</div>
182+
)}
183+
</>
184+
)
185+
}
186+
187+
export default ArgoCdAssociatedServicesListModal
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import { queries } from '@qovery/state/util-queries'
3+
4+
export interface UseArgoCdAssociatedServicesProps {
5+
clusterId: string
6+
suspense?: boolean
7+
}
8+
9+
export function useArgoCdAssociatedServices({ clusterId, suspense = false }: UseArgoCdAssociatedServicesProps) {
10+
return useQuery({
11+
...queries.organizations.argoCdAssociatedServices({ clusterId }),
12+
suspense,
13+
})
14+
}
15+
16+
export default useArgoCdAssociatedServices

libs/domains/organizations/feature/src/lib/settings-argocd-integration/settings-argocd-integration.spec.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,23 @@ describe('SettingsArgoCdIntegration', () => {
107107
expect(screen.getByText('Unlinked clusters (1)')).toBeInTheDocument()
108108
expect(screen.getByText('AWS EKS Demo')).toBeInTheDocument()
109109

110+
await userEvent.click(screen.getByTestId('argocd-associated-services-cluster-1'))
111+
112+
expect(mockOpenModal).toHaveBeenCalledWith(
113+
expect.objectContaining({
114+
content: expect.objectContaining({
115+
props: expect.objectContaining({
116+
organizationId: 'org-1',
117+
clusterId: 'cluster-1',
118+
associatedServicesCount: 4,
119+
}),
120+
}),
121+
options: {
122+
width: 680,
123+
},
124+
})
125+
)
126+
110127
await userEvent.click(screen.getByText('Unlinked clusters (1)'))
111128

112129
expect(

0 commit comments

Comments
 (0)