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/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index 968536c8fcb..b4e0c7de319 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -8,7 +8,7 @@ export * from './lib/cluster-avatar/cluster-avatar' export * from './lib/cluster-advanced-settings/cluster-advanced-settings' export * from './lib/cluster-advanced-settings/cluster-advanced-settings-feature' export * from './lib/cluster-profile/cluster-profile' -export { resolveProfileSelection } from './lib/cluster-profile/profile-tree' +export * from './lib/cluster-profile/profile-tree' export * from './lib/cluster-profile/use-cluster-profile-tree' export * from './lib/cluster-advanced-settings/init-form-values' export * from './lib/cluster-setup/cluster-setup' @@ -63,9 +63,9 @@ export * from './lib/hooks/use-services-cluster/use-services-cluster' export * from './lib/hooks/use-cluster-statuses/use-cluster-statuses' export * from './lib/hooks/use-cluster/use-cluster' export * from './lib/hooks/use-platform-templates/use-platform-templates' -export * from './lib/platform-configuration/hooks/use-platform-configuration' -export * from './lib/platform-configuration/hooks/use-platform-component-configuration' -export * from './lib/platform-configuration/hooks/use-platform-component-configurations' +export * from './lib/hooks/use-platform-configuration/use-platform-configuration' +export * from './lib/hooks/use-platform-component-configuration/use-platform-component-configuration' +export * from './lib/hooks/use-platform-component-configurations/use-platform-component-configurations' export * from './lib/hooks/use-clusters/use-clusters' export * from './lib/hooks/use-argocd-credentials/use-argocd-credentials' export * from './lib/hooks/use-cluster-advanced-settings/use-cluster-advanced-settings' @@ -101,7 +101,7 @@ export * from './lib/cluster-resources-settings/cluster-resources-settings' export * from './lib/cluster-add/cluster-add' export * from './lib/hooks/use-create-self-managed-cluster/use-create-self-managed-cluster' 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-update-platform-configuration/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-attached/use-is-operator-attached' 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..57652a02284 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 { useIsOperatorAttached } from '../hooks/use-is-operator-attached/use-is-operator-attached' 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-attached/use-is-operator-attached') +const mockUseIsOperatorAttached = useIsOperatorAttached 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) + mockUseIsOperatorAttached.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('clusters with an attached Operator', () => { 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 without an attached 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 a cluster with an attached Operator, without stopping it', async () => { + mockUseIsOperatorAttached.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 cluster with an attached Operator, without upgrading Kubernetes', async () => { + mockUseIsOperatorAttached.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 clusters with an attached Operator', () => { + mockUseIsOperatorAttached.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..2216439e960 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 { useIsOperatorAttached } from '../hooks/use-is-operator-attached/use-is-operator-attached' 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, + isOperatorAttached = 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 attached to the Qovery Operator: Qovery only deploys its platform profile, it cannot stop or upgrade it. + isOperatorAttached?: 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 + !isOperatorAttached 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 && + !isOperatorAttached && 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 isOperatorAttached = useIsOperatorAttached(cluster) const searchParams = useMemo(() => { // @ts-expect-error TODO needs to be fixed @@ -479,8 +479,10 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }: const primaryActionButton = match(cluster) .when( - () => isEngineV2Cluster, - () => + () => isOperatorAttached, + () => ( + + ) ) .with({ cloud_provider: P.not('ON_PREMISE'), kubernetes: 'SELF_MANAGED' }, () => ( @@ -504,7 +506,7 @@ export function ClusterActions({ cluster, clusterStatus, variant = 'default' }: )) .otherwise(() => ) const logsButton = - variant === 'card' && (cluster.kubernetes !== 'SELF_MANAGED' || isEngineV2Cluster) ? ( + variant === 'card' && (cluster.kubernetes !== 'SELF_MANAGED' || isOperatorAttached) ? ( @@ -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/shared/util-js/src/lib/catalog-variable-field.ts b/libs/shared/schema-fields/feature/src/lib/schema-field/schema-field.ts similarity index 61% rename from libs/shared/util-js/src/lib/catalog-variable-field.ts rename to libs/shared/schema-fields/feature/src/lib/schema-field/schema-field.ts index 4abb1c27735..b631846e932 100644 --- a/libs/shared/util-js/src/lib/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/shared/schema-fields/feature/tsconfig.json b/libs/shared/schema-fields/feature/tsconfig.json new file mode 100644 index 00000000000..c88d07daddd --- /dev/null +++ b/libs/shared/schema-fields/feature/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json" +} diff --git a/libs/shared/schema-fields/feature/tsconfig.lib.json b/libs/shared/schema-fields/feature/tsconfig.lib.json new file mode 100644 index 00000000000..45b22973977 --- /dev/null +++ b/libs/shared/schema-fields/feature/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": ["node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"] + }, + "exclude": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/shared/schema-fields/feature/tsconfig.spec.json b/libs/shared/schema-fields/feature/tsconfig.spec.json new file mode 100644 index 00000000000..1033686367b --- /dev/null +++ b/libs/shared/schema-fields/feature/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/shared/ui/src/lib/components/empty-state/__snapshots__/empty-state.spec.tsx.snap b/libs/shared/ui/src/lib/components/empty-state/__snapshots__/empty-state.spec.tsx.snap index 58cb84cdff6..405dcf3d70c 100644 --- a/libs/shared/ui/src/lib/components/empty-state/__snapshots__/empty-state.spec.tsx.snap +++ b/libs/shared/ui/src/lib/components/empty-state/__snapshots__/empty-state.spec.tsx.snap @@ -4,7 +4,7 @@ exports[`EmptyState should match snapshot 1`] = `
{ title: ReactNode icon?: IconName | ReactNode iconStyle?: IconStyle description?: ReactNode - size?: 'sm' | 'base' - variant?: 'neutral' | 'positive' className?: string } @@ -23,9 +63,6 @@ export function EmptyState({ variant = 'neutral', children, }: EmptyStateProps) { - const isPositive = variant === 'positive' - const iconColorClass = isPositive ? 'text-positive' : 'text-neutral-disabled' - const emptyStateIcon = icon && (typeof icon === 'string' ? ( @@ -33,7 +70,7 @@ export function EmptyState({ @@ -61,13 +98,7 @@ export function EmptyState({ if (size === 'sm') { return ( -
+
{emptyStateIcon}
@@ -81,13 +112,7 @@ export function EmptyState({ } return ( -
+
{emptyStateIcon}
diff --git a/libs/shared/ui/src/lib/components/highlight-text/highlight-text.stories.tsx b/libs/shared/ui/src/lib/components/highlight-text/highlight-text.stories.tsx new file mode 100644 index 00000000000..2cd944bfea1 --- /dev/null +++ b/libs/shared/ui/src/lib/components/highlight-text/highlight-text.stories.tsx @@ -0,0 +1,37 @@ +import { type Meta, type StoryObj } from '@storybook/react-webpack5' +import { HighlightText } from './highlight-text' + +const meta: Meta = { + component: HighlightText, + title: 'HighlightText', + render: (args) => ( +

+ +

+ ), +} + +export default meta + +type Story = StoryObj + +export const Match: Story = { + args: { + text: 'Retention period in weeks', + highlight: 'retention', + }, +} + +export const NoMatch: Story = { + args: { + text: 'Retention period in weeks', + highlight: 'storage', + }, +} + +export const RepeatedMatches: Story = { + args: { + text: 'Log level of the logs collector', + highlight: 'log', + }, +} 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 c2c92bfa3ff..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 { act, 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() }) @@ -21,39 +20,27 @@ describe('InputSearch', () => { expect(input).toHaveValue('some new text value') }) - it('follows default value changes', () => { - const { rerender } = render() + it('shows a controlled value with its clear button', () => { + const { rerender } = renderWithProviders() - rerender() - expect(screen.getByRole('textbox')).toHaveValue('envoy') + expect(screen.getByRole('textbox')).toHaveValue('loki') + expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument() + + rerender() - rerender() expect(screen.getByRole('textbox')).toHaveValue('') expect(screen.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument() }) - it('shows the clear button when an empty search receives a default value', () => { - const { rerender } = render() - - rerender() - - expect(screen.getByRole('textbox')).toHaveValue('loki') - expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument() - }) - - it('keeps the typed value while focused', async () => { - let setDefaultValue: (value: string) => void = () => undefined - function InputSearchWithExternalValue() { - const [defaultValue, setValue] = useState('lo') - setDefaultValue = setValue - return + it('clears a controlled value through onChange', async () => { + function ControlledInputSearch() { + const [value, setValue] = useState('loki') + return } - const { userEvent } = renderWithProviders() - const input = screen.getByRole('textbox') + const { userEvent } = renderWithProviders() - await userEvent.type(input, 'ki') - act(() => setDefaultValue('lok')) + await userEvent.click(screen.getByRole('button', { name: 'Clear search' })) - expect(input).toHaveValue('loki') + expect(screen.getByRole('textbox')).toHaveValue('') }) }) diff --git a/libs/shared/ui/src/lib/components/inputs/input-search/input-search.tsx b/libs/shared/ui/src/lib/components/inputs/input-search/input-search.tsx index 34193145a09..e5c1aa7af60 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-search/input-search.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-search/input-search.tsx @@ -1,11 +1,12 @@ -import { type ReactElement, useEffect, useRef, useState } from 'react' +import { type ReactElement, useRef, useState } from 'react' import { twMerge } from '@qovery/shared/util-js' import Icon from '../../icon/icon' export interface InputSearchProps { ariaLabel?: string placeholder?: string - defaultValue?: string + // Controls the input value; the input keeps its own value when unset. + value?: string className?: string onChange?: (value: string) => void isEmpty?: boolean @@ -18,7 +19,7 @@ export function InputSearch(props: InputSearchProps) { const { ariaLabel, placeholder = '', - defaultValue, + value, className = '', onChange, isEmpty = false, @@ -28,15 +29,8 @@ export function InputSearch(props: InputSearchProps) { } = props const ref = useRef(null) - const [toggleDelete, setToggleDelete] = useState(Boolean(defaultValue)) - - useEffect(() => { - const input = ref.current - if (!input || document.activeElement === input) return - const nextValue = defaultValue ?? '' - if (input.value !== nextValue) input.value = nextValue - setToggleDelete(nextValue !== '') - }, [defaultValue]) + const [toggleDelete, setToggleDelete] = useState(false) + const showClearButton = value === undefined ? toggleDelete : value !== '' const getValue = (value: string) => { if (onChange) onChange(value) @@ -70,14 +64,13 @@ export function InputSearch(props: InputSearchProps) { )} type="text" aria-label={ariaLabel} - // A defaultValue prop, even undefined, makes React render an empty value attribute. - {...(defaultValue !== undefined ? { defaultValue } : {})} + value={value} placeholder={placeholder} disabled={false} onChange={(e) => getValue(e.currentTarget.value)} name="search" /> - {toggleDelete && ( + {showClearButton && (