From 016526a11f69cf0c8321feffb030aad084511cdf Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Tue, 13 Oct 2026 13:44:07 +0200 Subject: [PATCH 01/28] fix(clusters): keep read-only fields disabled in the array item modal Co-Authored-By: Claude Opus 5.5 --- .../profile-array-item-modal.spec.tsx | 20 +++++++++++++++++++ .../profile-array-item-modal.tsx | 1 + .../platform-configuration-utils.ts | 1 + 3 files changed, 22 insertions(+) diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-array-item-modal.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-array-item-modal.spec.tsx index 0db5ccfe8f3..62a75fd7f6f 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-array-item-modal.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-array-item-modal.spec.tsx @@ -36,6 +36,26 @@ describe('ProfileArrayItemModal', () => { expect(onSubmit).toHaveBeenCalledWith(submittedValues) }) + it('keeps read-only fields disabled and submits their value unchanged', async () => { + const onSubmit = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + expect(screen.getByRole('textbox')).toBeDisabled() + + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(onSubmit).toHaveBeenCalledWith({ cidr: '10.0.0.0/8' }) + }) + it('reports being mounted until it unmounts, including under StrictMode', () => { const onMountedChange = jest.fn() const { unmount } = renderWithProviders( diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-array-item-modal.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-array-item-modal.tsx index c009a5470d1..0f22d111490 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-array-item-modal.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-array-item-modal.tsx @@ -91,6 +91,7 @@ export function ProfileArrayItemModal({ Date: Tue, 13 Oct 2026 13:45:44 +0200 Subject: [PATCH 02/28] feat(clusters): warn before leaving the profile with unsaved changes Co-Authored-By: Claude Opus 5.5 --- .../cluster-profile-context.tsx | 22 +++++++- .../cluster-profile/cluster-profile.spec.tsx | 56 ++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-context.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-context.tsx index 64f07e48092..7c10b6f8bd3 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-context.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-context.tsx @@ -1,4 +1,4 @@ -import { useParams } from '@tanstack/react-router' +import { useBlocker, useParams } from '@tanstack/react-router' import equal from 'fast-deep-equal' import { type Cluster, @@ -22,6 +22,9 @@ import { import { type ProfileTreeItem } from './profile-tree' import { useClusterProfileTree } from './use-cluster-profile-tree' +const PROFILE_ROUTE_ID = '/_authenticated/organization/$organizationId/cluster/$clusterId/profile' +const LEAVE_UNSAVED_CHANGES_MESSAGE = 'You have unsaved changes on this profile. Leave the page and discard them?' + export type ProfileValues = Record> export type ClusterInputValues = Record> @@ -174,6 +177,21 @@ export function ClusterProfileProvider({ children }: PropsWithChildren) { ) } + const changeCount = countProfileChanges(profileTree, configuration, profileValues, clusterInputs) + const hasChanges = changeCount > 0 + + useBlocker({ + disabled: !hasChanges, + // Edits are kept while switching components of this profile: only leaving it would drop them. + shouldBlockFn: ({ next }) => + !( + next.routeId.startsWith(PROFILE_ROUTE_ID) && + 'clusterId' in next.params && + next.params.clusterId === clusterId + ) && !window.confirm(LEAVE_UNSAVED_CHANGES_MESSAGE), + enableBeforeUnload: hasChanges, + }) + const resetChanges = () => { setProfileValues({}) setClusterInputs({}) @@ -230,7 +248,7 @@ export function ClusterProfileProvider({ children }: PropsWithChildren) { profileValues, clusterInputs, formKey, - changeCount: countProfileChanges(profileTree, configuration, profileValues, clusterInputs), + changeCount, isSaving: isSavingConfiguration && !isSaveAndDeployPending, isDeploying: isSaveAndDeployPending, updateProfileConfig, diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.spec.tsx index 9d4ba0109dc..23e8e3a50cd 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.spec.tsx @@ -15,7 +15,11 @@ import { usePlatformConfiguration } from '../platform-configuration/hooks/use-pl import { useUpdatePlatformConfiguration } from '../platform-configuration/hooks/use-update-platform-configuration' import { ClusterProfileFeature } from './cluster-profile' -jest.mock('@tanstack/react-router', () => ({ useParams: jest.fn() })) +const mockUseBlocker = jest.fn() +jest.mock('@tanstack/react-router', () => ({ + useParams: jest.fn(), + useBlocker: (options: unknown) => mockUseBlocker(options), +})) const mockUseDebounce = jest.fn((value: T) => value) jest.mock('@qovery/shared/util-hooks', () => ({ ...jest.requireActual('@qovery/shared/util-hooks'), @@ -1048,6 +1052,56 @@ describe('ClusterProfileFeature', () => { }) }) + describe('leaving with unsaved changes', () => { + type BlockerOptions = { + disabled: boolean + enableBeforeUnload: boolean + shouldBlockFn: (args: { next: { routeId: string; params: Record } }) => boolean + } + const getBlocker = () => mockUseBlocker.mock.calls.at(-1)?.[0] as BlockerOptions + const profileRoute = '/_authenticated/organization/$organizationId/cluster/$clusterId/profile/$componentKey' + + it('does not block navigation without changes', () => { + renderWithProviders() + + expect(getBlocker()).toMatchObject({ disabled: true, enableBeforeUnload: false }) + }) + + it('asks for confirmation before leaving the profile, and enables the before-unload warning', async () => { + const confirm = jest.spyOn(window, 'confirm') + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('switch', { name: 'High availability' })) + + expect(getBlocker()).toMatchObject({ disabled: false, enableBeforeUnload: true }) + const leaveProfile = { + next: { routeId: '/_authenticated/organization/$organizationId/cluster/$clusterId/overview', params: {} }, + } + confirm.mockReturnValueOnce(false) + expect(getBlocker().shouldBlockFn(leaveProfile)).toBe(true) + confirm.mockReturnValueOnce(true) + expect(getBlocker().shouldBlockFn(leaveProfile)).toBe(false) + confirm.mockReturnValueOnce(false) + expect( + getBlocker().shouldBlockFn({ next: { routeId: profileRoute, params: { clusterId: 'other-cluster-id' } } }) + ).toBe(true) + confirm.mockRestore() + }) + + it('lets the user switch components of the same profile', async () => { + const confirm = jest.spyOn(window, 'confirm') + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('switch', { name: 'High availability' })) + + expect(getBlocker().shouldBlockFn({ next: { routeId: profileRoute, params: { clusterId: 'cluster-id' } } })).toBe( + false + ) + expect(confirm).not.toHaveBeenCalled() + confirm.mockRestore() + }) + }) + it('tells when the profile has no layers', () => { mockUsePlatformTemplates.mockReturnValue({ data: [{ ...mockTemplates[0], layers: [] }], From 82e2a23f4824e6385744182d0d4a59703b29c8c2 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Tue, 13 Oct 2026 13:46:26 +0200 Subject: [PATCH 03/28] refactor(shared): move isHttpStatus next to the Axios interceptor Co-Authored-By: Claude Opus 5.5 --- .../src/lib/domains-clusters-data-access.ts | 2 +- .../platform-configuration.ts | 2 +- libs/shared/utils/src/index.ts | 1 + .../is-http-status/is-http-status.spec.ts | 19 +++++++++++++++++++ .../http/is-http-status}/is-http-status.ts | 0 5 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 libs/shared/utils/src/lib/http/is-http-status/is-http-status.spec.ts rename libs/{domains/clusters/data-access/src/lib/http => shared/utils/src/lib/http/is-http-status}/is-http-status.ts (100%) diff --git a/libs/domains/clusters/data-access/src/lib/domains-clusters-data-access.ts b/libs/domains/clusters/data-access/src/lib/domains-clusters-data-access.ts index 5ce5b84a612..d639e790b66 100644 --- a/libs/domains/clusters/data-access/src/lib/domains-clusters-data-access.ts +++ b/libs/domains/clusters/data-access/src/lib/domains-clusters-data-access.ts @@ -20,7 +20,7 @@ import { type SelfManagedClusterRequest, } from 'qovery-typescript-axios' import { type ClusterMetricsDto, type ClusterStatusDto } from 'qovery-ws-typescript-axios' -import { isHttpStatus } from './http/is-http-status' +import { isHttpStatus } from '@qovery/shared/utils' const clusterApi = new ClustersApi() const clusterDeploymentHistoryApi = new ClusterDeploymentHistoryApi() diff --git a/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts b/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts index a85dceed8b6..72bd8473781 100644 --- a/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts +++ b/libs/domains/clusters/data-access/src/lib/platform-configuration/platform-configuration.ts @@ -6,7 +6,7 @@ import { type PlatformComponentConfigurationPreviewRequest, PlatformConfigurationApi, } from 'qovery-typescript-axios' -import { isHttpStatus } from '../http/is-http-status' +import { isHttpStatus } from '@qovery/shared/utils' const platformConfigurationApi = new PlatformConfigurationApi() diff --git a/libs/shared/utils/src/index.ts b/libs/shared/utils/src/index.ts index 90dc59e9490..66f393d914c 100644 --- a/libs/shared/utils/src/index.ts +++ b/libs/shared/utils/src/index.ts @@ -1,2 +1,3 @@ // http export * from './lib/http/interceptors/auth-interceptor/auth-interceptor' +export * from './lib/http/is-http-status/is-http-status' diff --git a/libs/shared/utils/src/lib/http/is-http-status/is-http-status.spec.ts b/libs/shared/utils/src/lib/http/is-http-status/is-http-status.spec.ts new file mode 100644 index 00000000000..2b92bdf4e39 --- /dev/null +++ b/libs/shared/utils/src/lib/http/is-http-status/is-http-status.spec.ts @@ -0,0 +1,19 @@ +import { AxiosError, type AxiosResponse } from 'axios' +import { isHttpStatus } from './is-http-status' + +describe('isHttpStatus', () => { + it('matches the status of an Axios error', () => { + const error = new AxiosError('Not found', undefined, undefined, undefined, { status: 404 } as AxiosResponse) + + expect(isHttpStatus(error, 404)).toBe(true) + expect(isHttpStatus(error, 500)).toBe(false) + }) + + it('matches the status of a serialized error', () => { + expect(isHttpStatus({ response: { status: 404 } }, 404)).toBe(true) + }) + + it.each([undefined, null, 'Not found', {}, { response: null }])('does not match %p', (error) => { + expect(isHttpStatus(error, 404)).toBe(false) + }) +}) diff --git a/libs/domains/clusters/data-access/src/lib/http/is-http-status.ts b/libs/shared/utils/src/lib/http/is-http-status/is-http-status.ts similarity index 100% rename from libs/domains/clusters/data-access/src/lib/http/is-http-status.ts rename to libs/shared/utils/src/lib/http/is-http-status/is-http-status.ts From f29d2fc19bb286144709a7bb66c3892eeeac0ffe Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Tue, 13 Oct 2026 13:47:08 +0200 Subject: [PATCH 04/28] refactor(clusters): rename useIsEngineV2Cluster to useIsOperatorManagedCluster Co-Authored-By: Claude Opus 5.5 --- libs/domains/clusters/feature/src/index.ts | 2 +- .../cluster-actions/cluster-actions.spec.tsx | 24 ++++++++--------- .../lib/cluster-actions/cluster-actions.tsx | 27 ++++++++++++------- .../use-is-operator-managed-cluster.spec.ts} | 12 ++++----- .../use-is-operator-managed-cluster.ts} | 6 ++--- 5 files changed, 39 insertions(+), 32 deletions(-) rename libs/domains/clusters/feature/src/lib/hooks/{use-is-engine-v2-cluster/use-is-engine-v2-cluster.spec.ts => use-is-operator-managed-cluster/use-is-operator-managed-cluster.spec.ts} (77%) rename libs/domains/clusters/feature/src/lib/hooks/{use-is-engine-v2-cluster/use-is-engine-v2-cluster.ts => use-is-operator-managed-cluster/use-is-operator-managed-cluster.ts} (75%) diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 968536c8fcb..1a548a14a01 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -103,5 +103,5 @@ export * from './lib/hooks/use-create-self-managed-cluster/use-create-self-manag export * from './lib/hooks/use-cluster-operator-status/use-cluster-operator-status' export * from './lib/platform-configuration/hooks/use-update-platform-configuration' export * from './lib/hooks/use-cluster-operator-bootstrap/use-cluster-operator-bootstrap' -export * from './lib/hooks/use-is-engine-v2-cluster/use-is-engine-v2-cluster' +export * from './lib/hooks/use-is-operator-managed-cluster/use-is-operator-managed-cluster' export * from './lib/hooks/use-update-cluster-operator/use-update-cluster-operator' diff --git a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx index 2d60437e7d8..03bca317d09 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.spec.tsx @@ -8,11 +8,11 @@ import { import type { ReactNode } from 'react' import { clusterFactoryMock } from '@qovery/shared/factories' import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import { useIsEngineV2Cluster } from '../hooks/use-is-engine-v2-cluster/use-is-engine-v2-cluster' +import { useIsOperatorManagedCluster } from '../hooks/use-is-operator-managed-cluster/use-is-operator-managed-cluster' import { ClusterActions } from './cluster-actions' -jest.mock('../hooks/use-is-engine-v2-cluster/use-is-engine-v2-cluster') -const mockUseIsEngineV2Cluster = useIsEngineV2Cluster as jest.Mock +jest.mock('../hooks/use-is-operator-managed-cluster/use-is-operator-managed-cluster') +const mockUseIsOperatorManagedCluster = useIsOperatorManagedCluster as jest.Mock const mockCluster = clusterFactoryMock(1)[0] const mockOpenModal = jest.fn() @@ -58,7 +58,7 @@ jest.mock('@qovery/shared/util-hooks', () => ({ describe('ClusterActions', () => { beforeEach(() => { jest.clearAllMocks() - mockUseIsEngineV2Cluster.mockReturnValue(false) + mockUseIsOperatorManagedCluster.mockReturnValue(false) mockCluster.deployment_status = ClusterDeploymentStatusEnum.UP_TO_DATE mockClusterStatus = { cluster_id: mockCluster.id, @@ -316,14 +316,14 @@ describe('ClusterActions', () => { expect(screen.queryByRole('button', { name: 'Logs' })).not.toBeInTheDocument() }) - describe('Engine v2 clusters', () => { + describe('Operator-managed clusters', () => { const selfManagedCluster = { ...mockCluster, kubernetes: KubernetesEnum.SELF_MANAGED, cloud_provider: 'AWS' as const, } - it('keeps the installation guide for self-managed clusters outside Engine v2', () => { + it('keeps the installation guide for self-managed clusters not managed by the Operator', () => { const { container } = renderWithProviders( ) @@ -333,8 +333,8 @@ describe('ClusterActions', () => { expect(screen.queryByLabelText(/manage deployment/i)).not.toBeInTheDocument() }) - it('offers to deploy an Engine v2 cluster, without stopping it', async () => { - mockUseIsEngineV2Cluster.mockReturnValue(true) + it('offers to deploy an Operator-managed cluster, without stopping it', async () => { + mockUseIsOperatorManagedCluster.mockReturnValue(true) const { userEvent } = renderWithProviders( { expect(screen.queryByRole('menuitem', { name: /stop/i })).not.toBeInTheDocument() }) - it('offers to redeploy a deployed Engine v2 cluster, without upgrading Kubernetes', async () => { - mockUseIsEngineV2Cluster.mockReturnValue(true) + it('offers to redeploy a deployed Operator-managed cluster, without upgrading Kubernetes', async () => { + mockUseIsOperatorManagedCluster.mockReturnValue(true) const { userEvent } = renderWithProviders( { expect(screen.queryByRole('menuitem', { name: /stop/i })).not.toBeInTheDocument() }) - it('shows the deployments card action for Engine v2 clusters', () => { - mockUseIsEngineV2Cluster.mockReturnValue(true) + it('shows the deployments card action for Operator-managed clusters', () => { + mockUseIsOperatorManagedCluster.mockReturnValue(true) renderWithProviders( ) diff --git a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx index f199cfd4a0c..c72bd9b27fc 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-actions/cluster-actions.tsx @@ -25,7 +25,7 @@ import { useClusterRunningStatus } from '../hooks/use-cluster-running-status/use import { useDeployCluster } from '../hooks/use-deploy-cluster/use-deploy-cluster' import { useDownloadKubeconfig } from '../hooks/use-download-kubeconfig/use-download-kubeconfig' import { useEksAnywhereClusterJwt } from '../hooks/use-eks-anywhere-cluster-jwt/use-eks-anywhere-cluster-jwt' -import { useIsEngineV2Cluster } from '../hooks/use-is-engine-v2-cluster/use-is-engine-v2-cluster' +import { useIsOperatorManagedCluster } from '../hooks/use-is-operator-managed-cluster/use-is-operator-managed-cluster' import { useStopCluster } from '../hooks/use-stop-cluster/use-stop-cluster' import { useUpdateEksAnywhereCommit } from '../hooks/use-update-eks-anywhere-commit/use-update-eks-anywhere-commit' import { useUpgradeCluster } from '../hooks/use-upgrade-cluster/use-upgrade-cluster' @@ -37,13 +37,13 @@ function MenuManageDeployment({ cluster, clusterStatus, variant = 'default', - isEngineV2Cluster = false, + isOperatorManagedCluster = false, }: { cluster: Cluster clusterStatus: ClusterStatus variant?: ActionToolbarVariant - // The customer runs an Engine v2 cluster: Qovery only deploys its platform profile, it cannot stop or upgrade it. - isEngineV2Cluster?: boolean + // The cluster is managed by the Qovery Operator: Qovery only deploys its platform profile, it cannot stop or upgrade it. + isOperatorManagedCluster?: boolean }) { const { openModalConfirmation } = useModalConfirmation() const { openModal, closeModal } = useModal() @@ -68,7 +68,7 @@ function MenuManageDeployment({ clusterStatus.next_k8s_available_version !== null && clusterStatus.status === 'DEPLOYED' && cluster.kubernetes !== 'PARTIALLY_MANAGED' && - !isEngineV2Cluster + !isOperatorManagedCluster const clusterNeedUpdate = cluster.deployment_status !== 'UP_TO_DATE' && clusterStatus.status !== 'STOPPED' const isEksAnywhereCluster = cluster.kubernetes === 'PARTIALLY_MANAGED' const hasEksAnywhereGitRepository = Boolean( @@ -207,7 +207,7 @@ function MenuManageDeployment({ {tooltipClusterNeedUpdate} ), - !isEngineV2Cluster && + !isOperatorManagedCluster && cluster.cloud_provider !== 'GCP' && cluster.cloud_provider !== 'AZURE' && isStopAvailable(clusterStatus.status) && @@ -428,7 +428,7 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }: organizationId: cluster.organization.id, clusterId: cluster.id, }) - const isEngineV2Cluster = useIsEngineV2Cluster(cluster) + const isOperatorManagedCluster = useIsOperatorManagedCluster(cluster) const searchParams = useMemo(() => { // @ts-expect-error TODO needs to be fixed @@ -479,8 +479,15 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }: const primaryActionButton = match(cluster) .when( - () => isEngineV2Cluster, - () => + () => isOperatorManagedCluster, + () => ( + + ) ) .with({ cloud_provider: P.not('ON_PREMISE'), kubernetes: 'SELF_MANAGED' }, () => ( @@ -504,7 +511,7 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }: )) .otherwise(() => ) const logsButton = - variant === 'card' && (cluster.kubernetes !== 'SELF_MANAGED' || isEngineV2Cluster) ? ( + variant === 'card' && (cluster.kubernetes !== 'SELF_MANAGED' || isOperatorManagedCluster) ? ( @@ -150,13 +150,8 @@ describe('CatalogVariableInput', () => { const field = { key: 'enabled', label: 'Enabled', type: 'bool' } as const renderWithProviders( <> - - + + ) @@ -166,7 +161,7 @@ describe('CatalogVariableInput', () => { it('does not flag a control without error', () => { renderWithProviders( - { it('renders sensitive values as passwords', () => { renderWithProviders( - { it.each(['card', 'row'] as const)('masks sensitive number values in the %s layout', (layout) => { renderWithProviders( - { it('does not let a disabled sensitive field reveal its value', () => { renderWithProviders( - { it('keeps the toggle presentation as the default for boolean fields', async () => { const onChange = jest.fn() const { userEvent } = renderWithProviders( - { it('renders a boolean control even when the field declares allowed values', async () => { const onChange = jest.fn() const { userEvent } = renderWithProviders( - { it('supports a checkbox presentation for boolean fields', async () => { const onChange = jest.fn() const { userEvent } = renderWithProviders( - void - value: CatalogVariableValue | undefined + onChange: (value: SchemaFieldValue) => void + value: SchemaFieldValue | undefined } const DESCRIPTION_TRUNCATE_LIMIT = 200 const formatDescription = (description: string) => description.replace(/([.!?])\s+/g, '$1\n') -export function CatalogVariableDescription({ description, highlight }: { description: string; highlight?: string }) { +export function SchemaFieldDescription({ description, highlight }: { description: string; highlight?: string }) { const formattedDescription = formatDescription(description) return ( @@ -47,9 +47,9 @@ export function CatalogVariableDescription({ description, highlight }: { descrip ) } -type CatalogVariableControlProps = Omit & { errorId: string } +type SchemaFieldControlProps = Omit & { errorId: string } -function CatalogVariableControl({ +function SchemaFieldControl({ autoFocus, booleanControl = 'toggle', disabled, @@ -58,7 +58,7 @@ function CatalogVariableControl({ field, onChange, value, -}: CatalogVariableControlProps) { +}: SchemaFieldControlProps) { const errorProps = { 'aria-describedby': error ? errorId : undefined, 'aria-invalid': error ? true : undefined, @@ -136,7 +136,7 @@ function CatalogVariableControl({ ) } -function CatalogVariableInputRow({ +function SchemaFieldInputRow({ autoFocus, booleanControl, disabled, @@ -146,19 +146,17 @@ function CatalogVariableInputRow({ highlight, onChange, value, -}: CatalogVariableControlProps) { +}: SchemaFieldControlProps) { return (

- {field.description ? ( - - ) : null} + {field.description ? : null}
- { + it('formats snake_case and kebab-case keys into a readable label', () => { + expect(formatSchemaFieldKey('database_name')).toBe('Database name') + expect(formatSchemaFieldKey('log-storage')).toBe('Log storage') + expect(formatSchemaFieldKey('CUSTOMER_MANAGED')).toBe('CUSTOMER MANAGED') + }) +}) + +describe('schema field value coercion', () => { + it('returns string and boolean values with safe fallbacks', () => { + expect(getSchemaFieldStringValue('value')).toBe('value') + expect(getSchemaFieldStringValue(true)).toBe('') + expect(getSchemaFieldStringValue(undefined)).toBe('') + expect(getSchemaFieldBooleanValue(true)).toBe(true) + expect(getSchemaFieldBooleanValue('value')).toBe(false) + expect(getSchemaFieldBooleanValue(undefined)).toBe(false) + }) + + it('resolves values against the field default without losing their type', () => { + expect(getSchemaFieldValue({ type: 'number', defaultValue: '12' }, undefined)).toBe('12') + expect(getSchemaFieldValue({ type: 'number', defaultValue: '12' }, 24)).toBe('24') + expect(getSchemaFieldValue({ type: 'bool', defaultValue: 'true' }, undefined)).toBe(true) + expect(getSchemaFieldValue({ type: 'bool' }, 'false')).toBe(false) + expect(getSchemaFieldValue({ type: 'bool' }, undefined)).toBeUndefined() + expect(getSchemaFieldValue({ type: 'string', defaultValue: null }, undefined)).toBeUndefined() + expect(getSchemaFieldValue({ type: 'string' }, { unexpected: true })).toBeUndefined() + }) +}) + +describe('schema field validation', () => { + it('treats booleans and non-blank strings as fulfilled', () => { + expect(isSchemaFieldValueFulfilled(false)).toBe(true) + expect(isSchemaFieldValueFulfilled('value')).toBe(true) + expect(isSchemaFieldValueFulfilled(' ')).toBe(false) + expect(isSchemaFieldValueFulfilled(undefined)).toBe(false) + }) + + it('matches values against the field pattern and ignores broken patterns', () => { + expect(isSchemaFieldValueMatchingPattern({ pattern: '^[a-z]+$' }, 'value')).toBe(true) + expect(isSchemaFieldValueMatchingPattern({ pattern: '^[a-z]+$' }, 'Value')).toBe(false) + expect(isSchemaFieldValueMatchingPattern({ pattern: '(' }, 'value')).toBe(true) + expect(isSchemaFieldValueMatchingPattern({}, 'value')).toBe(true) + expect(isSchemaFieldValueMatchingPattern({ pattern: '^[a-z]+$' }, undefined)).toBe(true) + }) + + it('reports length violations with a dedicated message', () => { + expect(getSchemaFieldLengthValidationError({ minLength: 2, maxLength: 4 }, 'value')).toBe( + 'Value must be between 2 and 4 characters.' + ) + expect(getSchemaFieldLengthValidationError({ minLength: 8 }, 'value')).toBe('Value must be at least 8 characters.') + expect(getSchemaFieldLengthValidationError({ maxLength: 3 }, 'value')).toBe('Value must be at most 3 characters.') + expect(getSchemaFieldLengthValidationError({ minLength: 2, maxLength: 8 }, 'value')).toBeUndefined() + expect(getSchemaFieldLengthValidationError({ minLength: 2 }, undefined)).toBeUndefined() + }) + + it('reports numeric violations with a dedicated message', () => { + expect(getSchemaFieldNumberValidationError({ type: 'number', min: 20, max: 65536 }, '19')).toBe( + 'Value must be between 20 and 65536.' + ) + expect(getSchemaFieldNumberValidationError({ type: 'number', min: 20 }, '19')).toBe('Value must be at least 20.') + expect(getSchemaFieldNumberValidationError({ type: 'number', max: 65536 }, '65537')).toBe( + 'Value must be at most 65536.' + ) + expect(getSchemaFieldNumberValidationError({ type: 'number' }, 'not-a-number')).toBe('Value must be a number.') + expect(getSchemaFieldNumberValidationError({ type: 'number', min: 20, max: 65536 }, '20')).toBeUndefined() + expect(getSchemaFieldNumberValidationError({ type: 'string', min: 20 }, '19')).toBeUndefined() + }) + + it('combines length and pattern checks into one validation error', () => { + expect(getSchemaFieldValidationError({ type: 'string', maxLength: 3, pattern: '^[a-z]+$' }, 'value')).toBe( + 'Value must be at most 3 characters.' + ) + expect(getSchemaFieldValidationError({ type: 'string', pattern: '^[a-z]+$' }, 'Value')).toBe( + 'Value does not match the expected format.' + ) + expect(getSchemaFieldValidationError({ type: 'string', pattern: '^[a-z]+$' }, 'value')).toBeUndefined() + }) + + it('rejects values outside the allowed values', () => { + const field = { type: 'string' as const, allowedValues: ['pvc', 's3'] } + + expect(getSchemaFieldValidationError(field, 'gcs')).toBe('Please select one of the allowed values.') + expect(getSchemaFieldValidationError(field, 's3')).toBeUndefined() + expect(getSchemaFieldValidationError(field, '')).toBeUndefined() + expect(getSchemaFieldValidationError({ type: 'bool', allowedValues: ['true'] }, false)).toBeUndefined() + }) + + it('validates required state and constraints together', () => { + expect(isSchemaFieldValid({ type: 'string', required: true }, undefined)).toBe(false) + expect(isSchemaFieldValid({ type: 'string', required: true }, 'value')).toBe(true) + expect(isSchemaFieldValid({ type: 'string', required: false, pattern: '^[a-z]+$' }, 'Value')).toBe(false) + expect(isSchemaFieldValid({ type: 'string', required: false }, undefined)).toBe(true) + }) +}) + +describe('getSchemaFieldSummaryValue', () => { + it('renders booleans as enabled state and masks sensitive values', () => { + expect(getSchemaFieldSummaryValue({}, true)).toBe('Enabled') + expect(getSchemaFieldSummaryValue({}, false)).toBe('Disabled') + expect(getSchemaFieldSummaryValue({ sensitive: true }, 'secret')).toBe('••••••••') + expect(getSchemaFieldSummaryValue({ sensitive: true }, '')).toBe('') + expect(getSchemaFieldSummaryValue({ sensitive: false }, 'value')).toBe('value') + }) +}) diff --git a/libs/domains/catalog/feature/src/lib/catalog-variable-field/catalog-variable-field.ts b/libs/shared/schema-fields/feature/src/lib/schema-field/schema-field.ts similarity index 61% rename from libs/domains/catalog/feature/src/lib/catalog-variable-field/catalog-variable-field.ts rename to libs/shared/schema-fields/feature/src/lib/schema-field/schema-field.ts index 4abb1c27735..b631846e932 100644 --- a/libs/domains/catalog/feature/src/lib/catalog-variable-field/catalog-variable-field.ts +++ b/libs/shared/schema-fields/feature/src/lib/schema-field/schema-field.ts @@ -1,11 +1,11 @@ -export type CatalogVariableValue = string | boolean +export type SchemaFieldValue = string | boolean -export type CatalogVariableFieldType = 'string' | 'number' | 'bool' +export type SchemaFieldType = 'string' | 'number' | 'bool' -export interface CatalogVariableField { +export interface SchemaField { key: string label: string - type: CatalogVariableFieldType + type: SchemaFieldType description?: string required?: boolean sensitive?: boolean @@ -18,23 +18,23 @@ export interface CatalogVariableField { max?: number } -export function formatCatalogKey(key: string) { +export function formatSchemaFieldKey(key: string) { const label = key.replace(/[-_]/g, ' ') return `${label.charAt(0).toUpperCase()}${label.slice(1)}` } -export function getCatalogStringValue(value: CatalogVariableValue | undefined) { +export function getSchemaFieldStringValue(value: SchemaFieldValue | undefined) { return typeof value === 'string' ? value : '' } -export function getCatalogBooleanValue(value: CatalogVariableValue | undefined) { +export function getSchemaFieldBooleanValue(value: SchemaFieldValue | undefined) { return typeof value === 'boolean' ? value : false } -export function getCatalogVariableValue( - field: Pick & { defaultValue?: string | null }, +export function getSchemaFieldValue( + field: Pick & { defaultValue?: string | null }, value: unknown -): CatalogVariableValue | undefined { +): SchemaFieldValue | undefined { const resolvedValue = value ?? field.defaultValue if (field.type === 'bool') { if (typeof resolvedValue === 'boolean') return resolvedValue @@ -46,14 +46,14 @@ export function getCatalogVariableValue( return undefined } -export function isCatalogFieldValueFulfilled(value: CatalogVariableValue | undefined) { +export function isSchemaFieldValueFulfilled(value: SchemaFieldValue | undefined) { if (typeof value === 'boolean') return true return Boolean(value?.trim()) } -export function isCatalogFieldValueMatchingPattern( - field: Pick, - value: CatalogVariableValue | undefined +export function isSchemaFieldValueMatchingPattern( + field: Pick, + value: SchemaFieldValue | undefined ) { if (typeof value !== 'string' || !value || !field.pattern) return true @@ -64,9 +64,9 @@ export function isCatalogFieldValueMatchingPattern( } } -export function getCatalogFieldLengthValidationError( - field: Pick, - value: CatalogVariableValue | undefined +export function getSchemaFieldLengthValidationError( + field: Pick, + value: SchemaFieldValue | undefined ) { if (typeof value !== 'string' || !value) return undefined @@ -84,9 +84,9 @@ export function getCatalogFieldLengthValidationError( return undefined } -export function getCatalogFieldNumberValidationError( - field: Pick, - value: CatalogVariableValue | undefined +export function getSchemaFieldNumberValidationError( + field: Pick, + value: SchemaFieldValue | undefined ) { if (field.type !== 'number' || typeof value !== 'string' || !value.trim()) return undefined @@ -107,9 +107,9 @@ export function getCatalogFieldNumberValidationError( return undefined } -export function getCatalogFieldValidationError( - field: Pick, - value: CatalogVariableValue | undefined +export function getSchemaFieldValidationError( + field: Pick, + value: SchemaFieldValue | undefined ) { if ( field.type !== 'bool' && @@ -121,31 +121,28 @@ export function getCatalogFieldValidationError( return 'Please select one of the allowed values.' } - const numberValidationError = getCatalogFieldNumberValidationError(field, value) + const numberValidationError = getSchemaFieldNumberValidationError(field, value) if (numberValidationError) return numberValidationError - const lengthValidationError = getCatalogFieldLengthValidationError(field, value) + const lengthValidationError = getSchemaFieldLengthValidationError(field, value) if (lengthValidationError) return lengthValidationError - if (!isCatalogFieldValueMatchingPattern(field, value)) return 'Value does not match the expected format.' + if (!isSchemaFieldValueMatchingPattern(field, value)) return 'Value does not match the expected format.' return undefined } -export function isCatalogFieldValid( +export function isSchemaFieldValid( field: Pick< - CatalogVariableField, + SchemaField, 'required' | 'type' | 'allowedValues' | 'pattern' | 'minLength' | 'maxLength' | 'min' | 'max' >, - value: CatalogVariableValue | undefined + value: SchemaFieldValue | undefined ) { - if (field.required && !isCatalogFieldValueFulfilled(value)) return false - return !getCatalogFieldValidationError(field, value) + if (field.required && !isSchemaFieldValueFulfilled(value)) return false + return !getSchemaFieldValidationError(field, value) } -export function getCatalogSummaryFieldValue( - field: Pick, - value: CatalogVariableValue | undefined -) { +export function getSchemaFieldSummaryValue(field: Pick, value: SchemaFieldValue | undefined) { if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled' if (field.sensitive && value) return '••••••••' return value diff --git a/libs/domains/catalog/feature/tsconfig.json b/libs/shared/schema-fields/feature/tsconfig.json similarity index 100% rename from libs/domains/catalog/feature/tsconfig.json rename to libs/shared/schema-fields/feature/tsconfig.json diff --git a/libs/domains/catalog/feature/tsconfig.lib.json b/libs/shared/schema-fields/feature/tsconfig.lib.json similarity index 100% rename from libs/domains/catalog/feature/tsconfig.lib.json rename to libs/shared/schema-fields/feature/tsconfig.lib.json diff --git a/libs/domains/catalog/feature/tsconfig.spec.json b/libs/shared/schema-fields/feature/tsconfig.spec.json similarity index 100% rename from libs/domains/catalog/feature/tsconfig.spec.json rename to libs/shared/schema-fields/feature/tsconfig.spec.json diff --git a/tsconfig.base.json b/tsconfig.base.json index ecf27f17870..d9478ca5b11 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -19,7 +19,6 @@ "@qovery/domains/audit-logs/feature": ["libs/domains/audit-logs/feature/src/index.ts"], "@qovery/domains/cloud-providers/data-access": ["libs/domains/cloud-providers/data-access/src/index.ts"], "@qovery/domains/cloud-providers/feature": ["libs/domains/cloud-providers/feature/src/index.ts"], - "@qovery/domains/catalog/feature": ["libs/domains/catalog/feature/src/index.ts"], "@qovery/domains/cluster-metrics/feature": ["libs/domains/cluster-metrics/feature/src/index.ts"], "@qovery/domains/clusters/data-access": ["libs/domains/clusters/data-access/src/index.ts"], "@qovery/domains/clusters/feature": ["libs/domains/clusters/feature/src/index.ts"], @@ -66,6 +65,7 @@ "@qovery/shared/posthog/feature": ["libs/shared/posthog/feature/src/index.ts"], "@qovery/shared/router": ["libs/shared/router/src/index.ts"], "@qovery/shared/routes": ["libs/shared/routes/src/index.ts"], + "@qovery/shared/schema-fields/feature": ["libs/shared/schema-fields/feature/src/index.ts"], "@qovery/shared/spotlight/feature": ["libs/shared/spotlight/feature/src/index.ts"], "@qovery/shared/ui": ["libs/shared/ui/src/index.ts"], "@qovery/shared/util-const": ["libs/shared/util-const/src/index.ts"], From 39a7ad61b2f85b9d46531eb8508ff372f2c7ba12 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Wed, 14 Oct 2026 10:39:24 +0200 Subject: [PATCH 28/28] test(ui): render InputSearch specs with the shared test utils Co-Authored-By: Claude Opus 5.5 --- .../components/inputs/input-search/input-search.spec.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/libs/shared/ui/src/lib/components/inputs/input-search/input-search.spec.tsx b/libs/shared/ui/src/lib/components/inputs/input-search/input-search.spec.tsx index 637335400b7..78741b49b42 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-search/input-search.spec.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-search/input-search.spec.tsx @@ -1,13 +1,12 @@ -import { render, screen } from '__tests__/utils/setup-jest' import { useState } from 'react' -import { renderWithProviders } from '@qovery/shared/util-tests' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' import InputSearch, { type InputSearchProps } from './input-search' let props: InputSearchProps describe('InputSearch', () => { it('should render successfully', () => { - const { baseElement } = render() + const { baseElement } = renderWithProviders() expect(baseElement).toBeTruthy() }) @@ -22,7 +21,7 @@ describe('InputSearch', () => { }) it('shows a controlled value with its clear button', () => { - const { rerender } = render() + const { rerender } = renderWithProviders() expect(screen.getByRole('textbox')).toHaveValue('loki') expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument()