Skip to content

Commit 4b37c27

Browse files
momesginMo Mesgin
andauthored
[ backport 2.13.6 ] chart multiple installation (#17242)
* backport chart multiple installation part 1 * backport required changes from issue 14817 - sorting versions * backport required changes from issue 13553 - action menu * latest ux/ui changes * add suffix to tooltips for mulitple installed case * backport changes for respecting version + styling + comment * revert unnecessary backporting action-invoked logic * add installNew event handler --------- Co-authored-by: Mo Mesgin <mmesgin@Mos-M2-MacBook-Pro.local>
1 parent 5fed7fa commit 4b37c27

13 files changed

Lines changed: 648 additions & 87 deletions

File tree

cypress/e2e/po/pages/explorer/charts/chart.po.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export class ChartPage extends PagePo {
3737
}
3838

3939
goToInstall() {
40-
const btn = new AsyncButtonPo('.chart-header .btn.role-primary');
40+
const btn = new AsyncButtonPo('[data-testid="btn-chart-install"]');
4141

4242
btn.checkVisible(MEDIUM_TIMEOUT_OPT);
4343
btn.click(true);

shell/assets/translations/en-us.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ generic:
130130
deprecated: Deprecated
131131
upgradeable: Upgradeable
132132
installed: Installed
133+
installedMultiple: Installed (multiple)
133134
featured: Featured
134135
shortFeatured: Feat
135136
category: Category
@@ -1135,12 +1136,15 @@ catalog:
11351136
deprecatedWarning: '{chartName} has been marked as deprecated. Use caution when installing this helm chart as it might be removed in the future.'
11361137
experimentalWarning: '{chartName} has been marked as experimental. Use caution when installing this helm chart as it might not function as expected.'
11371138
deprecatedAndExperimentalWarning: '{chartName} has been marked as deprecated and experimental. Use caution when installing this helm chart as it might be removed in the future and might not function as expected.'
1139+
installedAppsSelector:
1140+
ariaLabel: Select an installed instance to edit or upgrade
11381141
chartButton:
11391142
action:
11401143
install: Install this version
11411144
edit: Edit the current version
11421145
upgrade: Upgrade to this version
11431146
downgrade: Downgrade to this version
1147+
installNew: Install as new instance
11441148
charts:
11451149
browseAriaLabel: Show only charts grid
11461150
iconAlt: Icon for {app} card/grid item

shell/config/query-params.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const DEPRECATED = 'deprecated';
7272
export const HIDDEN = 'hidden';
7373
export const FROM_TOOLS = 'tools';
7474
export const FROM_CLUSTER = 'cluster';
75+
export const NEW_APP_INSTANCE = 'new-instance';
7576
export const HIDE_SIDE_NAV = 'hide-side-nav';
7677

7778
// Cluster provisioning

shell/mixins/__tests__/chart.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,5 +322,152 @@ describe('chartMixin', () => {
322322
icon: 'icon-upgrade-alt',
323323
});
324324
});
325+
326+
it('should return "upgrade" action when upgrading from a pre-release to a stable version with "up" build metadata', () => {
327+
const wrapper = mount(DummyComponent, {
328+
data: () => ({
329+
existing: { spec: { chart: { metadata: { version: '108.0.0+up0.25.0-rc.4' } } } },
330+
version: { version: '108.0.0+up0.25.0' }
331+
}),
332+
global: {
333+
mocks: {
334+
$store: mockStore,
335+
$route: { query: {} }
336+
}
337+
}
338+
});
339+
340+
expect(wrapper.vm.action).toStrictEqual({
341+
name: 'upgrade',
342+
tKey: 'upgrade',
343+
icon: 'icon-upgrade-alt',
344+
});
345+
});
346+
347+
it('should return "upgrade" action when upgrading with build metadata change', () => {
348+
const wrapper = mount(DummyComponent, {
349+
data: () => ({
350+
existing: { spec: { chart: { metadata: { version: '1.0.0+1' } } } },
351+
version: { version: '1.0.0+2' }
352+
}),
353+
global: {
354+
mocks: {
355+
$store: mockStore,
356+
$route: { query: {} }
357+
}
358+
}
359+
});
360+
361+
expect(wrapper.vm.action).toStrictEqual({
362+
name: 'upgrade',
363+
tKey: 'upgrade',
364+
icon: 'icon-upgrade-alt',
365+
});
366+
});
367+
});
368+
369+
describe('mappedVersions', () => {
370+
it('should return versions sorted by semver (descending)', () => {
371+
const versions = [
372+
{ version: '0.1.0', created: '2026-01-01' },
373+
{ version: '0.2.0-rc1', created: '2026-01-01' },
374+
{ version: '0.2.0', created: '2026-01-01' },
375+
{ version: '1.2.3', created: '2026-01-01' },
376+
{ version: '1.2.3-dev', created: '2026-01-01' },
377+
{ version: '10.0.0', created: '2026-01-01' },
378+
{ version: '2.0.0', created: '2026-01-01' },
379+
{ version: '2.0.0-rc2', created: '2026-01-01' },
380+
{ version: '2.0.0-rc1', created: '2026-01-01' },
381+
{ version: '2.0.0-beta.1', created: '2026-01-01' },
382+
{ version: '2.0.0-alpha', created: '2026-01-01' },
383+
{ version: '3.0.0-rc.3', created: '2026-01-01' },
384+
{ version: '3.0.0-rc.2', created: '2026-01-01' },
385+
{ version: '3.0.0-rc.10', created: '2026-01-01' },
386+
{ version: '108.0.0+up0.25.0-rc.4', created: '2026-01-01' },
387+
{ version: '108.0.0+up0.25.0', created: '2026-01-01' },
388+
{ version: '1.0.0-alpha.beta', created: '2026-01-01' },
389+
{ version: '1.0.0-alpha.1', created: '2026-01-01' },
390+
{ version: '1.0.0-alpha.2', created: '2026-01-01' },
391+
{ version: '1.0.0-alpha', created: '2026-01-01' },
392+
{ version: '1.0.0-beta.11', created: '2026-01-01' },
393+
{ version: '1.0.0-beta.2', created: '2026-01-01' },
394+
{ version: '1.0.0-beta', created: '2026-01-01' },
395+
{ version: '1.0.0+build.1', created: '2026-01-01' },
396+
{ version: '1.0.0+build.2', created: '2026-01-01' },
397+
{ version: '1.0.0+up1.0.0', created: '2026-01-01' },
398+
{ version: '1.0.0+upFoo', created: '2026-01-01' },
399+
{ version: '108.0.0+up0.25.0-rc.5', created: '2026-01-01' },
400+
{ version: '108.0.0+up0.25.1', created: '2026-01-01' },
401+
{ version: '0.0.1', created: '2026-01-01' }
402+
];
403+
404+
const mockStore = {
405+
dispatch: jest.fn(() => Promise.resolve()),
406+
getters: {
407+
currentCluster: () => {},
408+
isRancher: () => true,
409+
'catalog/repo': () => () => 'repo',
410+
'catalog/chart': () => ({ versions }),
411+
'prefs/get': () => (key: string) => true,
412+
'i18n/t': () => jest.fn()
413+
}
414+
};
415+
416+
const DummyComponent = {
417+
mixins: [ChartMixin],
418+
template: '<div></div>',
419+
};
420+
421+
const wrapper = mount(
422+
DummyComponent,
423+
{
424+
data() {
425+
return { chart: { versions } };
426+
},
427+
global: {
428+
mocks: {
429+
$store: mockStore,
430+
$route: { query: { version: '10.0.0' } }
431+
}
432+
}
433+
});
434+
435+
// mappedVersions is a computed property, so we access it directly
436+
const result = wrapper.vm.mappedVersions;
437+
const resultVersions = result.map((v: any) => v.version);
438+
439+
expect(resultVersions).toStrictEqual([
440+
'108.0.0+up0.25.1',
441+
'108.0.0+up0.25.0',
442+
'108.0.0+up0.25.0-rc.5',
443+
'108.0.0+up0.25.0-rc.4',
444+
'10.0.0',
445+
'3.0.0-rc.10',
446+
'3.0.0-rc.3',
447+
'3.0.0-rc.2',
448+
'2.0.0',
449+
'2.0.0-rc2',
450+
'2.0.0-rc1',
451+
'2.0.0-beta.1',
452+
'2.0.0-alpha',
453+
'1.2.3',
454+
'1.2.3-dev',
455+
'1.0.0+up1.0.0',
456+
'1.0.0+upFoo',
457+
'1.0.0+build.2',
458+
'1.0.0+build.1',
459+
'1.0.0-beta.11',
460+
'1.0.0-beta.2',
461+
'1.0.0-beta',
462+
'1.0.0-alpha.beta',
463+
'1.0.0-alpha.2',
464+
'1.0.0-alpha.1',
465+
'1.0.0-alpha',
466+
'0.2.0',
467+
'0.2.0-rc1',
468+
'0.1.0',
469+
'0.0.1'
470+
]);
471+
});
325472
});
326473
});

shell/mixins/chart.js

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import { NAME as MANAGER } from '@shell/config/product/manager';
1010
import { OPA_GATE_KEEPER_ID } from '@shell/pages/c/_cluster/gatekeeper/index.vue';
1111
import { formatSi, parseSi } from '@shell/utils/units';
1212
import { CAPI, CATALOG } from '@shell/config/types';
13-
import { isPrerelease, compare, isUpgradeFromPreToStable } from '@shell/utils/version';
13+
import { isPrerelease } from '@shell/utils/version';
14+
import { compareChartVersions } from '@shell/utils/chart';
1415
import difference from 'lodash/difference';
1516
import { LINUX, APP_UPGRADE_STATUS } from '@shell/store/catalog';
1617
import { clone } from '@shell/utils/object';
@@ -27,6 +28,17 @@ export default {
2728
ignoreWarning: false,
2829

2930
chart: null,
31+
32+
// Whether installing a new instance is allowed.
33+
// This is false when the chart is targeted (has fixed namespace/name annotations)
34+
// or when the URL query specifies a specific app to edit.
35+
canInstallNew: true,
36+
37+
// Installed instances of this chart that can be selected for edit/upgrade
38+
// on the chart detail page. When instances exist, `existing` is set to the
39+
// first one by default, but the user can select a different instance or
40+
// choose to install a new one.
41+
installedInstances: [],
3042
};
3143
},
3244

