diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx index c5d0fdf235f..77dd386af39 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx @@ -17,7 +17,7 @@ import { useDeploymentStatus, useEnvironment, } from '@qovery/domains/environments/feature' -import { isEditableService } from '@qovery/domains/services/data-access' +import { isArgoCd, isEditableService } from '@qovery/domains/services/data-access' import { ArgoCdServiceList, useServices } from '@qovery/domains/services/feature' import { Heading, Icon, Link, Navbar, Section, Tooltip } from '@qovery/shared/ui' @@ -58,10 +58,7 @@ function RouteComponent() { const activeTabId = tabs.find((tab) => matchRoute({ to: tab.routeId }))?.id const isServicesListTab = activeTabId === 'services' const qoveryServicesCount = useMemo(() => services.filter(isEditableService).length, [services]) - const argoCdServicesCount = useMemo( - () => services.filter((service) => !isEditableService(service)).length, - [services] - ) + const argoCdServicesCount = useMemo(() => services.filter(isArgoCd).length, [services]) const hasQoveryServices = qoveryServicesCount > 0 const hasArgoCdServices = argoCdServicesCount > 0 const shouldDisplayQoveryServicesSubtitle = isServicesListTab && hasArgoCdServices @@ -130,7 +127,7 @@ function RouteComponent() {
{shouldDisplayQoveryServicesSubtitle && ( - Qovery native services + Qovery services )} {shouldDisplayArgoCdServicesAboveQovery ? ( diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest.tsx index bce7cf281ec..97e88e3f6d0 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest.tsx @@ -1,4 +1,8 @@ -import { createFileRoute } from '@tanstack/react-router' +import { Navigate, createFileRoute, useParams } from '@tanstack/react-router' +import { Suspense } from 'react' +import { isArgoCd } from '@qovery/domains/services/data-access' +import { ArgoCdManifest, useServiceSummary } from '@qovery/domains/services/feature' +import { ErrorBoundary, LoaderSpinner } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' export const Route = createFileRoute( @@ -7,8 +11,44 @@ export const Route = createFileRoute( component: RouteComponent, }) -function RouteComponent() { +function ManifestLoader() { + return ( +
+ +
+ ) +} + +function ManifestRouteContent() { + const { organizationId = '', projectId = '', environmentId = '', serviceId = '' } = useParams({ strict: false }) + const { data: service } = useServiceSummary({ + environmentId, + serviceId, + enabled: Boolean(environmentId) && Boolean(serviceId), + suspense: true, + }) + useDocumentTitle('Service - Manifest') - return
+ if (!service || !isArgoCd(service)) { + return ( + + ) + } + + return +} + +function RouteComponent() { + return ( + + }> + + + + ) } diff --git a/apps/console/src/routes/_authenticated/organization/route.tsx b/apps/console/src/routes/_authenticated/organization/route.tsx index 5636eeb3380..c7e12008dc0 100644 --- a/apps/console/src/routes/_authenticated/organization/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/route.tsx @@ -6,7 +6,7 @@ import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useClusters } from '@qovery/domains/clusters/feature' import { useEnvironment } from '@qovery/domains/environments/feature' import { useProject } from '@qovery/domains/projects/feature' -import { type AnyService, isEditableService, isManagedDatabase } from '@qovery/domains/services/data-access' +import { type AnyService, isArgoCd, isEditableService, isManagedDatabase } from '@qovery/domains/services/data-access' import { useRecentServices, useServiceSummary } from '@qovery/domains/services/feature' import { AssistantPanelOutlet, AssistantProvider } from '@qovery/shared/assistant/feature' import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/context' @@ -252,7 +252,7 @@ function createRoutePatternRegex(routeIdPattern: string): RegExp { } function getServiceTabs(service?: AnyService, cluster?: Cluster) { - const isArgoCdService = service ? !isEditableService(service) : false + const isArgoCdService = service ? isArgoCd(service) : false if (isArgoCdService) { return SERVICE_TABS.filter((tab) => ARGOCD_SERVICE_TAB_IDS.includes(tab.id)) diff --git a/libs/domains/organizations/feature/src/lib/container-registry-services-list-modal/__snapshots__/container-registry-services-list-modal.spec.tsx.snap b/libs/domains/organizations/feature/src/lib/container-registry-services-list-modal/__snapshots__/container-registry-services-list-modal.spec.tsx.snap index 40bdcecc699..3220c0048a3 100644 --- a/libs/domains/organizations/feature/src/lib/container-registry-services-list-modal/__snapshots__/container-registry-services-list-modal.spec.tsx.snap +++ b/libs/domains/organizations/feature/src/lib/container-registry-services-list-modal/__snapshots__/container-registry-services-list-modal.spec.tsx.snap @@ -21,7 +21,7 @@ exports[`ContainerRegistryServicesListModal should match snapshots 1`] = ` class="fa-regular fa-magnifying-glass absolute left-3 top-1/2 block -translate-y-1/2 text-base leading-none text-neutral-subtle" /> - {/* Split panel: Tree list (with search) and Details */}
- {/* Left panel: Search + Resource tree list */} -
- {/* Search bar */} -
- -
+
+ - {/* Tree list */} -
+
- {/* Right panel: Resource details */} -
+
diff --git a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts index 3fcce92f7e3..cdb40cea6fc 100644 --- a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts +++ b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts @@ -218,6 +218,10 @@ export function isHelm(service: AnyService): service is Helm { return service.service_type === 'HELM' } +export function isArgoCd(service: AnyService): service is ArgoCd { + return service.service_type === 'ARGOCD_APP' +} + export function isEditableService(service: AnyService): service is EditableService { return service.service_type !== 'ARGOCD_APP' } @@ -270,6 +274,13 @@ export const services = createQueryKeys('services', { return (response.data.results ?? []).filter((service) => service.service_type === 'ARGOCD_APP') }, }), + argocdManifest: (serviceId: string) => ({ + queryKey: [serviceId], + async queryFn() { + const response = await argoCdApi.getArgoCdAppManifest(serviceId) + return response.data + }, + }), list: (environmentId: string) => ({ queryKey: [environmentId], async queryFn() { diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index fe3412d2987..1e9bae2d6a3 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -29,6 +29,7 @@ export * from './lib/hooks/use-service-summary/use-service-summary' export * from './lib/hooks/use-service/use-service' export * from './lib/hooks/use-services/use-services' export * from './lib/hooks/use-argocd-services/use-argocd-services' +export * from './lib/hooks/use-argocd-manifest/use-argocd-manifest' export * from './lib/hooks/use-service-deployment-and-running-statuses/use-service-deployment-and-running-statuses' export * from './lib/hooks/use-delete-all-services/use-delete-all-services' export * from './lib/hooks/use-delete-service/use-delete-service' @@ -55,6 +56,7 @@ export * from './lib/hooks/use-recent-services/use-recent-services' export * from './lib/hooks/use-favorite-services/use-favorite-services' export * from './lib/service-actions/service-actions' export * from './lib/argocd-service-list/argocd-service-list' +export * from './lib/argocd-manifest/argocd-manifest' export * from './lib/service-deployment-status-label/service-deployment-status-label' export * from './lib/service-overview/service-header/service-header' export * from './lib/service-links-popover/service-links-popover' diff --git a/libs/domains/services/feature/src/lib/argocd-manifest/argocd-manifest.spec.tsx b/libs/domains/services/feature/src/lib/argocd-manifest/argocd-manifest.spec.tsx new file mode 100644 index 00000000000..0348068385e --- /dev/null +++ b/libs/domains/services/feature/src/lib/argocd-manifest/argocd-manifest.spec.tsx @@ -0,0 +1,167 @@ +import { type UseQueryResult } from '@tanstack/react-query' +import { type ArgocdAppManifestResponse } from 'qovery-typescript-axios' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import { useArgoCdManifest } from '../hooks/use-argocd-manifest/use-argocd-manifest' +import { ArgoCdManifest, formatLiveState, toManifestResources } from './argocd-manifest' + +jest.mock('../hooks/use-argocd-manifest/use-argocd-manifest') + +jest.mock('@qovery/shared/ui', () => { + const actual = jest.requireActual('@qovery/shared/ui') + + return { + ...actual, + CodeEditor: ({ value }: { value?: string }) =>
{value}
, + CodeDiffEditor: ({ original, modified }: { original: string; modified: string }) => ( +
+
{original}
+
{modified}
+
+ ), + } +}) + +const mockUseArgoCdManifest = useArgoCdManifest as jest.MockedFunction + +const createUseArgoCdManifestResult = ( + result: Partial> +): UseQueryResult => + ({ + data: undefined, + isLoading: false, + isError: false, + ...result, + }) as unknown as UseQueryResult + +const manifestResponse: ArgocdAppManifestResponse = { + manifest_metadata: { + managed_resources: [ + { + kind: 'Service', + name: 'api', + liveState: JSON.stringify({ kind: 'Service', metadata: { name: 'api' } }), + targetState: JSON.stringify({ kind: 'Service', metadata: { name: 'api-target' } }), + }, + { + kind: 'ConfigMap', + name: 'settings', + liveState: 'not-json', + targetState: 'target-not-json', + }, + ], + }, +} + +describe('ArgoCdManifest', () => { + beforeEach(() => { + mockUseArgoCdManifest.mockReturnValue(createUseArgoCdManifestResult({ data: manifestResponse })) + }) + + it('should adapt ArgoCD resources to tree resources with stable ids', () => { + expect(toManifestResources(manifestResponse.manifest_metadata.managed_resources ?? [])).toEqual([ + expect.objectContaining({ + id: 'Service:api:0', + resourceType: 'Service', + displayName: 'Service', + name: 'api', + targetState: JSON.stringify({ kind: 'Service', metadata: { name: 'api-target' } }), + }), + expect.objectContaining({ + id: 'ConfigMap:settings:1', + resourceType: 'ConfigMap', + displayName: 'ConfigMap', + name: 'settings', + }), + ]) + }) + + it('should format valid JSON liveState', () => { + expect(formatLiveState(JSON.stringify({ kind: 'Service' }))).toBe('{\n "kind": "Service"\n}') + }) + + it('should keep raw liveState when JSON parsing fails', () => { + expect(formatLiveState('not-json')).toBe('not-json') + }) + + it('should display resources and select the first one by default', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /api/ })).toBeInTheDocument() + }) + + expect(screen.getByRole('button', { name: /settings/ })).toBeInTheDocument() + expect(screen.getByTestId('code-editor')).toHaveTextContent('"kind": "Service"') + }) + + it('should display selected resource liveState', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(await screen.findByRole('button', { name: /settings/ })) + + expect(screen.getByTestId('code-editor')).toHaveTextContent('not-json') + }) + + it('should display a diff between target and live state when diff is enabled', async () => { + const { userEvent } = renderWithProviders() + + const toggle = screen.getByTestId('input-toggle-button') + + expect(toggle).toBeInTheDocument() + + await userEvent.click(toggle) + + expect(screen.getByTestId('code-diff-original')).toHaveTextContent('"name": "api-target"') + expect(screen.getByTestId('code-diff-modified')).toHaveTextContent('"name": "api"') + expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument() + }) + + it('should toggle diff mode when clicking the diff label', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(await screen.findByText('Compare with target')) + + expect(screen.getByTestId('code-diff-editor')).toBeInTheDocument() + }) + + it('should display an empty state when there are no managed resources', () => { + mockUseArgoCdManifest.mockReturnValue( + createUseArgoCdManifestResult({ + data: { + manifest_metadata: { + managed_resources: [], + }, + }, + }) + ) + + renderWithProviders() + + expect(screen.getByText('No managed resources')).toBeInTheDocument() + }) + + it('should fallback optional resource fields in the UI adapter', () => { + expect(toManifestResources([{}])).toEqual([ + expect.objectContaining({ + id: 'Unknown:Unnamed:0', + resourceType: 'Unknown', + displayName: 'Unknown', + name: 'Unnamed', + liveState: '', + targetState: '', + }), + ]) + }) + + it('should display an error state when manifest loading fails', () => { + mockUseArgoCdManifest.mockReturnValue( + createUseArgoCdManifestResult({ + isError: true, + }) + ) + + renderWithProviders() + + expect(screen.getByText('Unable to load manifest')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/services/feature/src/lib/argocd-manifest/argocd-manifest.tsx b/libs/domains/services/feature/src/lib/argocd-manifest/argocd-manifest.tsx new file mode 100644 index 00000000000..ede43caf1eb --- /dev/null +++ b/libs/domains/services/feature/src/lib/argocd-manifest/argocd-manifest.tsx @@ -0,0 +1,173 @@ +import { useParams } from '@tanstack/react-router' +import { type ArgocdManagedResource } from 'qovery-typescript-axios' +import { type ReactElement, useEffect, useMemo, useState } from 'react' +import { ResourceTreeList, type ResourceTreeResource } from '@qovery/shared/console-shared' +import { CodeDiffEditor, CodeEditor, EmptyState, Heading, InputSearch, InputToggle, Section } from '@qovery/shared/ui' +import { useArgoCdManifest } from '../hooks/use-argocd-manifest/use-argocd-manifest' + +interface ArgoCdManifestResource extends ResourceTreeResource { + liveState: string + targetState: string +} + +export function formatLiveState(liveState: string): string { + try { + return JSON.stringify(JSON.parse(liveState), null, 2) + } catch { + return liveState + } +} + +function getResourceId(resource: ArgocdManagedResource, index: number): string { + return `${resource.kind ?? 'Unknown'}:${resource.name ?? 'Unnamed'}:${index}` +} + +export function toManifestResources(resources: ArgocdManagedResource[]): ArgoCdManifestResource[] { + return resources.map((resource, index) => ({ + id: getResourceId(resource, index), + resourceType: resource.kind ?? 'Unknown', + displayName: resource.kind ?? 'Unknown', + name: resource.name ?? 'Unnamed', + address: `${resource.kind ?? 'Unknown'}.${resource.name ?? 'Unnamed'}`, + attributes: { + kind: resource.kind ?? '', + name: resource.name ?? '', + }, + liveState: resource.liveState ?? '', + targetState: resource.targetState ?? '', + })) +} + +function ArgoCdManifestState({ + icon, + title, + description, +}: { + icon: Parameters[0]['icon'] + title: string + description: string +}): ReactElement { + return ( +
+
+
+ Manifest +
+
+ +
+
+ ) +} + +export function ArgoCdManifest() { + const { serviceId = '' } = useParams({ strict: false }) ?? {} + const [searchQuery, setSearchQuery] = useState('') + const [selectedResourceId, setSelectedResourceId] = useState(null) + const [showDiff, setShowDiff] = useState(false) + const { data, isError } = useArgoCdManifest({ serviceId, suspense: true }) + + const resources = useMemo( + () => toManifestResources(data?.manifest_metadata?.managed_resources ?? []), + [data?.manifest_metadata?.managed_resources] + ) + const stateResources = useMemo( + () => + resources.filter((resource) => { + if (showDiff) return resource.liveState || resource.targetState + return resource.liveState + }), + [resources, showDiff] + ) + + // Necessary to ensure the selected resource is always valid + useEffect(() => { + if (!selectedResourceId && stateResources.length > 0) { + setSelectedResourceId(stateResources[0].id) + return + } + + if (selectedResourceId && !stateResources.some((resource) => resource.id === selectedResourceId)) { + setSelectedResourceId(stateResources[0]?.id ?? null) + } + }, [stateResources, selectedResourceId]) + + const selectedResource = + stateResources.find((resource) => resource.id === selectedResourceId) ?? + (stateResources.length > 0 ? stateResources[0] : null) + + if (isError) { + return ( + + ) + } + + if (resources.length === 0) { + return ( + + ) + } + + return ( +
+
+
+ Manifest +
+
+ +
+
+ +
+ + +
+ +
+
+ +
+ {showDiff ? ( + + ) : ( + + )} +
+
+
+
+ ) +} + +export default ArgoCdManifest diff --git a/libs/domains/services/feature/src/lib/hooks/use-argocd-manifest/use-argocd-manifest.ts b/libs/domains/services/feature/src/lib/hooks/use-argocd-manifest/use-argocd-manifest.ts new file mode 100644 index 00000000000..a381d5831fb --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-argocd-manifest/use-argocd-manifest.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query' +import { services } from '@qovery/domains/services/data-access' + +export interface UseArgoCdManifestProps { + serviceId?: string + enabled?: boolean + suspense?: boolean +} + +export function useArgoCdManifest({ serviceId, enabled = true, suspense = false }: UseArgoCdManifestProps) { + return useQuery({ + ...services.argocdManifest(serviceId ?? ''), + enabled: Boolean(serviceId) && enabled, + suspense, + }) +} + +export default useArgoCdManifest diff --git a/libs/domains/services/feature/src/lib/select-commit-modal/__snapshots__/select-commit-modal.spec.tsx.snap b/libs/domains/services/feature/src/lib/select-commit-modal/__snapshots__/select-commit-modal.spec.tsx.snap index 606bccaa0a3..f8b16020d34 100644 --- a/libs/domains/services/feature/src/lib/select-commit-modal/__snapshots__/select-commit-modal.spec.tsx.snap +++ b/libs/domains/services/feature/src/lib/select-commit-modal/__snapshots__/select-commit-modal.spec.tsx.snap @@ -28,7 +28,7 @@ exports[`SelectCommitModal should match snapshot 1`] = ` class="fa-regular fa-magnifying-glass absolute left-3 top-1/2 block -translate-y-1/2 text-base leading-none text-neutral-subtle" /> true) - .otherwise(() => false) + const isArgoCdService = isArgoCd(service) useClusterRunningStatusSocket({ organizationId, clusterId: environment.cluster_id }) @@ -175,9 +173,7 @@ function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { })) .otherwise(() => undefined) - const isArgoCdService = match(service) - .with({ service_type: 'ARGOCD_APP' }, () => true) - .otherwise(() => false) + const isArgoCdService = isArgoCd(service) const handleCopyCredentials = (credentials: Credentials) => { if (!databaseSource) { diff --git a/libs/domains/services/feature/src/lib/service-overview/service-overview.tsx b/libs/domains/services/feature/src/lib/service-overview/service-overview.tsx index 475342a5e45..d4803a5d2e8 100644 --- a/libs/domains/services/feature/src/lib/service-overview/service-overview.tsx +++ b/libs/domains/services/feature/src/lib/service-overview/service-overview.tsx @@ -6,6 +6,7 @@ import { type EditableService, type Job, type Terraform, + isArgoCd, isEditableService, } from '@qovery/domains/services/data-access' import { OutputVariables } from '@qovery/domains/variables/feature' @@ -210,9 +211,7 @@ function ServiceOverviewContent({ {!isTerraformService && isEditableService(service) && ( )} - {service.serviceType === 'ARGOCD_APP' && ( - - )} + {isArgoCd(service) && } {isKedaAutoscaling && scaledObject && } {service.serviceType === 'JOB' && service.job_type === 'LIFECYCLE' && ( diff --git a/libs/shared/console-shared/src/index.ts b/libs/shared/console-shared/src/index.ts index 0b35b0d1707..c0f310b63d4 100644 --- a/libs/shared/console-shared/src/index.ts +++ b/libs/shared/console-shared/src/index.ts @@ -2,3 +2,4 @@ // then console-shared only as a last resort. export * from './lib/settings-heading/settings-heading' export * from './lib/live-elapsed-duration-cell/live-elapsed-duration-cell' +export * from './lib/resource-tree-list/resource-tree-list' diff --git a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx b/libs/shared/console-shared/src/lib/resource-tree-list/resource-tree-list.spec.tsx similarity index 69% rename from libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx rename to libs/shared/console-shared/src/lib/resource-tree-list/resource-tree-list.spec.tsx index 7e8408cb75a..abef83f9b31 100644 --- a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx +++ b/libs/shared/console-shared/src/lib/resource-tree-list/resource-tree-list.spec.tsx @@ -1,27 +1,36 @@ import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import { createMockTerraformResource } from '../test-fixtures/mock-terraform-resources' -import { ResourceTreeList } from './resource-tree-list' +import { ResourceTreeList, type ResourceTreeResource } from './resource-tree-list' -const mockResources = [ - createMockTerraformResource({ +function createResource(overrides: Partial = {}): ResourceTreeResource { + return { id: 'res-1', + resourceType: 'aws_s3_bucket', name: 'app_bucket', - provider: 'aws', - }), - createMockTerraformResource({ + address: 'aws_s3_bucket.app_bucket', + displayName: 'S3 Bucket', + attributes: { + id: 'my-app-bucket', + bucket: 'my-app-bucket', + region: 'us-east-1', + }, + ...overrides, + } +} + +const mockResources = [ + createResource({ id: 'res-1', name: 'app_bucket' }), + createResource({ id: 'res-2', resourceType: 'aws_rds_instance', name: 'app_db', address: 'aws_rds_instance.app_db', - provider: 'aws', displayName: 'RDS Instance', attributes: { id: 'mydb', engine: 'mysql', db_name: 'appdb' }, }), - createMockTerraformResource({ + createResource({ id: 'res-3', name: 'backup_bucket', address: 'aws_s3_bucket.backup_bucket', - provider: 'aws', attributes: { id: 'my-backup-bucket', bucket: 'my-backup-bucket', region: 'us-west-2' }, }), ] @@ -56,11 +65,8 @@ describe('ResourceTreeList', () => { /> ) - // Should show resource types with counts expect(screen.getByText(/RDS Instance/)).toBeInTheDocument() - expect(screen.getByText(/\(1\)/)).toBeInTheDocument() expect(screen.getByText(/S3 Bucket/)).toBeInTheDocument() - expect(screen.getByText(/\(2\)/)).toBeInTheDocument() }) it('should highlight selected resource', () => { @@ -74,7 +80,7 @@ describe('ResourceTreeList', () => { ) const selectedButton = screen.getByRole('button', { name: /app_bucket/ }) - expect(selectedButton).toHaveClass('bg-surface-brand-subtle') + expect(selectedButton).toHaveClass('bg-surface-brand-component') }) it('should call onSelectResource when clicking a resource', async () => { @@ -93,7 +99,7 @@ describe('ResourceTreeList', () => { expect(mockOnSelectResource).toHaveBeenCalledWith('res-1') }) - it('should highlight matching resources when searching by name', () => { + it('should keep matching and non-matching resources visible when searching by name', () => { renderWithProviders( { /> ) - // All resources should be visible, but non-matching ones are dimmed expect(screen.getByText('backup_bucket')).toBeInTheDocument() expect(screen.getByText('app_bucket')).toBeInTheDocument() - - // Non-matching button should have dimmed styling - const nonMatchingButton = screen.getByRole('button', { name: /app_bucket/ }) - expect(nonMatchingButton).toHaveClass('text-neutral-disabled') }) - it('should highlight matching resources when searching by type', () => { + it('should match resources when searching by type', () => { renderWithProviders( { ) expect(screen.getByText('app_db')).toBeInTheDocument() - // Non-matching resources are visible but dimmed - const nonMatchingButton = screen.getByRole('button', { name: /app_bucket/ }) - expect(nonMatchingButton).toHaveClass('text-neutral-disabled') }) - it('should highlight matching resources when searching by attribute keys', () => { + it('should match resources when searching by attribute keys', () => { renderWithProviders( { ) expect(screen.getByText('app_db')).toBeInTheDocument() - // Non-matching resources are visible but dimmed - const nonMatchingButton = screen.getByRole('button', { name: /app_bucket/ }) - expect(nonMatchingButton).toHaveClass('text-neutral-disabled') }) - it('should highlight matching resources when searching by attribute values', () => { + it('should match resources when searching by attribute values', () => { renderWithProviders( { ) expect(screen.getByText('app_db')).toBeInTheDocument() - // Non-matching resources are visible but dimmed - const nonMatchingButton = screen.getByRole('button', { name: /app_bucket/ }) - expect(nonMatchingButton).toHaveClass('text-neutral-disabled') }) it('should show no results message when search returns nothing', () => { @@ -183,9 +175,7 @@ describe('ResourceTreeList', () => { /> ) - // Get only the resource buttons (those containing bucket names), not tree triggers const bucketButtons = screen.getAllByRole('button').filter((btn) => btn.textContent?.includes('_bucket')) - // Should have app_bucket before backup_bucket alphabetically expect(bucketButtons[0]).toHaveTextContent('app_bucket') expect(bucketButtons[1]).toHaveTextContent('backup_bucket') }) diff --git a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx b/libs/shared/console-shared/src/lib/resource-tree-list/resource-tree-list.tsx similarity index 50% rename from libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx rename to libs/shared/console-shared/src/lib/resource-tree-list/resource-tree-list.tsx index fc7e6baa1c6..f9bb4ed7e14 100644 --- a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx +++ b/libs/shared/console-shared/src/lib/resource-tree-list/resource-tree-list.tsx @@ -1,11 +1,18 @@ import clsx from 'clsx' import { type ReactElement, useEffect, useMemo, useState } from 'react' -import { type TerraformResource } from '@qovery/domains/service-terraform/data-access' -import { Icon, TreeView } from '@qovery/shared/ui' -import { matchesSearch } from '../utils/matches-search' +import { Accordion, Icon } from '@qovery/shared/ui' + +export interface ResourceTreeResource { + id: string + resourceType: string + name: string + address: string + attributes: Record + displayName: string +} export interface ResourceTreeListProps { - resources: TerraformResource[] + resources: ResourceTreeResource[] selectedResourceId: string | null onSelectResource: (resourceId: string) => void searchQuery: string @@ -14,11 +21,28 @@ export interface ResourceTreeListProps { interface GroupedResources { resourceType: string displayName: string - resources: TerraformResource[] + resources: ResourceTreeResource[] +} + +function matchesSearch(resource: ResourceTreeResource, query: string): boolean { + const lowerQuery = query.toLowerCase() + + if (resource.name.toLowerCase().includes(lowerQuery)) return true + if (resource.resourceType.toLowerCase().includes(lowerQuery)) return true + if (resource.displayName.toLowerCase().includes(lowerQuery)) return true + if (resource.address.toLowerCase().includes(lowerQuery)) return true + + const attributeKeys = Object.keys(resource.attributes) + if (attributeKeys.some((key) => key.toLowerCase().includes(lowerQuery))) return true + + const attributeValues = Object.values(resource.attributes).map((value) => String(value)) + if (attributeValues.some((value) => value.toLowerCase().includes(lowerQuery))) return true + + return false } -function groupResourcesByType(resources: TerraformResource[]): GroupedResources[] { - const groups = new Map() +function groupResourcesByType(resources: ResourceTreeResource[]): GroupedResources[] { + const groups = new Map() for (const resource of resources) { const list = groups.get(resource.resourceType) ?? [] @@ -43,10 +67,9 @@ export function ResourceTreeList({ }: ResourceTreeListProps): ReactElement { const [expandedGroups, setExpandedGroups] = useState([]) - // Always show all resources, but mark which ones match the search const resourceMatchMap = useMemo(() => { - if (!searchQuery) return new Map(resources.map((r) => [r.id, true])) - return new Map(resources.map((r) => [r.id, matchesSearch(r, searchQuery)])) + if (!searchQuery) return new Map(resources.map((resource) => [resource.id, true])) + return new Map(resources.map((resource) => [resource.id, matchesSearch(resource, searchQuery)])) }, [resources, searchQuery]) const groupedResources = useMemo(() => { @@ -81,20 +104,22 @@ export function ResourceTreeList({ } return ( -
- +
+ {groupedResources.map((group) => ( - - - - {group.displayName} - ({group.resources.length}) + + + + - - + {group.displayName} + +
    {group.resources.map((resource) => { - const matches = resourceMatchMap.get(resource.id) ?? true const isSelected = selectedResourceId === resource.id return ( @@ -103,25 +128,23 @@ export function ResourceTreeList({ type="button" onClick={() => onSelectResource(resource.id)} className={clsx( - 'mb-1 flex h-8 w-full cursor-pointer items-center gap-2 rounded px-2 text-sm transition-colors', - isSelected && 'bg-surface-brand-subtle text-brand', - !isSelected && - (matches - ? 'text-neutral-subtle hover:bg-surface-neutral-subtle' - : 'text-neutral-disabled hover:bg-surface-neutral-subtle') + 'mb-1 flex h-8 w-full cursor-pointer items-center gap-1 rounded-md pl-6 pr-1.5 text-left text-sm transition-colors', + isSelected ? 'bg-surface-brand-component text-brand' : 'hover:bg-surface-neutral-subtle' )} > - - {resource.name} + + + + {resource.name} ) })}
-
-
+ + ))} - +
) } diff --git a/libs/shared/ui/src/lib/components/accordion/accordion.tsx b/libs/shared/ui/src/lib/components/accordion/accordion.tsx index 545b7d9b2e5..4cf889ff1e5 100644 --- a/libs/shared/ui/src/lib/components/accordion/accordion.tsx +++ b/libs/shared/ui/src/lib/components/accordion/accordion.tsx @@ -17,10 +17,12 @@ const AccordionItem = forwardRef, Acc ) ) -interface AccordionTriggerProps extends ComponentPropsWithoutRef {} +interface AccordionTriggerProps extends ComponentPropsWithoutRef { + iconClassName?: string +} const AccordionTrigger = forwardRef, AccordionTriggerProps>( - ({ children, className, ...props }, forwardedRef) => ( + ({ children, className, iconClassName, ...props }, forwardedRef) => ( {children} diff --git a/libs/shared/ui/src/lib/components/inputs/input-search/input-search.tsx b/libs/shared/ui/src/lib/components/inputs/input-search/input-search.tsx index c1622ced572..87c16a5ebfb 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-search/input-search.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-search/input-search.tsx @@ -53,7 +53,7 @@ export function InputSearch(props: InputSearchProps) { ref={ref} autoFocus={autofocus} className={twMerge( - 'w-full rounded border border-neutral bg-surface-neutral pl-10 pr-6 leading-none text-neutral placeholder:text-neutral-subtle focus:border-brand-strong focus:outline-none focus:transition-[border-color]', + 'w-full rounded-md border border-neutral bg-surface-neutral pl-10 pr-6 leading-none text-neutral placeholder:text-neutral-subtle focus:border-brand-strong focus:outline-none focus:transition-[border-color]', customSize )} type="text" diff --git a/package.json b/package.json index 5718be9cf69..551eb73908b 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@xterm/xterm": "5.5.0", "ansi-to-react": "6.1.6", "autoprefixer": "10.4.13", - "axios": "1.15.0", + "axios": "1.15.2", "class-variance-authority": "0.7.0", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -75,7 +75,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.881", + "qovery-typescript-axios": "^1.1.886", "react": "18.3.1", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index a8c2efddc3e..4f4964e650f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6320,7 +6320,7 @@ __metadata: "@xterm/xterm": 5.5.0 ansi-to-react: 6.1.6 autoprefixer: 10.4.13 - axios: 1.15.0 + axios: 1.15.2 babel-jest: 30.0.5 babel-loader: 8.3.0 chance: 1.1.10 @@ -6369,7 +6369,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.881 + qovery-typescript-axios: ^1.1.886 qovery-ws-typescript-axios: 0.1.506 react: 18.3.1 react-country-flag: 3.0.2 @@ -12187,14 +12187,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:1.15.0": - version: 1.15.0 - resolution: "axios@npm:1.15.0" +"axios@npm:1.15.2": + version: 1.15.2 + resolution: "axios@npm:1.15.2" dependencies: follow-redirects: ^1.15.11 form-data: ^4.0.5 proxy-from-env: ^2.1.0 - checksum: 95a8455554867a083ab3772fcadba42a22ec4bb546dccc66011556d837a07e544ae006675a30a5c43453f3e37e7c0982e934cec482c06b75abead2a2c157448a + checksum: e7d208b751959c7c6936417b870d6286979e0dff17784ae230d3988e754b6322682cb136e25669b534072b6f44c08715801ab961227eb8cc075323d9fda8ad43 languageName: node linkType: hard @@ -26327,12 +26327,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.881": - version: 1.1.881 - resolution: "qovery-typescript-axios@npm:1.1.881" +"qovery-typescript-axios@npm:^1.1.886": + version: 1.1.886 + resolution: "qovery-typescript-axios@npm:1.1.886" dependencies: - axios: 1.15.0 - checksum: 230ffc8a4d5d18dd650e6f0ce9a71fedd69fa21c38b738905f9969e5f65e7eb2ed5cb490bc01334208b5da7dc7193604eaa3701d6b088bf19c79670684d9d033 + axios: 1.15.2 + checksum: 5816055f0c2fbde9d2cb1f5792ddbcebeb1f11af4373b15f0678123d173fc1a3dae6d8207e3745182282bf2f61ee40e36ce3d5d318b1a873d633f28592ac293c languageName: node linkType: hard