diff --git a/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.spec.tsx index da5b2a454e6..2817251cffb 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.spec.tsx @@ -54,6 +54,18 @@ describe('ClusterAdd', () => { await userEvent.click(screen.getByRole('button', { name: /Amazon Web Services EKS Anywhere\s*Request access/ })) expect(mockShowPylonForm).toHaveBeenCalledWith('request-access-eks-anywhere') + expect(posthog.capture).toHaveBeenCalledWith('select-cluster', { + selectedCloudProvider: 'AWS', + selectedInstallationType: 'partially-managed', + }) + }) + + it('names each provider card once for assistive technologies', () => { + const { container } = renderWithProviders() + + expect(screen.getByRole('button', { name: 'AWS' })).toBeInTheDocument() + expect(screen.queryAllByRole('img')).toHaveLength(0) + expect(container.querySelectorAll('svg:not([aria-hidden="true"])')).toHaveLength(0) }) it('lets the creation flow be dismissed before the cluster is created', async () => { diff --git a/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.tsx b/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.tsx index 8c7ab62ae48..a76cf74f09f 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-add/cluster-add.tsx @@ -25,11 +25,12 @@ type ProviderCardProps = { analytics: { selectedCloudProvider: string; selectedInstallationType: string } } & ({ slug: string } | { onClick: () => void }) -function ProviderIcon({ icon, title }: { icon: string | ReactElement; title: string }) { +// Decorative: the card title already names the provider. +function ProviderIcon({ icon }: { icon: string | ReactElement }) { return typeof icon === 'string' ? ( - {title} + ) : ( - cloneElement(icon, { className: 'size-5 select-none' }) + cloneElement(icon, { className: 'size-5 select-none', 'aria-hidden': true }) ) } @@ -41,7 +42,7 @@ function ProviderCard({ title, icon, disabled = false, actionLabel, analytics, . ) const content = ( <> - + {title} {actionLabel ? ( {actionLabel} diff --git a/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.spec.tsx index d5aadc2dd5c..25563eeb131 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-operator-status/cluster-operator-status.spec.tsx @@ -44,8 +44,7 @@ describe('ClusterOperatorStatus', () => { const button = screen.getByRole('button', { name: 'Update Operator' }) expect(button).toBeDisabled() - button.focus() - await userEvent.keyboard('{Enter}') + await userEvent.click(button) expect(mockUpdateOperator).not.toHaveBeenCalled() }) 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 0826c5c5353..e55b044cfd0 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 @@ -31,28 +31,43 @@ function getDisplayedScalarValue(field: PlatformScalarField, value: unknown) { } // Local edits differing from the saved (or default) value; edits reverted by hand do not count. +function getChangedProfileValues( + profileTree: ProfileTreeItem[], + configuration: ClusterPlatformConfigurationResponse | null | undefined, + profileValues: ProfileValues +): ProfileValues { + const fieldsByComponent = new Map( + profileTree.flatMap((layer) => layer.children).map((component) => [component.key, component.fields]) + ) + return Object.fromEntries( + Object.entries(profileValues).flatMap(([componentKey, values]) => { + const fields = fieldsByComponent.get(componentKey) ?? [] + const savedValues = applyPlatformConfigurationDefaults( + fields, + configuration?.platform.managedConfig?.[componentKey] ?? {} + ) + const changedValues = Object.fromEntries( + Object.entries(values).filter(([fieldKey, value]) => { + const field = fields.find(({ key }) => key === fieldKey) + return field && isPlatformScalarField(field) + ? getDisplayedScalarValue(field, value) !== getDisplayedScalarValue(field, savedValues[fieldKey]) + : !equal(value, savedValues[fieldKey]) + }) + ) + return Object.keys(changedValues).length ? [[componentKey, changedValues]] : [] + }) + ) +} + function countProfileChanges( profileTree: ProfileTreeItem[], configuration: ClusterPlatformConfigurationResponse | null | undefined, profileValues: ProfileValues, clusterInputs: ClusterInputValues ) { - const fieldsByComponent = new Map( - profileTree.flatMap((layer) => layer.children).map((component) => [component.key, component.fields]) + const profileChanges = Object.values(getChangedProfileValues(profileTree, configuration, profileValues)).flatMap( + (values) => Object.keys(values) ) - const profileChanges = Object.entries(profileValues).flatMap(([componentKey, values]) => { - const fields = fieldsByComponent.get(componentKey) ?? [] - const savedValues = applyPlatformConfigurationDefaults( - fields, - configuration?.platform.managedConfig?.[componentKey] ?? {} - ) - return Object.entries(values).filter(([fieldKey, value]) => { - const field = fields.find(({ key }) => key === fieldKey) - return field && isPlatformScalarField(field) - ? getDisplayedScalarValue(field, value) !== getDisplayedScalarValue(field, savedValues[fieldKey]) - : !equal(value, savedValues[fieldKey]) - }) - }) const clusterInputChanges = Object.entries(clusterInputs).flatMap(([componentKey, values]) => Object.entries(values).filter( ([inputKey, value]) => value !== (configuration?.clusterInputs[componentKey]?.[inputKey] ?? '') @@ -73,16 +88,20 @@ function omitSubmittedValues>>( ) as T } -// PUT replaces the whole configuration: unedited components and inputs are sent back as saved. +// PUT replaces the whole configuration: unedited components and inputs are sent back as saved, and edits reverted +// by hand are left out so the field keeps following the schema default. function getConfigurationRequest( template: PlatformTemplateSummaryResponse, + profileTree: ProfileTreeItem[], configuration: ClusterPlatformConfigurationResponse | null | undefined, profileValues: ProfileValues, clusterInputs: ClusterInputValues ): ClusterPlatformConfigurationRequest { const savedManagedConfig = configuration?.platform.managedConfig const managedConfig = { ...savedManagedConfig } - for (const [componentKey, values] of Object.entries(profileValues)) { + for (const [componentKey, values] of Object.entries( + getChangedProfileValues(profileTree, configuration, profileValues) + )) { managedConfig[componentKey] = omitEmptyValues({ ...savedManagedConfig?.[componentKey], ...values }) } const nextClusterInputs = { ...configuration?.clusterInputs } @@ -157,7 +176,13 @@ export function ClusterProfileProvider({ children }: PropsWithChildren) { if (!selectedTemplate) return await updatePlatformConfiguration({ clusterId, - configurationRequest: getConfigurationRequest(selectedTemplate, configuration, profileValues, clusterInputs), + configurationRequest: getConfigurationRequest( + selectedTemplate, + profileTree, + configuration, + profileValues, + clusterInputs + ), }) // The saved configuration now carries the submitted edits, so their local copies can go. setProfileValues((currentValues) => omitSubmittedValues(currentValues, profileValues)) 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 4f287054a0d..f8a073058f1 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 @@ -699,6 +699,14 @@ describe('ClusterProfileFeature', () => { expect(onActiveComponentChange).not.toHaveBeenCalled() }) + it('does not open a component without configuration matching the search', () => { + renderWithProviders() + + expect(screen.getByRole('button', { name: 'Karpenter crd' })).toBeDisabled() + expect(screen.getByText('No settings match your search')).toBeInTheDocument() + expect(screen.queryByText('No configuration needed for this component')).not.toBeInTheDocument() + }) + it('shows empty states when nothing matches', () => { renderWithProviders() @@ -861,6 +869,24 @@ describe('ClusterProfileFeature', () => { expect(mockDeployCluster).not.toHaveBeenCalled() }) + it('does not save edits that were reverted by hand', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('switch', { name: 'High availability' })) + await userEvent.click(screen.getByRole('switch', { name: 'High availability' })) + await userEvent.clear(screen.getByRole('spinbutton', { name: 'Retention period' })) + await userEvent.type(screen.getByRole('spinbutton', { name: 'Retention period' }), '24') + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(mockUpdatePlatformConfiguration).toHaveBeenCalledWith({ + clusterId: 'cluster-id', + configurationRequest: { + platform: expect.objectContaining({ managedConfig: { loki: { 'retention-period': 24 } } }), + clusterInputs: {}, + }, + }) + }) + it('keeps the edits made while saving', async () => { let resolveSave = () => undefined as unknown mockUpdatePlatformConfiguration.mockReturnValue(new Promise((resolve) => (resolveSave = resolve))) diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.tsx index b3666f8df5a..112198e18ba 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile.tsx @@ -68,7 +68,10 @@ function filterProfileTree(profileTree: ProfileTreeItem[], query: string): Profi getSectionFields(section).some((field) => fieldMatchesProfileSearch(field, query)) ) ) - return children.length ? [{ ...layer, children }] : [] + // A layer matched through components with nothing to configure has nothing left to open. + return children.length + ? [{ ...layer, children, configurable: children.some(({ configurable }) => configurable) }] + : [] }) } @@ -478,11 +481,15 @@ function ClusterProfileView({
- {searchQuery && !isLoading && !activeComponent ? ( + {!isLoading && !isError && !activeComponent ? (
No settings match your search} + title={ + + {searchQuery ? 'No settings match your search' : 'Nothing to configure for this cluster'} + + } className="h-auto w-full p-8 shadow-sm" />
diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.spec.tsx index c40866bfdfd..9d200b95ef2 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.spec.tsx @@ -48,6 +48,7 @@ describe('ProfileOperatorFooter', () => { organizationId: 'org-123', clusterId: 'cluster-123', refetchInterval: 30_000, + stopPollingWhenMissing: true, }) expect(screen.getByRole('button', { name: 'Qovery Operator: Update available' })).toBeInTheDocument() }) @@ -71,20 +72,21 @@ describe('ProfileOperatorFooter', () => { expect(screen.queryByRole('button', { name: 'Update Operator' })).not.toBeInTheDocument() }) - it('shows the Operator versions in a tooltip and starts an update', async () => { + it('shows the Operator versions in a popover and starts an update', async () => { mockUseClusterOperatorStatus.mockReturnValue({ data: operatorStatus, isLoading: false }) const { userEvent } = renderWithProviders() - await userEvent.hover(screen.getByRole('button', { name: 'Qovery Operator: Update available' })) + await userEvent.click(screen.getByRole('button', { name: 'Qovery Operator: Update available' })) - const status = (await screen.findAllByRole('region', { name: 'Qovery Operator status' }))[0] + expect(await screen.findByRole('dialog', { name: 'Qovery Operator status' })).toBeInTheDocument() + const status = await screen.findByRole('region', { name: 'Qovery Operator status' }) expect(status).toHaveTextContent('Update available') expect(status).toHaveTextContent('v1.202.0') expect(status).toHaveTextContent('Target: v1.203.0') expect(status).toHaveTextContent('0.2.0') expect(status).toHaveTextContent('Target: 0.2.1') - await userEvent.click(screen.getAllByRole('button', { name: 'Update Operator' })[0]) + await userEvent.click(screen.getByRole('button', { name: 'Update Operator' })) expect(mockUpdateOperator).toHaveBeenCalledWith({ organizationId: 'org-123', @@ -94,6 +96,17 @@ describe('ProfileOperatorFooter', () => { }) }) + it('reaches the update action from the keyboard', async () => { + mockUseClusterOperatorStatus.mockReturnValue({ data: operatorStatus, isLoading: false }) + const { userEvent } = renderWithProviders() + + screen.getByRole('button', { name: 'Qovery Operator: Update available' }).focus() + await userEvent.keyboard('{Enter}') + await userEvent.tab() + + expect(screen.getByRole('button', { name: 'Update Operator' })).toHaveFocus() + }) + it('hides the update action while the Operator is disconnected', async () => { mockUseClusterOperatorStatus.mockReturnValue({ data: { ...operatorStatus, operator_connected: false, status: 'DISCONNECTED' }, @@ -101,9 +114,9 @@ describe('ProfileOperatorFooter', () => { }) const { userEvent } = renderWithProviders() - await userEvent.hover(screen.getByRole('button', { name: 'Qovery Operator: Disconnected' })) + await userEvent.click(screen.getByRole('button', { name: 'Qovery Operator: Disconnected' })) - expect((await screen.findAllByText('No recent heartbeat was received from the Operator.'))[0]).toBeInTheDocument() + expect(await screen.findByText('No recent heartbeat was received from the Operator.')).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Update Operator' })).not.toBeInTheDocument() }) }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.tsx index 33b2e9cfd71..c9ae44b6b1b 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-operator-footer.tsx @@ -1,5 +1,5 @@ import { useParams } from '@tanstack/react-router' -import { Icon, LoaderSpinner, Tooltip } from '@qovery/shared/ui' +import { Icon, LoaderSpinner, Popover, Tooltip } from '@qovery/shared/ui' import { ClusterOperatorStatus, getClusterOperatorStatusDisplay, @@ -18,6 +18,7 @@ export function ProfileOperatorFooter() { organizationId, clusterId, refetchInterval: OPERATOR_STATUS_REFRESH_INTERVAL, + stopPollingWhenMissing: true, }) // Clusters without Operator state (q-core answers 404) have no Operator to describe. @@ -50,17 +51,14 @@ export function ProfileOperatorFooter() { ) } + // The panel holds an action: a popover keeps it reachable by keyboard, unlike a tooltip. return operatorStatus ? ( - } - > - {trigger} - + + {trigger} + + + + ) : ( trigger ) diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.spec.ts b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.spec.ts index ef4483bc894..e479d3b09c3 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.spec.ts +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.spec.ts @@ -43,4 +43,26 @@ describe('resolveProfileSelection', () => { expect(resolveProfileSelection(profileTree, requestedKey).component?.key).toBe('karpenter-configuration') } ) + + it('selects nothing when the only enabled layer has nothing to configure', () => { + const emptyTree = getProfileTree({ + ...template, + layers: [{ key: 'crds', components: [{ key: 'karpenter-crd', fields: [] }] }], + } as unknown as PlatformTemplateSummaryResponse) + + expect(emptyTree[0]).toMatchObject({ status: 'success', configurable: false }) + expect(resolveProfileSelection(emptyTree, undefined)).toEqual({ layer: undefined, component: undefined }) + expect(resolveProfileSelection(emptyTree, 'karpenter-crd')).toEqual({ layer: undefined, component: undefined }) + }) + + it('selects nothing when every layer is skipped or managed by Qovery', () => { + const lockedTree = getProfileTree(template, [ + { key: 'network', status: 'SKIPPED', reason: '', componentKeys: [] }, + { key: 'log-infra', status: 'DISABLED', reason: '', componentKeys: [] }, + { key: 'karpenter', status: 'DISABLED', reason: '', componentKeys: [] }, + ]) + + expect(resolveProfileSelection(lockedTree, undefined)).toEqual({ layer: undefined, component: undefined }) + expect(resolveProfileSelection(lockedTree, 'loki')).toEqual({ layer: undefined, component: undefined }) + }) }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.ts b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.ts index 9ee2166991b..c889bac00d5 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.ts +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-tree.ts @@ -94,12 +94,12 @@ function findProfileLayer(profileTree: ProfileTreeItem[], requestedKey?: string) // Skipped or Qovery-managed layers, and layers with nothing to configure, cannot be opened. const isOpenableLayer = (item: ProfileTreeItem) => item.status !== 'disabled' && item.configurable +// Nothing is selected when no layer can be opened. function getDefaultProfileComponent(profileTree: ProfileTreeItem[]) { return getFirstConfigurableComponent( ( profileTree.find((item) => item.label.toLowerCase() === 'log infra' && isOpenableLayer(item)) ?? - profileTree.find(isOpenableLayer) ?? - profileTree.find((item) => item.children?.length) + profileTree.find(isOpenableLayer) )?.children ) } diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.spec.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.spec.ts index 5f60195ced8..5c0a39133bf 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.spec.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.spec.ts @@ -10,9 +10,23 @@ jest.mock('@tanstack/react-query', () => ({ const mockUseQuery = useQuery as jest.Mock describe('useClusterOperatorStatus', () => { - it('stops polling once the cluster is known to have no Operator', () => { + it('keeps polling while the Operator state is missing by default', () => { renderHook(() => - useClusterOperatorStatus({ organizationId: 'org-123', clusterId: 'cluster-123', refetchInterval: 30_000 }) + useClusterOperatorStatus({ organizationId: 'org-123', clusterId: 'cluster-123', refetchInterval: 5_000 }) + ) + + const { refetchInterval } = mockUseQuery.mock.calls[0][0] + expect(refetchInterval(null)).toBe(5_000) + }) + + it('stops polling once the cluster is known to have no Operator when asked to', () => { + renderHook(() => + useClusterOperatorStatus({ + organizationId: 'org-123', + clusterId: 'cluster-123', + refetchInterval: 30_000, + stopPollingWhenMissing: true, + }) ) const { refetchInterval } = mockUseQuery.mock.calls[0][0] diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.ts b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.ts index 3033e90b260..8906d11076d 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-cluster-operator-status/use-cluster-operator-status.ts @@ -7,6 +7,8 @@ export interface UseClusterOperatorStatusProps { enabled?: boolean // Poll interval in milliseconds, disabled when unset. refetchInterval?: number + // Stop polling once the cluster is known to have no Operator state (404), instead of waiting for it to appear. + stopPollingWhenMissing?: boolean staleTime?: number } @@ -15,12 +17,15 @@ export function useClusterOperatorStatus({ clusterId, enabled = true, refetchInterval, + stopPollingWhenMissing = false, staleTime, }: UseClusterOperatorStatusProps) { return useQuery({ ...queries.clusters.operatorStatus({ organizationId, clusterId }), enabled: enabled && Boolean(organizationId) && Boolean(clusterId), - refetchInterval: refetchInterval ? (data) => (data === null ? false : refetchInterval) : undefined, + refetchInterval: refetchInterval + ? (data) => (stopPollingWhenMissing && data === null ? false : refetchInterval) + : undefined, staleTime, }) } diff --git a/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx index 909f569a29b..00ca9107a8a 100644 --- a/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx +++ b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.spec.tsx @@ -114,6 +114,19 @@ describe('CatalogVariableInput', () => { expect(screen.getByLabelText('Token')).toHaveAttribute('type', 'password') }) + it.each(['card', 'row'] as const)('masks sensitive number values in the %s layout', (layout) => { + renderWithProviders( + + ) + + expect(screen.getByLabelText('Pin')).toHaveAttribute('type', 'password') + }) + it('keeps the toggle presentation as the default for boolean fields', async () => { const onChange = jest.fn() const { userEvent } = renderWithProviders( diff --git a/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.tsx b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.tsx index a82715b565c..45198a16f91 100644 --- a/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.tsx +++ b/libs/shared/console-shared/src/lib/catalog-variable-input/catalog-variable-input.tsx @@ -102,7 +102,7 @@ function CatalogVariableControl({ id={field.key} label={field.label} className="w-full [&>div[data-testid=input]]:!h-10 [&>div[data-testid=input]]:!min-h-10" - type={field.type === 'number' ? 'number' : field.sensitive ? 'password' : 'text'} + type={field.sensitive ? 'password' : field.type === 'number' ? 'number' : 'text'} value={typeof value === 'string' ? value : ''} error={error} hasShowPasswordButton={field.sensitive} @@ -240,7 +240,7 @@ export function CatalogVariableInput({