From 09a00af731124bca0554d506e44211e4cbb44826 Mon Sep 17 00:00:00 2001 From: Fabien FLEUREAU Date: Tue, 20 Jan 2026 11:45:56 +0100 Subject: [PATCH 1/7] feat(terraform-resources): implement resource viewer with domain scoping and UI cleanup - Add terraform resources display feature with tree view and details panel - Group resources by type with expandable sections - Implement search functionality to filter resources by name/attributes - Auto-select first matching resource and maintain selection during search - Move terraform interfaces from shared to service-terraform domain scope for better encapsulation - Remove unused TerraformResourcesTable component and TerraformResourceKeyAttribute interface - Remove global axios interceptor workaround (fixed in qovery-typescript-axios v1.1.809) - Simplify TerraformResourcesSection to display-only (remove Apply button and deployment logic) - Update to qovery-typescript-axios v1.1.809 with proper type exports - Update page-general to display terraform resources in Overview tab - Add comprehensive test coverage for all new components --- .../data-access/src/index.ts | 2 + .../domains-service-terraform-data-access.ts | 53 ++++- .../src/lib/terraform.interface.ts | 16 ++ .../service-terraform/feature/src/index.ts | 5 +- .../use-terraform-resources.ts | 14 ++ .../resource-details.spec.tsx | 65 ++++++ .../lib/resource-details/resource-details.tsx | 96 +++++++++ .../resource-tree-list.spec.tsx | 192 ++++++++++++++++++ .../resource-tree-list/resource-tree-list.tsx | 121 +++++++++++ .../terraform-resources-section.spec.tsx | 173 ++++++++++++++++ .../terraform-resources-section.tsx | 122 +++++++++++ .../test-fixtures/mock-terraform-resources.ts | 46 +++++ .../feature/src/lib/utils/matches-search.ts | 18 ++ .../lib/ui/page-general/page-general.spec.tsx | 9 +- .../src/lib/ui/page-general/page-general.tsx | 76 ++++--- package.json | 2 +- yarn.lock | 10 +- 17 files changed, 984 insertions(+), 36 deletions(-) create mode 100644 libs/domains/service-terraform/data-access/src/lib/terraform.interface.ts create mode 100644 libs/domains/service-terraform/feature/src/lib/hooks/use-terraform-resources/use-terraform-resources.ts create mode 100644 libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx create mode 100644 libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx create mode 100644 libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx create mode 100644 libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx create mode 100644 libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx create mode 100644 libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx create mode 100644 libs/domains/service-terraform/feature/src/lib/test-fixtures/mock-terraform-resources.ts create mode 100644 libs/domains/service-terraform/feature/src/lib/utils/matches-search.ts diff --git a/libs/domains/service-terraform/data-access/src/index.ts b/libs/domains/service-terraform/data-access/src/index.ts index 863eb222c60..a1510b31825 100644 --- a/libs/domains/service-terraform/data-access/src/index.ts +++ b/libs/domains/service-terraform/data-access/src/index.ts @@ -1 +1,3 @@ export * from './lib/domains-service-terraform-data-access' +export * from './lib/terraform.interface' +export * from './lib/terraform.interface' diff --git a/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts b/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts index 24cec9ee590..4fbad75ec0d 100644 --- a/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts +++ b/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts @@ -1,14 +1,61 @@ import { createQueryKeys } from '@lukemorales/query-key-factory' -import { TerraformMainCallsApi } from 'qovery-typescript-axios' +import { type AxiosError } from 'axios' +import { TerraformMainCallsApi, TerraformResourcesApi, type TerraformResourcesResponse } from 'qovery-typescript-axios' +import { type TerraformResource } from './terraform.interface' -const terraformApi = new TerraformMainCallsApi() +const terraformMainCallsApi = new TerraformMainCallsApi() +const terraformResourcesApi = new TerraformResourcesApi() + +class ResourcesNotAppliedError extends Error { + constructor() { + super('Terraform resources have not been applied yet') + this.name = 'ResourcesNotAppliedError' + } +} + +function transformApiResponse(response: TerraformResourcesResponse): TerraformResource[] { + return response.results.map((item) => { + const resourceType = item.resource_type || '' + const displayName = resourceType + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + + return { + id: item.id, + resourceType, + name: item.name, + address: item.address, + provider: item.provider, + mode: item.mode, + attributes: item.attributes, + extractedAt: item.extractedAt || '', + displayName, + } as TerraformResource + }) +} export const serviceTerraform = createQueryKeys('serviceTerraform', { listAvailableVersions: () => ({ queryKey: ['listTerraformAvailableVersion'], async queryFn() { - const response = await terraformApi.listTerraformVersions() + const response = await terraformMainCallsApi.listTerraformVersions() return response.data.results }, }), + listResources: (terraformId: string) => ({ + queryKey: [terraformId, 'resources'], + async queryFn(): Promise { + try { + const response = await terraformResourcesApi.getTerraformResources(terraformId) + return transformApiResponse(response.data) + } catch (error) { + const axiosError = error as AxiosError + if (axiosError.response?.status === 404) { + throw new ResourcesNotAppliedError() + } + throw error + } + }, + }), }) diff --git a/libs/domains/service-terraform/data-access/src/lib/terraform.interface.ts b/libs/domains/service-terraform/data-access/src/lib/terraform.interface.ts new file mode 100644 index 00000000000..d868dbe992d --- /dev/null +++ b/libs/domains/service-terraform/data-access/src/lib/terraform.interface.ts @@ -0,0 +1,16 @@ +/** + * Terraform resource interfaces for displaying infrastructure resources + * created by Terraform deployments in the Qovery Console. + */ + +export interface TerraformResource { + id: string + resourceType: string + name: string + address: string + provider: string + mode: string + attributes: Record + extractedAt: string + displayName: string +} diff --git a/libs/domains/service-terraform/feature/src/index.ts b/libs/domains/service-terraform/feature/src/index.ts index 2b8b329b91e..01b865fff16 100644 --- a/libs/domains/service-terraform/feature/src/index.ts +++ b/libs/domains/service-terraform/feature/src/index.ts @@ -3,4 +3,7 @@ export * from './source-setting/source-setting' export * from './lib/terraform-variables-settings/terraform-variables-settings' export * from './lib/terraform-variables-settings/terraform-variables-context' export * from './lib/terraform-variables-settings/terraform-tfvars-popover/terraform-tfvars-popover' -export * from './lib/terraform-variables-settings/terraform-configuration-settings/terraform-configuration-settings' +export * from './lib/hooks/use-terraform-resources/use-terraform-resources' +export * from './lib/terraform-resources-section/terraform-resources-section' +export * from './lib/resource-details/resource-details' +export * from './lib/resource-tree-list/resource-tree-list' diff --git a/libs/domains/service-terraform/feature/src/lib/hooks/use-terraform-resources/use-terraform-resources.ts b/libs/domains/service-terraform/feature/src/lib/hooks/use-terraform-resources/use-terraform-resources.ts new file mode 100644 index 00000000000..db840bdd957 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/hooks/use-terraform-resources/use-terraform-resources.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' +import { queries } from '@qovery/state/util-queries' + +export interface UseTerraformResourcesProps { + terraformId: string + enabled?: boolean +} + +export function useTerraformResources({ terraformId, enabled = true }: UseTerraformResourcesProps) { + return useQuery({ + ...queries.serviceTerraform.listResources(terraformId), + enabled: Boolean(terraformId) && enabled, + }) +} diff --git a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx new file mode 100644 index 00000000000..93c93a41fa1 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx @@ -0,0 +1,65 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { createMockTerraformResource } from '../test-fixtures/mock-terraform-resources' +import { ResourceDetails } from './resource-details' + +const mockResource = createMockTerraformResource({ + provider: 'provider["registry.terraform.io/hashicorp/aws"]', + attributes: { + id: 'my-app-bucket', + bucket: 'my-app-bucket', + region: 'us-east-1', + versioning: { enabled: true }, + }, +}) + +describe('ResourceDetails', () => { + it('should show empty state when no resource is selected', () => { + renderWithProviders() + + expect(screen.getByText('No resource selected')).toBeInTheDocument() + }) + + it('should display resource metadata when resource is provided', () => { + renderWithProviders() + + expect(screen.getByText('app_bucket')).toBeInTheDocument() + expect(screen.getByText('aws_s3_bucket')).toBeInTheDocument() + expect(screen.getByText('aws_s3_bucket.app_bucket')).toBeInTheDocument() + }) + + it('should display all attributes in table', () => { + renderWithProviders() + + // Check table structure + expect(screen.getByText('Key')).toBeInTheDocument() + expect(screen.getByText('Value')).toBeInTheDocument() + + // Check attribute keys are displayed + expect(screen.getByText('Name')).toBeInTheDocument() + expect(screen.getByText('Type')).toBeInTheDocument() + expect(screen.getByText('region')).toBeInTheDocument() + }) + + it('should convert non-string attributes to JSON', () => { + renderWithProviders() + + // The versioning object should be displayed as JSON string + expect(screen.getByText(/enabled/)).toBeInTheDocument() + }) + + it('should display extracted date in readable format', () => { + renderWithProviders() + + expect(screen.getByText(/2025/)).toBeInTheDocument() + }) + + it('should render table with correct structure', () => { + renderWithProviders() + + // Verify table headers + const headers = screen.getAllByRole('columnheader') + expect(headers).toHaveLength(2) + expect(headers[0]).toHaveTextContent('Key') + expect(headers[1]).toHaveTextContent('Value') + }) +}) diff --git a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx new file mode 100644 index 00000000000..1f805d70f30 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx @@ -0,0 +1,96 @@ +import { type ReactElement, useState } from 'react'; +import { type TerraformResource } from '@qovery/domains/service-terraform/data-access'; +import { EmptyState, Icon, TablePrimitives } from '@qovery/shared/ui'; +import { twMerge } from '@qovery/shared/util-js'; + + + + + + + +const { Table } = TablePrimitives + +export interface ResourceDetailsProps { + resource: TerraformResource | null +} + +interface TableRowData { + key: string + value: string +} + +export function ResourceDetails({ resource }: ResourceDetailsProps): ReactElement { + const [hoveredIndex, setHoveredIndex] = useState(null) + + if (!resource) { + return + } + + const extractedAtDate = new Date(resource.extractedAt).toLocaleString() + + const tableData: TableRowData[] = [ + { key: 'Name', value: resource.name }, + { key: 'Type', value: resource.resourceType }, + { key: 'Address', value: resource.address }, + { key: 'Provider', value: resource.provider }, + { key: 'Mode', value: resource.mode }, + ...Object.entries(resource.attributes).map(([key, val]) => ({ + key, + value: typeof val === 'string' ? val : JSON.stringify(val), + })), + { key: 'Extracted At', value: extractedAtDate }, + ] + + return ( +
+
+ + + + + Key + + + Value + + + + + {tableData.map((row, index) => ( + setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + > + + {row.key} + + + {row.value} + {hoveredIndex === index && ( + + )} + + + ))} + + +
+
+ ) +} diff --git a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx b/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx new file mode 100644 index 00000000000..eea1cbbf27c --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx @@ -0,0 +1,192 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { createMockTerraformResource } from '../test-fixtures/mock-terraform-resources' +import { ResourceTreeList } from './resource-tree-list' + +const mockResources = [ + createMockTerraformResource({ + id: 'res-1', + name: 'app_bucket', + provider: 'aws', + }), + createMockTerraformResource({ + 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({ + 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' }, + }), +] + +describe('ResourceTreeList', () => { + const mockOnSelectResource = jest.fn() + + beforeEach(() => { + mockOnSelectResource.mockClear() + }) + + it('should render empty state when no resources', () => { + renderWithProviders( + + ) + + expect(screen.getByText('No resources found')).toBeInTheDocument() + }) + + it('should group resources by type', () => { + renderWithProviders( + + ) + + // 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', () => { + renderWithProviders( + + ) + + const selectedButton = screen.getByRole('button', { name: /app_bucket/ }) + expect(selectedButton).toHaveClass('bg-neutral-200') + }) + + it('should call onSelectResource when clicking a resource', async () => { + const { userEvent } = renderWithProviders( + + ) + + const resourceButton = screen.getByRole('button', { name: /app_bucket/ }) + await userEvent.click(resourceButton) + + expect(mockOnSelectResource).toHaveBeenCalledWith('res-1') + }) + + it('should highlight matching resources 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-250') + }) + + it('should highlight matching 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-250') + }) + + it('should highlight matching 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-250') + }) + + it('should highlight matching 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-250') + }) + + it('should show no results message when search returns nothing', () => { + renderWithProviders( + + ) + + expect(screen.getByText('No resources match')).toBeInTheDocument() + }) + + it('should sort resources by name within each group', () => { + renderWithProviders( + + ) + + // 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/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx new file mode 100644 index 00000000000..9a34736c7d0 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx @@ -0,0 +1,121 @@ +import { type ReactElement, useEffect, useMemo, useState } from 'react' +import { type TerraformResource } from '@qovery/domains/service-terraform/data-access' +import { EmptyState, Icon, TreeView } from '@qovery/shared/ui' +import { matchesSearch } from '../utils/matches-search' + +export interface ResourceTreeListProps { + resources: TerraformResource[] + selectedResourceId: string | null + onSelectResource: (resourceId: string) => void + searchQuery: string +} + +interface GroupedResources { + resourceType: string + displayName: string + resources: TerraformResource[] +} + +function groupResourcesByType(resources: TerraformResource[]): GroupedResources[] { + const groups = new Map() + + for (const resource of resources) { + const list = groups.get(resource.resourceType) ?? [] + list.push(resource) + groups.set(resource.resourceType, list) + } + + return Array.from(groups.entries()) + .map(([type, groupItems]) => ({ + resourceType: type, + displayName: groupItems[0].displayName, + resources: groupItems.sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort((a, b) => a.displayName.localeCompare(b.displayName)) +} + +export function ResourceTreeList({ + resources, + selectedResourceId, + onSelectResource, + searchQuery, +}: 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)])) + }, [resources, searchQuery]) + + const groupedResources = useMemo(() => { + return groupResourcesByType(resources) + }, [resources]) + + useEffect(() => { + if (expandedGroups.length === 0 && groupedResources.length > 0) { + setExpandedGroups(groupedResources.map((group) => group.resourceType)) + } + }, [expandedGroups.length, groupedResources]) + + const hasMatches = Array.from(resourceMatchMap.values()).some((match) => match) + + if (resources.length === 0) { + return + } + + if (searchQuery && !hasMatches) { + return + } + + return ( +
+ + {groupedResources.map((group) => ( + + + + {group.displayName} + ({group.resources.length}) + + + +
    + {group.resources.map((resource) => { + const matches = resourceMatchMap.get(resource.id) ?? true + const isSelected = selectedResourceId === resource.id + + function getButtonClassName(): string { + const base = + 'w-full cursor-pointer rounded px-2 py-1.5 text-left text-sm transition-colors flex items-center gap-2' + if (isSelected) { + return `${base} bg-neutral-200 font-medium text-neutral-400` + } + if (matches) { + return `${base} text-neutral-350 hover:bg-neutral-150` + } + return `${base} text-neutral-250 hover:bg-neutral-150/50` + } + + return ( +
  • + +
  • + ) + })} +
+
+
+ ))} +
+
+ ) +} diff --git a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx new file mode 100644 index 00000000000..f2b9b59ed82 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx @@ -0,0 +1,173 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import * as useTerraformResourcesModule from '../hooks/use-terraform-resources/use-terraform-resources' +import { createMockTerraformResource } from '../test-fixtures/mock-terraform-resources' +import { TerraformResourcesSection } from './terraform-resources-section' + +jest.mock('../hooks/use-terraform-resources/use-terraform-resources') + +const mockUseTerraformResources = jest.spyOn(useTerraformResourcesModule, 'useTerraformResources') + +describe('TerraformResourcesSection', () => { + const terraformId = 'test-terraform-id' + const mockResource = createMockTerraformResource({ + id: '1', + resourceType: 'aws_instance', + name: 'web_server', + address: 'aws_instance.web_server', + displayName: 'EC2 Instance', + attributes: { instance_type: 't3.micro' }, + extractedAt: '2024-01-14T10:00:00Z', + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should render loading state', () => { + mockUseTerraformResources.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any) + + renderWithProviders() + + expect(screen.getByTestId('spinner')).toBeInTheDocument() + }) + + it('should render error state with message', () => { + const errorMessage = 'Failed to fetch resources' + mockUseTerraformResources.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error(errorMessage), + } as any) + + renderWithProviders() + + expect(screen.getByText(/Failed to load terraform resources/i)).toBeInTheDocument() + expect(screen.getByText(errorMessage)).toBeInTheDocument() + }) + + it('should render empty state when no resources', () => { + mockUseTerraformResources.mockReturnValue({ + data: { resources: [] }, + isLoading: false, + error: null, + } as any) + + renderWithProviders() + + expect(screen.getByText(/No infrastructure resources found/i)).toBeInTheDocument() + }) + + it('should render resources with tree list and details', () => { + mockUseTerraformResources.mockReturnValue({ + data: { resources: [mockResource] }, + isLoading: false, + error: null, + } as any) + + renderWithProviders() + + // Resource should be shown in tree list (may appear in both tree and details) + expect(screen.getAllByText('web_server').length).toBeGreaterThan(0) + expect(screen.getByText('EC2 Instance')).toBeInTheDocument() + }) + + it('should render split panel with resources', () => { + const resources = [mockResource] + mockUseTerraformResources.mockReturnValue({ + data: { resources }, + isLoading: false, + error: null, + } as any) + + renderWithProviders() + + expect(screen.getByText('EC2 Instance')).toBeInTheDocument() + expect(screen.getAllByText('web_server').length).toBeGreaterThan(0) + }) + + it('should select first resource on load', () => { + mockUseTerraformResources.mockReturnValue({ + data: { resources: [mockResource] }, + isLoading: false, + error: null, + } as any) + + renderWithProviders() + + expect(screen.getByText('aws_instance.web_server')).toBeInTheDocument() + }) + + it('should dim non-matching resources when searching', async () => { + const resources = [ + mockResource, + createMockTerraformResource({ + id: '2', + name: 'data_bucket', + resourceType: 'aws_s3_bucket', + displayName: 'S3 Bucket', + address: 'aws_s3_bucket.data_bucket', + }), + ] + + mockUseTerraformResources.mockReturnValue({ + data: { resources }, + isLoading: false, + error: null, + } as any) + + const { userEvent } = renderWithProviders() + + const searchInput = screen.getByPlaceholderText(/Search resources/i) + await userEvent.type(searchInput, 'bucket') + + // Both resources are visible in tree, but non-matching one is dimmed + expect(screen.getAllByText('data_bucket').length).toBeGreaterThan(0) + expect(screen.getAllByText('web_server').length).toBeGreaterThan(0) + + // Non-matching resource should have dimmed styling + const nonMatchingButton = screen.getByRole('button', { name: /web_server/ }) + expect(nonMatchingButton).toHaveClass('text-neutral-250') + }) + + it('should show empty search state when no matches', async () => { + mockUseTerraformResources.mockReturnValue({ + data: { resources: [mockResource] }, + isLoading: false, + error: null, + } as any) + + const { userEvent } = renderWithProviders() + + const searchInput = screen.getByPlaceholderText(/Search resources/i) + await userEvent.type(searchInput, 'nonexistent') + + expect(screen.getByText('No resources match')).toBeInTheDocument() + }) + + it('should display multiple resources', () => { + const resources = [ + mockResource, + createMockTerraformResource({ + id: '2', + name: 'db_instance', + resourceType: 'aws_db_instance', + displayName: 'RDS Instance', + }), + ] + + mockUseTerraformResources.mockReturnValue({ + data: { resources }, + isLoading: false, + error: null, + } as any) + + renderWithProviders() + + expect(screen.getByText('EC2 Instance')).toBeInTheDocument() + expect(screen.getByText('RDS Instance')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx new file mode 100644 index 00000000000..69d180fd431 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx @@ -0,0 +1,122 @@ +import { type ReactElement, useEffect, useMemo, useState } from 'react' +import { EmptyState, Icon, InputText, LoaderSpinner } from '@qovery/shared/ui' +import { useTerraformResources } from '../hooks/use-terraform-resources/use-terraform-resources' +import { ResourceDetails } from '../resource-details/resource-details' +import { ResourceTreeList } from '../resource-tree-list/resource-tree-list' +import { matchesSearch } from '../utils/matches-search' + +export interface TerraformResourcesSectionProps { + terraformId: string +} + +export function TerraformResourcesSection({ terraformId }: TerraformResourcesSectionProps): ReactElement { + const [searchQuery, setSearchQuery] = useState('') + const [selectedResourceId, setSelectedResourceId] = useState(null) + + const { data, isLoading, error } = useTerraformResources({ terraformId }) + + // Auto-select first resource on load; re-select on search changes to keep selection in sync + useEffect(() => { + if (!data?.length) return + + if (!selectedResourceId) { + setSelectedResourceId(data[0].id) + return + } + + if (searchQuery) { + const selectedResource = data.find((r) => r.id === selectedResourceId) + if (!selectedResource || !matchesSearch(selectedResource, searchQuery)) { + const firstMatch = data.find((r) => matchesSearch(r, searchQuery)) + if (firstMatch) { + setSelectedResourceId(firstMatch.id) + } + } + } + }, [data, searchQuery, selectedResourceId]) + + // Get the selected resource (from all resources, not just filtered) + const selectedResource = useMemo( + () => data?.find((r) => r.id === selectedResourceId) ?? null, + [data, selectedResourceId] + ) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+ +
+

Failed to load terraform resources

+

{error instanceof Error ? error.message : 'An error occurred'}

+
+
+ ) + } + + // Empty state (no resources at all) + if (!data || data.length === 0) { + return ( + + ) + } + + return ( +
+ {/* Split panel: Tree list (with search) and Details */} +
+ {/* Left panel: Search + Resource tree list */} +
+ {/* Search bar */} +
+
+ setSearchQuery(e.target.value)} + placeholder="Search resources..." + className="w-full" + /> + {searchQuery && ( + + )} +
+
+ + {/* Tree list */} +
+ +
+
+ + {/* Right panel: Resource details */} +
+ +
+
+
+ ) +} diff --git a/libs/domains/service-terraform/feature/src/lib/test-fixtures/mock-terraform-resources.ts b/libs/domains/service-terraform/feature/src/lib/test-fixtures/mock-terraform-resources.ts new file mode 100644 index 00000000000..bad0aec5b28 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/test-fixtures/mock-terraform-resources.ts @@ -0,0 +1,46 @@ +import { type TerraformResource } from '@qovery/domains/service-terraform/data-access' + +export function createMockTerraformResource(overrides: Partial = {}): TerraformResource { + return { + id: 'res-1', + resourceType: 'aws_s3_bucket', + name: 'app_bucket', + address: 'aws_s3_bucket.app_bucket', + provider: 'registry.terraform.io/hashicorp/aws', + mode: 'managed', + displayName: 'S3 Bucket', + extractedAt: '2025-01-16T12:00:00Z', + attributes: { + id: 'my-app-bucket', + bucket: 'my-app-bucket', + region: 'us-east-1', + }, + ...overrides, + } +} + +export const mockS3BucketResource = createMockTerraformResource() + +export const mockRdsResource = createMockTerraformResource({ + id: 'res-2', + resourceType: 'aws_rds_instance', + name: 'app_db', + address: 'aws_rds_instance.app_db', + displayName: 'RDS Instance', + attributes: { + id: 'mydb', + engine: 'mysql', + db_name: 'appdb', + }, +}) + +export const mockEc2Resource = createMockTerraformResource({ + id: 'res-3', + resourceType: 'aws_instance', + name: 'web_server', + address: 'aws_instance.web_server', + displayName: 'EC2 Instance', + attributes: { + instance_type: 't3.micro', + }, +}) diff --git a/libs/domains/service-terraform/feature/src/lib/utils/matches-search.ts b/libs/domains/service-terraform/feature/src/lib/utils/matches-search.ts new file mode 100644 index 00000000000..45fd8d97458 --- /dev/null +++ b/libs/domains/service-terraform/feature/src/lib/utils/matches-search.ts @@ -0,0 +1,18 @@ +import { type TerraformResource } from '@qovery/domains/service-terraform/data-access' + +export function matchesSearch(resource: TerraformResource, 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((v) => String(v)) + if (attributeValues.some((val) => val.toLowerCase().includes(lowerQuery))) return true + + return false +} diff --git a/libs/pages/application/src/lib/ui/page-general/page-general.spec.tsx b/libs/pages/application/src/lib/ui/page-general/page-general.spec.tsx index 74548511d93..6ef5f1181ff 100644 --- a/libs/pages/application/src/lib/ui/page-general/page-general.spec.tsx +++ b/libs/pages/application/src/lib/ui/page-general/page-general.spec.tsx @@ -3,7 +3,14 @@ import { PageGeneral } from './page-general' describe('General', () => { it('should render successfully', () => { - const { baseElement } = renderWithProviders() + const { baseElement } = renderWithProviders( + + ) expect(baseElement).toBeTruthy() }) }) diff --git a/libs/pages/application/src/lib/ui/page-general/page-general.tsx b/libs/pages/application/src/lib/ui/page-general/page-general.tsx index caf6b2218b4..fa55b8dffab 100644 --- a/libs/pages/application/src/lib/ui/page-general/page-general.tsx +++ b/libs/pages/application/src/lib/ui/page-general/page-general.tsx @@ -1,12 +1,15 @@ import { motion } from 'framer-motion' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { EnableObservabilityModal } from '@qovery/domains/observability/feature' import { type AnyService } from '@qovery/domains/services/data-access' import { PodStatusesCallout, PodsMetrics, ServiceDetails } from '@qovery/domains/services/feature' +import { TerraformResourcesSection } from '@qovery/domains/service-terraform/feature' import { OutputVariables } from '@qovery/domains/variables/feature' -import { Button, ExternalLink, Icon, useModal } from '@qovery/shared/ui' +import { Button, ExternalLink, Icon, TabsPrimitives, useModal } from '@qovery/shared/ui' import { useLocalStorage } from '@qovery/shared/util-hooks' +const { Tabs } = TabsPrimitives + export interface PageGeneralProps { serviceId: string environmentId: string @@ -168,11 +171,16 @@ function ObservabilityCallout() { ) } -export function PageGeneral({ serviceId, environmentId, service, hasNoMetrics }: PageGeneralProps) { - const isLifecycleJobOrTerraform = useMemo( - () => (service?.serviceType === 'JOB' && service.job_type === 'LIFECYCLE') || service?.serviceType === 'TERRAFORM', - [service] - ) +export function PageGeneral({ + serviceId, + environmentId, + service, + hasNoMetrics, +}: PageGeneralProps) { + const [activeTab, setActiveTab] = useState('variables') + + const isLifecycleJob = useMemo(() => service?.serviceType === 'JOB' && service.job_type === 'LIFECYCLE', [service]) + const isTerraformService = useMemo(() => service?.serviceType === 'TERRAFORM', [service]) const isCronJob = useMemo(() => service?.serviceType === 'JOB' && service.job_type === 'CRON', [service]) return ( @@ -180,24 +188,42 @@ export function PageGeneral({ serviceId, environmentId, service, hasNoMetrics }:
{hasNoMetrics && } - - {isCronJob && ( -
- -

- The number of past Completed or Failed job execution retained in the history and their TTL can be - customized in the advanced settings. -

- - See documentation - -
- )} -
- {isLifecycleJobOrTerraform && } + {!isTerraformService && ( + + {isCronJob && ( +
+ +

+ The number of past Completed or Failed job execution retained in the history and their TTL can be + customized in the advanced settings. +

+ + See documentation + +
+ )} +
+ )} + {isLifecycleJob && ( + + )} + {isTerraformService && ( + + + Output Variables + Infrastructure Resources + + + + + + + + + )}
Date: Tue, 20 Jan 2026 15:36:53 +0100 Subject: [PATCH 2/7] chore(qovery-client): upgrade to v1.1.810 and update API imports - Update qovery-typescript-axios from file reference to published v1.1.810 - Use TerraformResourcesResponse directly from qovery client - Simplify transformApiResponse to work with new API format - Remove unnecessary casting and type workarounds --- .../lib/domains-service-terraform-data-access.ts | 2 +- .../lib/resource-details/resource-details.tsx | 14 ++++---------- .../lib/ui/page-general/page-general.spec.tsx | 7 +------ .../src/lib/ui/page-general/page-general.tsx | 13 +++---------- package.json | 2 +- yarn.lock | 16 ++++++++-------- 6 files changed, 18 insertions(+), 36 deletions(-) diff --git a/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts b/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts index 4fbad75ec0d..b4a62575308 100644 --- a/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts +++ b/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts @@ -29,7 +29,7 @@ function transformApiResponse(response: TerraformResourcesResponse): TerraformRe provider: item.provider, mode: item.mode, attributes: item.attributes, - extractedAt: item.extractedAt || '', + extractedAt: item.extracted_at || '', displayName, } as TerraformResource }) diff --git a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx index 1f805d70f30..e1ec7aec303 100644 --- a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx +++ b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx @@ -1,13 +1,7 @@ -import { type ReactElement, useState } from 'react'; -import { type TerraformResource } from '@qovery/domains/service-terraform/data-access'; -import { EmptyState, Icon, TablePrimitives } from '@qovery/shared/ui'; -import { twMerge } from '@qovery/shared/util-js'; - - - - - - +import { type ReactElement, useState } from 'react' +import { type TerraformResource } from '@qovery/domains/service-terraform/data-access' +import { EmptyState, Icon, TablePrimitives } from '@qovery/shared/ui' +import { twMerge } from '@qovery/shared/util-js' const { Table } = TablePrimitives diff --git a/libs/pages/application/src/lib/ui/page-general/page-general.spec.tsx b/libs/pages/application/src/lib/ui/page-general/page-general.spec.tsx index 6ef5f1181ff..53bbb25a8a9 100644 --- a/libs/pages/application/src/lib/ui/page-general/page-general.spec.tsx +++ b/libs/pages/application/src/lib/ui/page-general/page-general.spec.tsx @@ -4,12 +4,7 @@ import { PageGeneral } from './page-general' describe('General', () => { it('should render successfully', () => { const { baseElement } = renderWithProviders( - + ) expect(baseElement).toBeTruthy() }) diff --git a/libs/pages/application/src/lib/ui/page-general/page-general.tsx b/libs/pages/application/src/lib/ui/page-general/page-general.tsx index fa55b8dffab..fc7fb1213f9 100644 --- a/libs/pages/application/src/lib/ui/page-general/page-general.tsx +++ b/libs/pages/application/src/lib/ui/page-general/page-general.tsx @@ -1,9 +1,9 @@ import { motion } from 'framer-motion' import { useMemo, useState } from 'react' import { EnableObservabilityModal } from '@qovery/domains/observability/feature' +import { TerraformResourcesSection } from '@qovery/domains/service-terraform/feature' import { type AnyService } from '@qovery/domains/services/data-access' import { PodStatusesCallout, PodsMetrics, ServiceDetails } from '@qovery/domains/services/feature' -import { TerraformResourcesSection } from '@qovery/domains/service-terraform/feature' import { OutputVariables } from '@qovery/domains/variables/feature' import { Button, ExternalLink, Icon, TabsPrimitives, useModal } from '@qovery/shared/ui' import { useLocalStorage } from '@qovery/shared/util-hooks' @@ -171,12 +171,7 @@ function ObservabilityCallout() { ) } -export function PageGeneral({ - serviceId, - environmentId, - service, - hasNoMetrics, -}: PageGeneralProps) { +export function PageGeneral({ serviceId, environmentId, service, hasNoMetrics }: PageGeneralProps) { const [activeTab, setActiveTab] = useState('variables') const isLifecycleJob = useMemo(() => service?.serviceType === 'JOB' && service.job_type === 'LIFECYCLE', [service]) @@ -207,9 +202,7 @@ export function PageGeneral({ )} )} - {isLifecycleJob && ( - - )} + {isLifecycleJob && } {isTerraformService && ( diff --git a/package.json b/package.json index c98e810d1bb..4fb5463cd19 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "mermaid": "^11.6.0", "monaco-editor": "0.53.0", "posthog-js": "^1.260.1", - "qovery-typescript-axios": "^1.1.809", + "qovery-typescript-axios": "^1.1.810", "react": "18.3.1", "react-country-flag": "^3.0.2", "react-datepicker": "^4.12.0", diff --git a/yarn.lock b/yarn.lock index b51fc28aa56..578d1e7f288 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5679,7 +5679,7 @@ __metadata: prettier: ^3.2.5 prettier-plugin-tailwindcss: ^0.5.14 pretty-quick: ^4.0.0 - qovery-typescript-axios: ^1.1.809 + qovery-typescript-axios: ^1.1.810 qovery-ws-typescript-axios: ^0.1.420 react: 18.3.1 react-country-flag: ^3.0.2 @@ -24520,21 +24520,21 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:^1.1.809": - version: 1.1.809 - resolution: "qovery-typescript-axios@npm:1.1.809" +"qovery-typescript-axios@npm:^1.1.810": + version: 1.1.810 + resolution: "qovery-typescript-axios@npm:1.1.810" dependencies: axios: 1.12.2 - checksum: 1400400e6cd93b0de75b5c070e8ee205db1966312d6ca3ec2f751b26ef2a9898d80c58fbd0ad5289ae1a8437f11c8283a53ea75f95049e4b71a27937e04682d0 + checksum: ede17ba36ba149256f91eab4420f9954fa43619b9029ce91864100dcbae1a0f6926187564edb8a386ee14e22f9fd5e352c42c0565e0d5708cd1a475af0c96f5e languageName: node linkType: hard "qovery-ws-typescript-axios@npm:^0.1.420": - version: 0.1.421 - resolution: "qovery-ws-typescript-axios@npm:0.1.421" + version: 0.1.506 + resolution: "qovery-ws-typescript-axios@npm:0.1.506" dependencies: axios: 1.12.2 - checksum: d88ce8ff41d759f5629282099b8c1d265f19c3aa4fbced1c732c6daeca285ca3d1dfcd27d9bd0582a5271928441999e825610d7c53068c58befdaf62abca7b96 + checksum: a0a995e8da1ca426c41fb134018859855e7e266be9c69a044c983c6c847f8b6c6f79b068aa81d0c055b572715e1bcf550009dccc8946cce4aec449ac8d7f582a languageName: node linkType: hard From 066ea738a10647672cdaab57a2c3d4d736cfe26f Mon Sep 17 00:00:00 2001 From: Fabien FLEUREAU Date: Tue, 20 Jan 2026 17:22:25 +0100 Subject: [PATCH 3/7] fix(terraform-resources): fix test mocks to return data as array directly - Mock data should be an array, not { resources: [...] } - useTerraformResources hook returns React Query result with data as array - Fixes TypeError: data?.find is not a function in all terraform-resources-section tests - All 8 terraform resource tests now pass --- .../service-terraform/data-access/src/index.ts | 1 - .../terraform-resources-section.spec.tsx | 14 +++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/libs/domains/service-terraform/data-access/src/index.ts b/libs/domains/service-terraform/data-access/src/index.ts index a1510b31825..3dbf712ea26 100644 --- a/libs/domains/service-terraform/data-access/src/index.ts +++ b/libs/domains/service-terraform/data-access/src/index.ts @@ -1,3 +1,2 @@ export * from './lib/domains-service-terraform-data-access' export * from './lib/terraform.interface' -export * from './lib/terraform.interface' diff --git a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx index f2b9b59ed82..ca3bd030c11 100644 --- a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx +++ b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx @@ -51,7 +51,7 @@ describe('TerraformResourcesSection', () => { it('should render empty state when no resources', () => { mockUseTerraformResources.mockReturnValue({ - data: { resources: [] }, + data: [], isLoading: false, error: null, } as any) @@ -63,7 +63,7 @@ describe('TerraformResourcesSection', () => { it('should render resources with tree list and details', () => { mockUseTerraformResources.mockReturnValue({ - data: { resources: [mockResource] }, + data: [mockResource], isLoading: false, error: null, } as any) @@ -78,7 +78,7 @@ describe('TerraformResourcesSection', () => { it('should render split panel with resources', () => { const resources = [mockResource] mockUseTerraformResources.mockReturnValue({ - data: { resources }, + data: resources, isLoading: false, error: null, } as any) @@ -91,7 +91,7 @@ describe('TerraformResourcesSection', () => { it('should select first resource on load', () => { mockUseTerraformResources.mockReturnValue({ - data: { resources: [mockResource] }, + data: [mockResource], isLoading: false, error: null, } as any) @@ -114,7 +114,7 @@ describe('TerraformResourcesSection', () => { ] mockUseTerraformResources.mockReturnValue({ - data: { resources }, + data: resources, isLoading: false, error: null, } as any) @@ -135,7 +135,7 @@ describe('TerraformResourcesSection', () => { it('should show empty search state when no matches', async () => { mockUseTerraformResources.mockReturnValue({ - data: { resources: [mockResource] }, + data: [mockResource], isLoading: false, error: null, } as any) @@ -160,7 +160,7 @@ describe('TerraformResourcesSection', () => { ] mockUseTerraformResources.mockReturnValue({ - data: { resources }, + data: resources, isLoading: false, error: null, } as any) From ba7e4e6ac557cac4638930ace8478c970d8d804a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Wed, 21 Jan 2026 14:01:59 +0100 Subject: [PATCH 4/7] design adjustments & cleanup --- .../resource-details.spec.tsx | 2 +- .../lib/resource-details/resource-details.tsx | 47 ++++++++----------- .../resource-tree-list.spec.tsx | 4 +- .../resource-tree-list/resource-tree-list.tsx | 29 ++++++++---- .../terraform-resources-section.spec.tsx | 8 ++-- .../terraform-resources-section.tsx | 23 +++++---- .../output-variables.spec.tsx.snap | 8 ++-- .../output-variables.spec.tsx | 11 ++++- .../lib/output-variables/output-variables.tsx | 14 ++++-- .../src/lib/ui/page-general/page-general.tsx | 8 +++- 10 files changed, 91 insertions(+), 63 deletions(-) diff --git a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx index 93c93a41fa1..7b9e615b539 100644 --- a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx +++ b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx @@ -16,7 +16,7 @@ describe('ResourceDetails', () => { it('should show empty state when no resource is selected', () => { renderWithProviders() - expect(screen.getByText('No resource selected')).toBeInTheDocument() + expect(screen.getByText('No resources selected')).toBeInTheDocument() }) it('should display resource metadata when resource is provided', () => { diff --git a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx index e1ec7aec303..6c4d6e352c0 100644 --- a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx +++ b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx @@ -1,6 +1,6 @@ import { type ReactElement, useState } from 'react' import { type TerraformResource } from '@qovery/domains/service-terraform/data-access' -import { EmptyState, Icon, TablePrimitives } from '@qovery/shared/ui' +import { CopyToClipboardButtonIcon, Icon, TablePrimitives } from '@qovery/shared/ui' import { twMerge } from '@qovery/shared/util-js' const { Table } = TablePrimitives @@ -18,7 +18,13 @@ export function ResourceDetails({ resource }: ResourceDetailsProps): ReactElemen const [hoveredIndex, setHoveredIndex] = useState(null) if (!resource) { - return + return ( +
+ +

No resources selected

+

Select a resource from the list to view details.

+
+ ) } const extractedAtDate = new Date(resource.extractedAt).toLocaleString() @@ -38,16 +44,14 @@ export function ResourceDetails({ resource }: ResourceDetailsProps): ReactElemen return (
-
- +
+ - + Key - - Value - + Value @@ -58,26 +62,15 @@ export function ResourceDetails({ resource }: ResourceDetailsProps): ReactElemen onMouseEnter={() => setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(null)} > - - {row.key} - - - {row.value} + {row.key} + + {row.value} {hoveredIndex === index && ( - + )} diff --git a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx b/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx index eea1cbbf27c..c5814653df1 100644 --- a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx +++ b/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx @@ -43,7 +43,7 @@ describe('ResourceTreeList', () => { /> ) - expect(screen.getByText('No resources found')).toBeInTheDocument() + expect(screen.getByText('No result for this search')).toBeInTheDocument() }) it('should group resources by type', () => { @@ -74,7 +74,7 @@ describe('ResourceTreeList', () => { ) const selectedButton = screen.getByRole('button', { name: /app_bucket/ }) - expect(selectedButton).toHaveClass('bg-neutral-200') + expect(selectedButton).toHaveClass('bg-brand-50') }) it('should call onSelectResource when clicking a resource', async () => { diff --git a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx b/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx index 9a34736c7d0..9cedc1cf88b 100644 --- a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx +++ b/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx @@ -1,6 +1,6 @@ import { type ReactElement, useEffect, useMemo, useState } from 'react' import { type TerraformResource } from '@qovery/domains/service-terraform/data-access' -import { EmptyState, Icon, TreeView } from '@qovery/shared/ui' +import { Icon, TreeView } from '@qovery/shared/ui' import { matchesSearch } from '../utils/matches-search' export interface ResourceTreeListProps { @@ -61,15 +61,26 @@ export function ResourceTreeList({ const hasMatches = Array.from(resourceMatchMap.values()).some((match) => match) if (resources.length === 0) { - return + return ( +
+ +

No result for this search

+
+ ) } if (searchQuery && !hasMatches) { - return + return ( +
+ +

No resources match

+

No resources found for ${searchQuery}

+
+ ) } return ( -
+
{groupedResources.map((group) => ( @@ -87,14 +98,14 @@ export function ResourceTreeList({ function getButtonClassName(): string { const base = - 'w-full cursor-pointer rounded px-2 py-1.5 text-left text-sm transition-colors flex items-center gap-2' + 'w-full cursor-pointer rounded h-8 px-2 gap-2 text-sm transition-colors flex items-center mb-1' if (isSelected) { - return `${base} bg-neutral-200 font-medium text-neutral-400` + return `${base} bg-brand-50 text-brand-500` } if (matches) { - return `${base} text-neutral-350 hover:bg-neutral-150` + return `${base} text-neutral-350 hover:bg-neutral-100` } - return `${base} text-neutral-250 hover:bg-neutral-150/50` + return `${base} text-neutral-250 hover:bg-neutral-100` } return ( @@ -105,7 +116,7 @@ export function ResourceTreeList({ className={getButtonClassName()} title={!matches ? 'Does not match search query' : undefined} > - + {resource.name} diff --git a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx index ca3bd030c11..1f04d20aafe 100644 --- a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx +++ b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx @@ -101,7 +101,7 @@ describe('TerraformResourcesSection', () => { expect(screen.getByText('aws_instance.web_server')).toBeInTheDocument() }) - it('should dim non-matching resources when searching', async () => { + it('should label non-matching resources when searching', async () => { const resources = [ mockResource, createMockTerraformResource({ @@ -124,13 +124,13 @@ describe('TerraformResourcesSection', () => { const searchInput = screen.getByPlaceholderText(/Search resources/i) await userEvent.type(searchInput, 'bucket') - // Both resources are visible in tree, but non-matching one is dimmed + // Both resources are visible in tree, but non-matching one is labeled expect(screen.getAllByText('data_bucket').length).toBeGreaterThan(0) expect(screen.getAllByText('web_server').length).toBeGreaterThan(0) - // Non-matching resource should have dimmed styling + // Non-matching resource should be labeled as not matching const nonMatchingButton = screen.getByRole('button', { name: /web_server/ }) - expect(nonMatchingButton).toHaveClass('text-neutral-250') + expect(nonMatchingButton).toHaveAttribute('title', 'Does not match search query') }) it('should show empty search state when no matches', async () => { diff --git a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx index 69d180fd431..98178e7c39a 100644 --- a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx +++ b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx @@ -1,5 +1,5 @@ import { type ReactElement, useEffect, useMemo, useState } from 'react' -import { EmptyState, Icon, InputText, LoaderSpinner } from '@qovery/shared/ui' +import { Icon, InputTextSmall, LoaderSpinner } from '@qovery/shared/ui' import { useTerraformResources } from '../hooks/use-terraform-resources/use-terraform-resources' import { ResourceDetails } from '../resource-details/resource-details' import { ResourceTreeList } from '../resource-tree-list/resource-tree-list' @@ -64,23 +64,26 @@ export function TerraformResourcesSection({ terraformId }: TerraformResourcesSec // Empty state (no resources at all) if (!data || data.length === 0) { return ( - +
+ +

No infrastructure resources found

+

+ Terraform resources will appear here after your first successful deployment. +

+
) } return (
{/* Split panel: Tree list (with search) and Details */} -
+
{/* Left panel: Search + Resource tree list */} -
+
{/* Search bar */} -
+
- {/* Right panel: Resource details */} -
+
diff --git a/libs/domains/variables/feature/src/lib/output-variables/__snapshots__/output-variables.spec.tsx.snap b/libs/domains/variables/feature/src/lib/output-variables/__snapshots__/output-variables.spec.tsx.snap index 0834620731d..6392b1d11b0 100644 --- a/libs/domains/variables/feature/src/lib/output-variables/__snapshots__/output-variables.spec.tsx.snap +++ b/libs/domains/variables/feature/src/lib/output-variables/__snapshots__/output-variables.spec.tsx.snap @@ -4,10 +4,10 @@ exports[`OutputVariables when serviceType is JOB should match snapshot 1`] = `
@@ -97,10 +97,10 @@ exports[`OutputVariables when serviceType is TERRAFORM should match snapshot 1`]
diff --git a/libs/domains/variables/feature/src/lib/output-variables/output-variables.spec.tsx b/libs/domains/variables/feature/src/lib/output-variables/output-variables.spec.tsx index cfa00177340..743b3a28b07 100644 --- a/libs/domains/variables/feature/src/lib/output-variables/output-variables.spec.tsx +++ b/libs/domains/variables/feature/src/lib/output-variables/output-variables.spec.tsx @@ -1,4 +1,4 @@ -import { renderWithProviders } from '@qovery/shared/util-tests' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { OutputVariables } from './output-variables' jest.mock('../hooks/use-variables/use-variables', () => ({ @@ -632,6 +632,15 @@ describe('OutputVariables', () => { expect(baseElement).toBeTruthy() }) + it('should render empty state when no output variables are found', () => { + renderWithProviders() + + expect(screen.getByText('No output variables found')).toBeInTheDocument() + expect( + screen.getByText('Job output variables will appear here after your first successful deployment.') + ).toBeInTheDocument() + }) + describe('when serviceType is TERRAFORM', () => { it('should match snapshot', () => { const { baseElement } = renderWithProviders( diff --git a/libs/domains/variables/feature/src/lib/output-variables/output-variables.tsx b/libs/domains/variables/feature/src/lib/output-variables/output-variables.tsx index e8a47f0c22c..157bbd21388 100644 --- a/libs/domains/variables/feature/src/lib/output-variables/output-variables.tsx +++ b/libs/domains/variables/feature/src/lib/output-variables/output-variables.tsx @@ -101,12 +101,20 @@ export function OutputVariables({ serviceId, serviceType, className, ...props }: }) if (variables.length === 0) { - return null + return ( +
+ +

No output variables found

+

+ {scopeName} output variables will appear here after your first successful deployment. +

+
+ ) } return ( -
- +
+ {table.getHeaderGroups().map((headerGroup) => ( diff --git a/libs/pages/application/src/lib/ui/page-general/page-general.tsx b/libs/pages/application/src/lib/ui/page-general/page-general.tsx index fc7fb1213f9..b076e795e16 100644 --- a/libs/pages/application/src/lib/ui/page-general/page-general.tsx +++ b/libs/pages/application/src/lib/ui/page-general/page-general.tsx @@ -204,8 +204,12 @@ export function PageGeneral({ serviceId, environmentId, service, hasNoMetrics }: )} {isLifecycleJob && } {isTerraformService && ( - - + + Output Variables Infrastructure Resources From 2bd8a73044984a714f18052a14416a5876330441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Wed, 21 Jan 2026 14:13:02 +0100 Subject: [PATCH 5/7] fix(resource-details): correct text in empty state message --- .../feature/src/lib/resource-details/resource-details.spec.tsx | 2 +- .../feature/src/lib/resource-details/resource-details.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx index 7b9e615b539..93c93a41fa1 100644 --- a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx +++ b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.spec.tsx @@ -16,7 +16,7 @@ describe('ResourceDetails', () => { it('should show empty state when no resource is selected', () => { renderWithProviders() - expect(screen.getByText('No resources selected')).toBeInTheDocument() + expect(screen.getByText('No resource selected')).toBeInTheDocument() }) it('should display resource metadata when resource is provided', () => { diff --git a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx index 6c4d6e352c0..ad9b90f17cb 100644 --- a/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx +++ b/libs/domains/service-terraform/feature/src/lib/resource-details/resource-details.tsx @@ -21,7 +21,7 @@ export function ResourceDetails({ resource }: ResourceDetailsProps): ReactElemen return (
-

No resources selected

+

No resource selected

Select a resource from the list to view details.

) From c6c826e85a9da063c4f7272c70a26e581648c831 Mon Sep 17 00:00:00 2001 From: Fabien FLEUREAU Date: Wed, 21 Jan 2026 20:27:17 +0100 Subject: [PATCH 6/7] fix(terraform-resources): fix after review - remove unused title - simplify error handling in query function - fix clear search button --- .../domains-service-terraform-data-access.ts | 12 +--- .../resource-tree-list/resource-tree-list.tsx | 1 - .../terraform-resources-section.spec.tsx | 55 +++++++++++++++++-- .../terraform-resources-section.tsx | 6 +- 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts b/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts index b4a62575308..dac1d2e62c3 100644 --- a/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts +++ b/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts @@ -46,16 +46,8 @@ export const serviceTerraform = createQueryKeys('serviceTerraform', { listResources: (terraformId: string) => ({ queryKey: [terraformId, 'resources'], async queryFn(): Promise { - try { - const response = await terraformResourcesApi.getTerraformResources(terraformId) - return transformApiResponse(response.data) - } catch (error) { - const axiosError = error as AxiosError - if (axiosError.response?.status === 404) { - throw new ResourcesNotAppliedError() - } - throw error - } + const response = await terraformResourcesApi.getTerraformResources(terraformId) + return transformApiResponse(response.data) }, }), }) diff --git a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx b/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx index 9cedc1cf88b..2bccc1177cb 100644 --- a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx +++ b/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx @@ -114,7 +114,6 @@ export function ResourceTreeList({ type="button" onClick={() => onSelectResource(resource.id)} className={getButtonClassName()} - title={!matches ? 'Does not match search query' : undefined} > {resource.name} diff --git a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx index 1f04d20aafe..f2b513cad39 100644 --- a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx +++ b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.spec.tsx @@ -101,7 +101,7 @@ describe('TerraformResourcesSection', () => { expect(screen.getByText('aws_instance.web_server')).toBeInTheDocument() }) - it('should label non-matching resources when searching', async () => { + it('should style non-matching resources differently when searching', async () => { const resources = [ mockResource, createMockTerraformResource({ @@ -124,13 +124,9 @@ describe('TerraformResourcesSection', () => { const searchInput = screen.getByPlaceholderText(/Search resources/i) await userEvent.type(searchInput, 'bucket') - // Both resources are visible in tree, but non-matching one is labeled + // Both resources are visible in tree expect(screen.getAllByText('data_bucket').length).toBeGreaterThan(0) expect(screen.getAllByText('web_server').length).toBeGreaterThan(0) - - // Non-matching resource should be labeled as not matching - const nonMatchingButton = screen.getByRole('button', { name: /web_server/ }) - expect(nonMatchingButton).toHaveAttribute('title', 'Does not match search query') }) it('should show empty search state when no matches', async () => { @@ -170,4 +166,51 @@ describe('TerraformResourcesSection', () => { expect(screen.getByText('EC2 Instance')).toBeInTheDocument() expect(screen.getByText('RDS Instance')).toBeInTheDocument() }) + + it('should show clear search button when search query is entered', async () => { + mockUseTerraformResources.mockReturnValue({ + data: [mockResource], + isLoading: false, + error: null, + } as any) + + const { userEvent } = renderWithProviders() + + // Initially button should not be visible + expect(screen.queryByTitle(/Clear search/i)).not.toBeInTheDocument() + + // Type in search field + const searchInput = screen.getByPlaceholderText(/Search resources/i) + await userEvent.type(searchInput, 'test') + + // Clear button should now be visible + const clearButton = screen.getByTitle(/Clear search/i) + expect(clearButton).toBeInTheDocument() + expect(clearButton).toBeVisible() + }) + + it('should clear search when clicking the clear button', async () => { + mockUseTerraformResources.mockReturnValue({ + data: [mockResource], + isLoading: false, + error: null, + } as any) + + const { userEvent } = renderWithProviders() + + // Type in search field + const searchInput = screen.getByPlaceholderText(/Search resources/i) as HTMLInputElement + await userEvent.type(searchInput, 'test') + expect(searchInput.value).toBe('test') + + // Click clear button + const clearButton = screen.getByTitle(/Clear search/i) + await userEvent.click(clearButton) + + // Search input should be cleared + expect(searchInput.value).toBe('') + + // Clear button should not be visible anymore + expect(screen.queryByTitle(/Clear search/i)).not.toBeInTheDocument() + }) }) diff --git a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx index 98178e7c39a..8f90eaeb9a0 100644 --- a/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx +++ b/libs/domains/service-terraform/feature/src/lib/terraform-resources-section/terraform-resources-section.tsx @@ -90,15 +90,17 @@ export function TerraformResourcesSection({ terraformId }: TerraformResourcesSec onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search resources..." className="w-full" + inputClassName="pr-8" /> {searchQuery && ( )}
From f0dfd81791c1a790d0b1db892bb5f9b239ab25be Mon Sep 17 00:00:00 2001 From: Fabien FLEUREAU Date: Fri, 23 Jan 2026 09:13:53 +0100 Subject: [PATCH 7/7] chore(terraform-resources): remove unused error --- .../src/lib/domains-service-terraform-data-access.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts b/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts index dac1d2e62c3..36c7f00b155 100644 --- a/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts +++ b/libs/domains/service-terraform/data-access/src/lib/domains-service-terraform-data-access.ts @@ -6,13 +6,6 @@ import { type TerraformResource } from './terraform.interface' const terraformMainCallsApi = new TerraformMainCallsApi() const terraformResourcesApi = new TerraformResourcesApi() -class ResourcesNotAppliedError extends Error { - constructor() { - super('Terraform resources have not been applied yet') - this.name = 'ResourcesNotAppliedError' - } -} - function transformApiResponse(response: TerraformResourcesResponse): TerraformResource[] { return response.results.map((item) => { const resourceType = item.resource_type || ''