diff --git a/src/features/clusters/queries/getHarperVersionsQuery.test.ts b/src/features/clusters/queries/getHarperVersionsQuery.test.ts index fb537ef39..585ba0105 100644 --- a/src/features/clusters/queries/getHarperVersionsQuery.test.ts +++ b/src/features/clusters/queries/getHarperVersionsQuery.test.ts @@ -88,9 +88,10 @@ describe('dedupeHarperVersionsByTag', () => { }); describe('getHarperVersionsOptions', () => { - it('configures the query to hit the HarperVersions cache key without retrying', () => { - const options = getHarperVersionsOptions(); - expect(options.queryKey).toEqual(['HarperVersions']); + it('scopes the cache key to the organization (org-first) and does not retry', () => { + const options = getHarperVersionsOptions('org_123'); + // Org-first so it participates in org-scoped `invalidateQueries([organizationId])`. + expect(options.queryKey).toEqual(['org_123', 'HarperVersions']); expect(options.staleTime).toBe(60_000); expect(options.retry).toBe(false); }); @@ -108,10 +109,10 @@ describe('getHarperVersionsOptions', () => { } satisfies HarperVersionsResponse, }); - const options = getHarperVersionsOptions(); + const options = getHarperVersionsOptions('org_1'); const result = await (options.queryFn as () => Promise)(); - expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/'); + expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/', { params: { organizationId: 'org_1' } }); expect(result).toEqual({ name: 'Harper Versions', description: 'Available Harper versions', @@ -122,6 +123,25 @@ describe('getHarperVersionsOptions', () => { }); }); + it('passes the organizationId through axios params (axios handles encoding)', async () => { + mockedGet.mockResolvedValue({ + data: { + name: 'Harper Versions', + description: 'Available Harper versions', + value: [ + { name: 'stable', version: '5.1.21' }, + { name: 'deployed on prod-east', version: '5.0.8' }, + ], + } satisfies HarperVersionsResponse, + }); + + const options = getHarperVersionsOptions('org/1'); + const result = await (options.queryFn as () => Promise)(); + + expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/', { params: { organizationId: 'org/1' } }); + expect(tags(result.value)).toEqual(['5.1.21 stable', '5.0.8 deployed on prod-east']); + }); + it('surfaces (rather than swallows) a malformed response with no value array', async () => { // The endpoint's OpenAPI description is broken, so the `as HarperVersionsResponse` cast is not // schema-backed. If it ever returns a body without `value`, the queryFn throws — React Query's @@ -129,7 +149,7 @@ describe('getHarperVersionsOptions', () => { // fails safely rather than silently rendering an empty picker. mockedGet.mockResolvedValue({ data: { name: 'Harper Versions', description: '' } }); - const options = getHarperVersionsOptions(); + const options = getHarperVersionsOptions('org_1'); await expect((options.queryFn as () => Promise)()).rejects.toThrow(); }); diff --git a/src/features/clusters/queries/getHarperVersionsQuery.ts b/src/features/clusters/queries/getHarperVersionsQuery.ts index 7140bbb80..2bf6bbb26 100644 --- a/src/features/clusters/queries/getHarperVersionsQuery.ts +++ b/src/features/clusters/queries/getHarperVersionsQuery.ts @@ -45,9 +45,13 @@ export function dedupeHarperVersionsByTag(versions: HarperVersion[]): HarperVers return [...bestByVersion.values()]; } -async function getHarperVersions() { +async function getHarperVersions(organizationId: string) { // TODO: OpenAPI from CM is erroring, so this new endpoint isn't described. - const { data } = await apiClient.get(`/HarperVersions/` as any); + // The list is org-scoped: enterprise orgs also get the versions currently deployed on their + // clusters (labeled with the cluster) merged in server-side. + const { data } = await apiClient.get(`/HarperVersions/` as any, { + params: { organizationId }, + }); const response = data as HarperVersionsResponse; return { ...response, @@ -55,10 +59,13 @@ async function getHarperVersions() { } satisfies HarperVersionsResponse; } -export function getHarperVersionsOptions() { +export function getHarperVersionsOptions(organizationId: string) { return queryOptions({ - queryKey: ['HarperVersions'], - queryFn: getHarperVersions, + // Org-first, matching the sibling queries (getPlanTypesQuery, getRegionLocationsQuery, …) so + // org-scoped invalidations — `queryClient.invalidateQueries({ queryKey: [organizationId] })`, + // used after cluster ops that can change deployed versions — also refresh this list. + queryKey: [organizationId, 'HarperVersions'], + queryFn: () => getHarperVersions(organizationId), staleTime: 60_000, retry: false, }); diff --git a/src/features/clusters/upsert/index.tsx b/src/features/clusters/upsert/index.tsx index f9b6ae651..ca30713a3 100644 --- a/src/features/clusters/upsert/index.tsx +++ b/src/features/clusters/upsert/index.tsx @@ -73,12 +73,14 @@ export function UpsertCluster() { })); const { data: regionLocationsDedicated } = useQuery(getRegionLocationsOptions({ organizationId })); - const { data: newHarperVersions } = useQuery(getHarperVersionsOptions()); + const { data: newHarperVersions } = useQuery(getHarperVersionsOptions(organizationId)); const harperVersions = useMemo(() => { if (cluster) { const clusterVersions = cluster.instances?.map(i => i.version).filter(excludeFalsy); if (newHarperVersions && clusterVersions) { - const latestClusterVersion = clusterVersions.sort(compareVersions).pop(); + // Copy before sort — sort mutates in place, and we reuse the full set below. + const latestClusterVersion = [...clusterVersions].sort(compareVersions).pop(); + const clusterVersionSet = new Set(clusterVersions); return { ...newHarperVersions, value: [ @@ -87,13 +89,12 @@ export function UpsertCluster() { version: latestClusterVersion, } as const, ...(newHarperVersions?.value || []).filter(v => { - // Is our version unique from the latest cluster version? - return latestClusterVersion !== v.version - // Do we have a cluster version? - && (!latestClusterVersion - // Or if we do, have we updated to a higher version already? - // This can prevent upgrading to, say, "next" v5, and then downgrading to the "latest" v4. - || wasAReleasedBeforeB(latestClusterVersion, v.version)); + // Drop any version this cluster already runs: the current version is shown once as + // "current" above, and the backend also returns it (and any co-tenant instance's + // version, mid-upgrade) as a "deployed on " entry we don't want to duplicate. + return !clusterVersionSet.has(v.version) + // Only offer newer releases — no downgrades (e.g. don't drop from "next" v5 to "stable" v4). + && (!latestClusterVersion || wasAReleasedBeforeB(latestClusterVersion, v.version)); }), ].filter(excludeFalsy), } satisfies HarperVersionsResponse;