From cca611d29fb7cd62803be8dbc41d109dfbd2adf4 Mon Sep 17 00:00:00 2001 From: Akanksha Trehun Date: Fri, 21 Aug 2026 11:46:54 +0530 Subject: [PATCH] fix(cluster-api): don't let an older copy clear a newer one's checkmark InlineSolutionPanel can show several quick-fix commands at once, each with its own copy button. handleCopy always cleared copiedCommand back to null after 1800ms, so copying command A then copying command B within that window meant A's timeout fired later and blanked B's checkmark early, even though B's own 1800ms hadn't elapsed yet. Only clear the state if it still matches the command that timeout was set for. Signed-off-by: Akanksha Trehun --- .../ClusterDetailsErrorOverview.test.tsx | 60 +++++++++++++++++++ .../overview/ClusterDetailsErrorOverview.tsx | 6 +- 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 cluster-api/src/components/overview/ClusterDetailsErrorOverview.test.tsx diff --git a/cluster-api/src/components/overview/ClusterDetailsErrorOverview.test.tsx b/cluster-api/src/components/overview/ClusterDetailsErrorOverview.test.tsx new file mode 100644 index 0000000000..564ba3b221 --- /dev/null +++ b/cluster-api/src/components/overview/ClusterDetailsErrorOverview.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { InlineSolutionPanel } from './ClusterDetailsErrorOverview'; +import { ClusterPriorityError } from './clusterHealth'; + +vi.mock('@iconify/react', () => ({ + Icon: ({ icon }: { icon: string }) => , +})); + +vi.mock('./capiUtils', async importOriginal => ({ + ...(await importOriginal()), + copyToClipboard: vi.fn().mockResolvedValue(undefined), +})); + +const priorityError: ClusterPriorityError = { + errorDef: { + id: 'test-error', + title: 'Test error', + description: 'A test error', + severity: 'warning', + matcher: {}, + solution: { + steps: ['do a thing'], + quickFixCommands: [ + { description: 'Fix A', command: 'kubectl apply -f a.yaml' }, + { description: 'Fix B', command: 'kubectl apply -f b.yaml' }, + ], + }, + }, +} as ClusterPriorityError; + +function copyIconFor(command: string): string { + const row = screen.getByText(command).closest('div')?.parentElement; + return row!.querySelector('[data-icon]')!.getAttribute('data-icon')!; +} + +describe('InlineSolutionPanel copy feedback', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("does not clear a newer copy's checkmark when an older copy's timeout fires", async () => { + render(); + + const buttons = screen.getAllByRole('button'); + fireEvent.click(buttons[0]); // copy "Fix A" + await vi.advanceTimersByTimeAsync(1000); + fireEvent.click(buttons[1]); // copy "Fix B", within A's 1800ms window + + // A's original timeout fires now (1800ms after A's click); B's hasn't yet. + await vi.advanceTimersByTimeAsync(800); + + expect(copyIconFor('kubectl apply -f a.yaml')).toBe('mdi:content-copy'); + expect(copyIconFor('kubectl apply -f b.yaml')).toBe('mdi:check'); + }); +}); diff --git a/cluster-api/src/components/overview/ClusterDetailsErrorOverview.tsx b/cluster-api/src/components/overview/ClusterDetailsErrorOverview.tsx index b42168e178..6071fe09ea 100644 --- a/cluster-api/src/components/overview/ClusterDetailsErrorOverview.tsx +++ b/cluster-api/src/components/overview/ClusterDetailsErrorOverview.tsx @@ -70,7 +70,7 @@ function renderClusterHealthStatus(healthy: boolean, priorityError: ClusterPrior * @param props.open - Whether the panel is currently expanded or collapsed. * @returns A Collapse-wrapped React component with solution instructions. */ -function InlineSolutionPanel({ +export function InlineSolutionPanel({ priorityError, open, }: { @@ -85,7 +85,9 @@ function InlineSolutionPanel({ try { await copyToClipboard(text); setCopiedCommand(text); - setTimeout(() => setCopiedCommand(null), 1800); + // only clear if this timeout's command is still the one showing — + // otherwise it clobbers a more recent copy before its own 1800ms is up + setTimeout(() => setCopiedCommand(current => (current === text ? null : current)), 1800); } catch { setCopiedCommand(null); }