@@ -51,7 +63,12 @@ export default {
5163
},
5264

5365
mappedVersions() {
54-
const versions = this.chart?.versions || [];
66+
const versions = (this.chart?.versions || []).slice();
67+
68+
versions.sort((a, b) => {
69+
return compareChartVersions(b.version, a.version);
70+
});
71+
5572
const selectedVersion = this.targetVersion;
5673
const OSs = this.currentCluster?.workerOSs;
5774
const out = [];
@@ -240,13 +257,9 @@ export default {
240257
};
241258
}
242259

243-
if (isUpgradeFromPreToStable(this.currentVersion, this.targetVersion)) {
244-
return {
245-
name: 'upgrade', tKey: 'upgrade', icon: 'icon-upgrade-alt'
246-
};
247-
}
260+
const diff = compareChartVersions(this.currentVersion, this.targetVersion);
248261

249-
if (compare(this.currentVersion, this.targetVersion) < 0) {
262+
if (diff < 0) {
250263
return {
251264
name: 'upgrade', tKey: 'upgrade', icon: 'icon-upgrade-alt'
252265
};
@@ -374,6 +387,9 @@ export default {
374387
// If found, set the form to edit mode. If not, set the
375388
// form to create mode.
376389

390+
// This is a hard blocker - installing a new instance is NOT allowed.
391+
this.canInstallNew = false;
392+
377393
try {
378394
this.existing = await this.$store.dispatch('cluster/find', {
379395
type: CATALOG.APP,
@@ -398,6 +414,9 @@ export default {
398414
// Ask to install a special chart with fixed namespace/name
399415
// or edit it if there's an existing install.
400416

417+
// This is a hard blocker - installing a new instance is NOT allowed.
418+
this.canInstallNew = false;
419+
401420
try {
402421
this.existing = await this.$store.dispatch('cluster/find', {
403422
type: CATALOG.APP,
@@ -408,19 +427,33 @@ export default {
408427
this.mode = _CREATE;
409428
this.existing = null;
410429
}
411-
} else if (this.chart) {
412-
const matching = this.chart.matchingInstalledApps;
430+
} else {
431+
// Regular chart (not targeted) - check if there are installed instances.
432+
// Installing new instances IS allowed (canInstallNew remains true).
433+
const isChartDetailPage = this.$route.name === 'c-cluster-apps-charts-chart';
413434

414-
if (matching.length === 1) {
415-
this.existing = matching[0];
416-
this.mode = _EDIT;
435+
if (isChartDetailPage) {
436+
const matching = this.chart?.matchingInstalledApps || [];
437+
438+
// Always refresh the available instances so stale values are removed.
439+
this.installedInstances = [];
440+
441+
if (matching.length >= 1) {
442+
// Populate the instance selector and preserve the current selection
443+
// when it is still one of the matching installed apps.
444+
this.installedInstances = matching;
445+
const hasExistingMatch = this.existing?.id && matching.some((instance) => instance.id === this.existing.id);
446+
447+
if (!hasExistingMatch) {
448+
this.existing = matching[0];
449+
}
450+
} else {
451+
this.existing = null;
452+
}
417453
} else {
454+
// Regular create
418455
this.mode = _CREATE;
419456
}
420-
} else {
421-
// Regular create
422-
423-
this.mode = _CREATE;
424457
}
425458
}
426459
}, // End of fetchChart

shell/models/__tests__/chart.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,15 @@ describe('class Chart', () => {
166166
expect(chart.isInstalled).toBe(false);
167167
});
168168

169-
it('is false when multiple apps match', () => {
169+
it('is true when multiple apps match', () => {
170170
const app = makeInstalledApp();
171171

172172
app.spec.chart.metadata.version = '1.2.3';
173173
ctx.rootGetters['cluster/all'] = () => [app, app];
174174

175175
const chart = new Chart(base, ctx);
176176

177-
expect(chart.isInstalled).toBe(false);
177+
expect(chart.isInstalled).toBe(true);
178178
});
179179
});
180180

@@ -274,6 +274,25 @@ describe('class Chart', () => {
274274
expect(installedStatus?.tooltip?.text).toContain(installedApp.spec.chart.metadata.version);
275275
});
276276

277+
it('does not include version in installed tooltip when multiple instances exist', () => {
278+
const app1 = makeInstalledApp();
279+
const app2 = makeInstalledApp();
280+
281+
app2.spec.chart.metadata.version = '1.2.0';
282+
ctx.rootGetters['cluster/all'] = () => [app1, app2];
283+
284+
const chart = new Chart(base, ctx);
285+
286+
const result = chart.cardContent as CardContent;
287+
288+
const installedStatus = result.statuses.find((s) => s.tooltip?.text?.startsWith('generic.installedMultiple'));
289+
290+
expect(installedStatus).toBeDefined();
291+
expect(installedStatus?.color).toBe('success');
292+
// Should not contain version number when multiple instances
293+
expect(installedStatus?.tooltip?.text).toBe('generic.installedMultiple');
294+
});
295+
277296
it('includes upgradeable status when upgrade is available', () => {
278297
const installedApp = makeInstalledApp(APP_UPGRADE_STATUS.SINGLE_UPGRADE);
279298

0 commit comments

Comments
 (0)