Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/features/clusters/ClustersList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ vi.mock(
vi.mock('@tanstack/react-router', () => ({
useParams: () => ({ organizationId: 'org-a' }),
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => <a href={to} {...props}>{children}</a>,
Navigate: ({ to }: { to: string }) => <p>Navigate {to}</p>,
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => <p>{replace ? 'Replace' : 'Push'} {to}</p>,
}));
vi.mock('@/components/SubNavMenu', () => ({ SubNavMenu: () => null }));
vi.mock(
Expand Down Expand Up @@ -109,10 +109,10 @@ describe('ClustersList', () => {
expect(screen.getByRole('link', { name: 'New Cluster' }).getAttribute('href')).toBe('/org-a/new-cluster');
});

it('preserves a saved cluster creation redirect', () => {
it('redirects a saved cluster draft in place, so Back skips the page that redirected (#1326)', () => {
state.clusters = [cluster('Production', 'RUNNING', 'US East')];
state.saved = { name: 'Draft' };
render(<ClustersList />);
expect(screen.getByText('Navigate /org-a/new-cluster')).toBeTruthy();
expect(screen.getByText('Replace /org-a/new-cluster')).toBeTruthy();
});
});
2 changes: 1 addition & 1 deletion src/features/clusters/ClustersList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function ClustersList() {
return <UpsertCluster />;
}
if (savedClusterState) {
return <Navigate to={`/${organizationId}/new-cluster`} />;
return <Navigate to={`/${organizationId}/new-cluster`} replace={true} />;
}

return (
Expand Down
5 changes: 5 additions & 0 deletions src/features/clusters/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

`lib/clusterListModel.ts` derives lifecycle categories, counts and filters from the same organization snapshot; its tests pin missing-data and self-hosted semantics. A running lifecycle is not a monitoring-health assertion. Regions combine resolved plan labels with available non-retired instance labels, deduplicating matches, because organization responses need not expand instances; the list resolves opaque ids through the existing organization-scoped region catalog once, without polling. Missing labels remain unreported without discarding other reported instance regions. Running classification uses the same activeClusterStatuses rule as card navigation; UPDATED remains attention until RUNNING is reported. The model still derives instance count and version when instance data is supplied, but the card no longer renders them: the organization response does not currently expand instances, so those two cells only ever showed a dash, and the Regions cell (real plan labels) went with the row. The list adds no per-card detail polling. Existing `ClusterProgress` retains its lifecycle polling behavior.

A saved cluster draft makes the list redirect to the form, and that redirect replaces its history entry (#1326). The
form clears the draft only once it mounts, so a pushed redirect left Back on the list, which pushed the form again for
as long as the form was still loading. Billing's Stripe return page replaces itself on the way back to the form for the
same reason: it strips the setup-intent secret from its own URL first, so going back to it would show a spinner forever.

SystemStatus notices have no cluster association and remain in the cloud-wide notification banner/center. Per-cluster monitoring incidents and scheduled maintenance need a scoped backend contract before the list can attribute them. The UI preserves ClusterCard's permission checks and lifecycle actions.

# Cluster configuration
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/** @vitest-environment jsdom */
import { cleanup, render, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ProcessSetupIntent } from './ProcessSetupIntent';

const state = vi.hoisted(() => ({
navigate: vi.fn(),
saved: null as unknown,
setupIntent: {} as { status: string; payment_method: string | null },
stripe: { retrieveSetupIntent: vi.fn() },
}));
Comment on lines +6 to +11

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The setupIntent property in the hoisted state is defined but never used in any of the tests. It can be safely removed to keep the test setup clean.

const state = vi.hoisted(() => ({
	navigate: vi.fn(),
	saved: null as unknown,
	stripe: { retrieveSetupIntent: vi.fn() },
}));

vi.mock('@stripe/react-stripe-js', () => ({ useStripe: () => state.stripe }));
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => state.navigate,
useParams: () => ({ organizationId: 'org-a' }),
useSearch: () => ({}),
}));
vi.mock('@/hooks/useLocalStorage', () => ({ useLocalStorage: () => [state.saved] }));
vi.mock('@/integrations/stripe/useProcessStripePaymentMethod', () => {
const process = (_paymentMethod: string, onProcessed: () => void) => onProcessed();
return { useProcessStripePaymentMethod: () => process };
});

afterEach(() => {
cleanup();
state.navigate.mockReset();
state.stripe.retrieveSetupIntent.mockReset();
state.saved = null;
});
Comment on lines +24 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent test pollution and ensure test isolation, reset the JSDOM window history/URL back to the root in the afterEach block. Since returnFromStripe modifies the global window.history state, not resetting it can lead to unexpected side effects or flakiness in other tests running in the same environment.

Suggested change
afterEach(() => {
cleanup();
state.navigate.mockReset();
state.stripe.retrieveSetupIntent.mockReset();
state.saved = null;
});
afterEach(() => {
cleanup();
state.navigate.mockReset();
state.stripe.retrieveSetupIntent.mockReset();
state.saved = null;
window.history.pushState(null, '', '/');
});


function returnFromStripe(setupIntent: { status: string; payment_method: string | null }) {
window.history.pushState(null, '', '/?setup_intent_client_secret=seti_secret#/org-a/billing/confirm');
state.stripe.retrieveSetupIntent.mockResolvedValue({ setupIntent });
render(<ProcessSetupIntent />);
}

describe('ProcessSetupIntent', () => {
it('replaces itself with the cluster draft, so Back from the form skips this page (#1326)', async () => {
state.saved = { clusterName: 'Draft' };
returnFromStripe({ status: 'succeeded', payment_method: 'pm_card' });

await waitFor(() => expect(state.navigate).toHaveBeenCalled());
expect(state.navigate).toHaveBeenCalledWith({ search: undefined, to: '../../new-cluster', replace: true });
expect(window.location.search).toBe('');
expect(window.location.hash).toBe('#/org-a/billing/confirm');
});

it('replaces itself on the way back to a cluster edit too', async () => {
state.saved = { clusterId: 'clu-a' };
returnFromStripe({ status: 'processing', payment_method: null });

await waitFor(() => expect(state.navigate).toHaveBeenCalled());
expect(state.navigate).toHaveBeenCalledWith({ search: undefined, to: '../../clu-a/edit', replace: true });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function ProcessSetupIntent() {
: '../../new-cluster'
: '../';
window.history.replaceState(null, '', currentUrlIncludingHash());
void navigate({ search: undefined, to });
void navigate({ search: undefined, to, replace: true });
}, [navigate, savedClusterState]);

useEffect(() => {
Expand Down
Loading