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); }