From 1da98ecb114a9ca9387fbbd41320babaec56d75f Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 2 Oct 2026 17:09:58 +0200 Subject: [PATCH 01/19] fix(clusters): keep profile edits made while saving Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cluster-profile-context.tsx | 17 +++++++++++++--- .../cluster-profile/cluster-profile.spec.tsx | 20 +++++++++++++++++++ 2 files changed, 34 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 58038318120..0826c5c5353 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 @@ -62,6 +62,17 @@ function countProfileChanges( return profileChanges.length + clusterInputChanges.length } +function omitSubmittedValues>>(currentValues: T, submittedValues: T) { + return Object.fromEntries( + Object.entries(currentValues).flatMap(([componentKey, values]) => { + const remainingValues = Object.fromEntries( + Object.entries(values).filter(([key, value]) => !equal(value, submittedValues[componentKey]?.[key])) + ) + return Object.keys(remainingValues).length ? [[componentKey, remainingValues]] : [] + }) + ) as T +} + // PUT replaces the whole configuration: unedited components and inputs are sent back as saved. function getConfigurationRequest( template: PlatformTemplateSummaryResponse, @@ -148,9 +159,9 @@ export function ClusterProfileProvider({ children }: PropsWithChildren) { clusterId, configurationRequest: getConfigurationRequest(selectedTemplate, configuration, profileValues, clusterInputs), }) - // The saved configuration now carries the edits, so the local copies can go. - setProfileValues({}) - setClusterInputs({}) + // The saved configuration now carries the submitted edits, so their local copies can go. + setProfileValues((currentValues) => omitSubmittedValues(currentValues, profileValues)) + setClusterInputs((currentValues) => omitSubmittedValues(currentValues, clusterInputs)) } const saveChanges = async () => { 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 6b06985bb46..76594f890fc 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 @@ -767,6 +767,26 @@ describe('ClusterProfileFeature', () => { expect(mockDeployCluster).not.toHaveBeenCalled() }) + it('keeps the edits made while saving', async () => { + let resolveSave = () => undefined as unknown + mockUpdatePlatformConfiguration.mockReturnValue(new Promise((resolve) => (resolveSave = resolve))) + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('switch', { name: 'High availability' })) + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + await userEvent.clear(screen.getByRole('spinbutton', { name: 'Retention period' })) + await userEvent.type(screen.getByRole('spinbutton', { name: 'Retention period' }), '24') + await act(async () => { + resolveSave() + }) + + expect(screen.getByRole('spinbutton', { name: 'Retention period' })).toHaveValue(24) + expect(screen.getByRole('switch', { name: 'High availability' })).not.toBeChecked() + expect( + within(screen.getByRole('region', { name: 'Unsaved profile changes' })).getByText(/1 change/) + ).toBeInTheDocument() + }) + it('deploys the cluster once the changes are saved', async () => { const { userEvent } = renderWithProviders() From 79eccbca1abb7cc31f618762cdc6080dc886091a Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 2 Oct 2026 17:10:55 +0200 Subject: [PATCH 02/19] fix(clusters): redirect only once when the Operator connects Co-Authored-By: Claude Opus 5.5 (1M context) --- ...elf-managed-cluster-creation-flow.spec.tsx | 25 +++++++++++++++++++ .../self-managed-cluster-creation-flow.tsx | 8 ++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.spec.tsx b/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.spec.tsx index e61cefba221..035d0616ce5 100644 --- a/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.spec.tsx @@ -1,5 +1,6 @@ import download from 'downloadjs' import { CloudProviderEnum } from 'qovery-typescript-axios' +import { useState } from 'react' import selectEvent from 'react-select-event' import * as cloudProvidersDomain from '@qovery/domains/cloud-providers/feature' import { renderWithProviders, screen, waitFor, within } from '@qovery/shared/util-tests' @@ -437,6 +438,30 @@ describe('SelfManagedClusterCreationFlow', () => { expect(refetch).toHaveBeenCalled() }) + it('redirects only once while the parent keeps re-rendering', async () => { + function ClusterCreationWithParentState() { + const [, setCloseCount] = useState(0) + return ( + { + mockOnClose() + setCloseCount((count) => count + 1) + }} + /> + ) + } + const { userEvent } = renderWithProviders() + + await goToOperatorStep(userEvent) + mockUseClusterOperatorStatus.mockReturnValue({ data: { operator_connected: true } }) + await userEvent.click(screen.getByRole('button', { name: 'Continue' })) + + await waitFor(() => expect(mockNavigate).toHaveBeenCalled()) + expect(mockOnClose).toHaveBeenCalledTimes(1) + expect(mockNavigate).toHaveBeenCalledTimes(1) + }) + it('redirects to the cluster profile once the Operator is connected', async () => { const { userEvent } = renderWithProviders( diff --git a/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.tsx b/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.tsx index 3388e12d8f9..935aa57e30c 100644 --- a/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.tsx +++ b/libs/domains/clusters/feature/src/lib/self-managed-cluster-creation/self-managed-cluster-creation-flow.tsx @@ -7,7 +7,7 @@ import { type PlatformTemplateComponentResponse, type SelfManagedClusterPlatformSelection, } from 'qovery-typescript-axios' -import { type ReactNode, useEffect, useMemo, useState } from 'react' +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import { Controller, FormProvider, useForm } from 'react-hook-form' import { useCloudProviders } from '@qovery/domains/cloud-providers/feature' import { @@ -357,8 +357,12 @@ function InstallStep({ refetchInterval: OPERATOR_STATUS_POLL_INTERVAL, }) + const hasNotifiedInstallRef = useRef(false) + useEffect(() => { - if (operatorStatus?.operator_connected) onInstalled() + if (!operatorStatus?.operator_connected || hasNotifiedInstallRef.current) return + hasNotifiedInstallRef.current = true + onInstalled() }, [onInstalled, operatorStatus?.operator_connected]) return ( From 3cca8df545041a33883207a66cfff66c9322079b Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 2 Oct 2026 17:11:54 +0200 Subject: [PATCH 03/19] fix(clusters): track the item modal however it is dismissed Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cluster-profile/cluster-profile.spec.tsx | 30 +++++++++++++++++++ .../profile-array-item-modal.tsx | 5 ++++ .../profile-configuration-field.tsx | 10 +++---- 3 files changed, 39 insertions(+), 6 deletions(-) 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 76594f890fc..c1e02f5f0e4 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 @@ -5,6 +5,7 @@ import { type PlatformTemplateSummaryResponse, } from 'qovery-typescript-axios' import { useState } from 'react' +import { useModal } from '@qovery/shared/ui' import { act, renderWithProviders, screen, waitFor, within } from '@qovery/shared/util-tests' import { useCluster } from '../hooks/use-cluster/use-cluster' import { useDeployCluster } from '../hooks/use-deploy-cluster/use-deploy-cluster' @@ -476,6 +477,35 @@ describe('ClusterProfileFeature', () => { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) }) + it('does not close another modal after the item modal was dismissed', async () => { + let hideProfile: () => void = () => undefined + function ClusterProfileWithOtherModal() { + const [isShown, setIsShown] = useState(true) + const { openModal } = useModal() + hideProfile = () => setIsShown(false) + return ( + <> + + {isShown ? : null} + + ) + } + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Add item to Client-validation CA certificates' })) + expect(await screen.findByRole('dialog')).toBeInTheDocument() + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: 'Open other modal' })) + expect(await screen.findByText('Other modal')).toBeInTheDocument() + act(() => hideProfile()) + + expect(screen.getByText('Other modal')).toBeInTheDocument() + }) + it('disables the add button once the item limit is reached', () => { mockUsePlatformConfiguration.mockReturnValue({ data: { 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 0d64fdd3fab..c202e44e05e 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 @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { Controller, FormProvider, useForm } from 'react-hook-form' import { CatalogVariableInput } from '@qovery/shared/console-shared' import { ModalCrud } from '@qovery/shared/ui' @@ -20,6 +21,7 @@ export interface ProfileArrayItemModalProps { values: Record isEdit: boolean onClose: () => void + onUnmount?: () => void onSubmit: (values: Record) => void } @@ -33,8 +35,11 @@ export function ProfileArrayItemModal({ values, isEdit, onClose, + onUnmount, onSubmit, }: ProfileArrayItemModalProps) { + useEffect(() => onUnmount, [onUnmount]) + const methods = useForm>({ mode: 'onChange', defaultValues: Object.fromEntries( diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-configuration-field.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-configuration-field.tsx index 14eaae7358d..b0d3bbc9399 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/profile-configuration-field.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/profile-configuration-field.tsx @@ -165,11 +165,6 @@ function ProfileArrayField({ const canRemove = typeof minItems !== 'number' || items.length > minItems const error = getFieldViolation(violations, path) - const closeItemModal = () => { - isItemModalOpenRef.current = false - closeModal() - } - useEffect( () => () => { if (isItemModalOpenRef.current) closeModal() @@ -195,7 +190,10 @@ function ProfileArrayField({ fields={itemFields} values={itemValues} isEdit={isEdit} - onClose={closeItemModal} + onClose={closeModal} + onUnmount={() => { + isItemModalOpenRef.current = false + }} onSubmit={(submittedValues) => { // Keep keys the modal does not edit (non-scalar or unevaluated fields) on existing object items. const nextItem = isObjectItem From 0f8101bc9589c12dd91f88ef4b44e1d899deab43 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 2 Oct 2026 17:12:50 +0200 Subject: [PATCH 04/19] perf(clusters): only debounce profile edits, not component switches Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cluster-profile/cluster-profile.spec.tsx | 24 ++++++++++++++++++- .../lib/cluster-profile/cluster-profile.tsx | 9 ++++--- 2 files changed, 29 insertions(+), 4 deletions(-) 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 c1e02f5f0e4..a4a128d9cc7 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 @@ -16,9 +16,10 @@ import { useUpdatePlatformConfiguration } from '../platform-configuration/hooks/ import { ClusterProfileFeature } from './cluster-profile' jest.mock('@tanstack/react-router', () => ({ useParams: jest.fn() })) +const mockUseDebounce = jest.fn((value: T) => value) jest.mock('@qovery/shared/util-hooks', () => ({ ...jest.requireActual('@qovery/shared/util-hooks'), - useDebounce: (value: T) => value, + useDebounce: (value: T) => mockUseDebounce(value), })) jest.mock('@qovery/shared/ui', () => { const React = jest.requireActual('react') @@ -242,6 +243,7 @@ function createComponentQueries(componentKeys: string[], isFetching = false, isE describe('ClusterProfileFeature', () => { beforeEach(() => { jest.clearAllMocks() + mockUseDebounce.mockImplementation((value: T) => value) mockUseParams.mockReturnValue({ organizationId: 'organization-id', clusterId: 'cluster-id' }) mockUseCluster.mockReturnValue({ data: { cloud_provider: 'AWS', kubernetes: 'SELF_MANAGED' }, @@ -408,6 +410,26 @@ describe('ClusterProfileFeature', () => { expect(onActiveComponentChange).toHaveBeenCalledWith('loki') }) + it('resolves another component without waiting for the edit debounce', async () => { + // A debounce that never settles: only edits may wait for it. + mockUseDebounce.mockImplementation((value: T) => jest.requireActual('react').useRef(value).current) + mockUsePlatformComponentConfigurations.mockImplementation(({ requests }) => + createComponentQueries(Object.keys(requests)) + ) + function ClusterProfileWithNavigation() { + const [componentKey, setComponentKey] = useState() + return + } + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Envoy' })) + + expect(mockUsePlatformComponentConfigurations).toHaveBeenLastCalledWith( + expect.objectContaining({ requests: { envoy: expect.anything() } }) + ) + expect(screen.queryByRole('status', { name: 'Loading configuration' })).not.toBeInTheDocument() + }) + it('moves between the component tabs with the arrow keys', async () => { const onActiveComponentChange = jest.fn() const { userEvent } = renderWithProviders( 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 5c9284cd80d..ed163928539 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 @@ -351,12 +351,15 @@ function ClusterProfileView({ [profileConfigs, resolvedClusterInputs, resolvedComponents] ) const debouncedPreviewRequests = useDebounce(previewRequests, 300) + const settledPreviewRequests = equal(Object.keys(debouncedPreviewRequests), Object.keys(previewRequests)) + ? debouncedPreviewRequests + : previewRequests const componentQueries = usePlatformComponentConfigurations({ clusterId, - requests: debouncedPreviewRequests, + requests: settledPreviewRequests, enabled: Boolean(resolvedComponents.length), }) - const requestedComponentKeys = Object.keys(debouncedPreviewRequests) + const requestedComponentKeys = Object.keys(settledPreviewRequests) const componentQueriesByKey = Object.fromEntries( requestedComponentKeys.flatMap((componentKey, index) => { const query = componentQueries[index] @@ -417,7 +420,7 @@ function ClusterProfileView({ const isBackgroundResolverError = hasResolverError && hasResolvedConfiguration // Saving needs every displayed or edited component resolved for the latest edits, with no violation or missing input. const isConfigurationReady = - equal(debouncedPreviewRequests, previewRequests) && + equal(settledPreviewRequests, previewRequests) && Object.keys(previewRequests).every((componentKey) => { const query = componentQueriesByKey[componentKey] const preview = query?.data From f9a3eed571496f2dbe8056dc7240bc7251741e17 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Fri, 2 Oct 2026 17:13:28 +0200 Subject: [PATCH 05/19] fix(clusters): use the brand focus outline on profile sidebar buttons Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/lib/cluster-profile/cluster-profile-sidebar.tsx | 4 ++-- .../src/lib/cluster-profile/profile-operator-footer.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx index 72f10878696..51005b7ef60 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-profile/cluster-profile-sidebar.tsx @@ -62,7 +62,7 @@ function LayerSectionRow({ type="button" disabled={isDisabled} aria-current={isSelected && !selectedItemId ? 'page' : undefined} - className={`focus-visible:ring-brand flex h-8 w-full items-center gap-1.5 rounded px-3 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-inset disabled:cursor-not-allowed disabled:text-neutral-disabled ${ + className={`flex h-8 w-full items-center gap-1.5 rounded px-3 text-left text-sm outline-none focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-strong disabled:cursor-not-allowed disabled:text-neutral-disabled ${ isDisabled ? '' : `${isSelected ? 'text-neutral' : 'text-neutral-subtle'} hover:bg-surface-neutral-component hover:text-neutral` @@ -93,7 +93,7 @@ function LayerSectionRow({ type="button" disabled={isItemDisabled} aria-current={isItemSelected ? 'page' : undefined} - className={`focus-visible:ring-brand group flex h-7 w-full items-center gap-1.5 px-3 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-inset disabled:cursor-not-allowed disabled:text-neutral-disabled ${ + className={`group flex h-7 w-full items-center gap-1.5 px-3 text-left text-sm outline-none focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-strong disabled:cursor-not-allowed disabled:text-neutral-disabled ${ isItemDisabled ? '' : isItemSelected 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 162176c8cc8..89d05472308 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 @@ -27,7 +27,7 @@ export function ProfileOperatorFooter({ organizationId, clusterId }: { organizat