diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml index 22b7066c08d..5da6b38f272 100644 --- a/shell/assets/translations/en-us.yaml +++ b/shell/assets/translations/en-us.yaml @@ -3010,24 +3010,31 @@ fleet: notReady: Not Ready waitApplied: Wait Applied clusterTargets: - title: Select by name - label: Clusters advancedConfigs: Advanced target configurations are defined, check the YAML file for further details. - placeholders: - selectMultiple: Select Multiple Clusters + clusters: + title: Clusters + byName: + placeholder: Select Multiple Clusters + label: Select by cluster name + byLabel: + title: Select by labels + addSelector: Add cluster selector + labelKey: Label + clusterGroups: + title: Cluster Groups + byName: + label: Select by cluster group name + placeholder: Select Multiple Cluster Groups rules: - title: Select by labels - addSelector: Add cluster selector - labelKey: Label matching: title: Selected clusters - placeholder: Select clusters by name or labels + placeholder: Select clusters by name, labels or groups empty: No clusters in the workspace plusMore: |- {n, plural, =1 {+ 1 more cluster} other {+ {n, number} more clusters} - } + } application: pageTitle: App Bundles menuLabel: App Bundles diff --git a/shell/components/fleet/FleetClusterTargets/index.vue b/shell/components/fleet/FleetClusterTargets/index.vue index 70a5e8d1623..06ffe369c8e 100644 --- a/shell/components/fleet/FleetClusterTargets/index.vue +++ b/shell/components/fleet/FleetClusterTargets/index.vue @@ -22,7 +22,9 @@ export interface Cluster { interface DataType { targetMode: TargetMode, allClusters: any[], + allClusterGroups: any[], selectedClusters: string[], + selectedClusterGroups: string[], clusterSelectors: Selector[], key: number, } @@ -76,19 +78,26 @@ export default { allClusters: { inStoreType: 'management', type: FLEET.CLUSTER - } - }, this.$store) as { allClusters: any[] }; + }, + allClusterGroups: { + inStoreType: 'management', + type: FLEET.CLUSTER_GROUP + }, + }, this.$store) as { allClusters: any[], allClusterGroups: any[] }; this.allClusters = hash.allClusters || []; + this.allClusterGroups = hash.allClusterGroups || []; }, data(): DataType { return { - targetMode: 'all', - allClusters: [], - selectedClusters: [], - clusterSelectors: [], - key: 0 // Generates a unique key to handle Targets + targetMode: 'all', + allClusters: [], + allClusterGroups: [], + selectedClusters: [], + selectedClusterGroups: [], + clusterSelectors: [], + key: 0 // Generates a unique key to handle Targets }; }, @@ -96,10 +105,9 @@ export default { this.fromTargets(); if (this.mode === _CREATE) { - this.update(); - // Restore the targetMode from parent component; this is the case of edit targets in CREATE mode, go to YAML editor and come back to the form this.targetMode = this.created || 'all'; + this.update(); } else { this.targetMode = FleetUtils.Application.getTargetMode(this.targets || [], this.namespace); } @@ -153,6 +161,14 @@ export default { .map((x) => ({ label: x.nameDisplay, value: x.metadata.name })); }, + clusterGroupsOptions() { + return this.allClusterGroups + .filter((x) => x.metadata.namespace === this.namespace) + .map((x) => { + return { label: x.nameDisplay, value: x.metadata.name }; + }); + }, + isLocal() { return this.namespace === 'fleet-local'; }, @@ -178,6 +194,12 @@ export default { this.update(); }, + selectClusterGroups(list: string[]) { + this.selectedClusterGroups = list; + + this.update(); + }, + addMatchExpressions() { const neu = { key: this.key++ }; @@ -224,11 +246,15 @@ export default { clusterGroupSelector, } = target; - // If clusterGroup or clusterGroupSelector are defined, targets are marked as complex and won't handle by the UI - if (clusterGroup || clusterGroupSelector) { + // If clusterGroupSelector are defined, targets are marked as complex and won't handle by the UI + if (clusterGroupSelector) { return; } + if (clusterGroup) { + this.selectedClusterGroups.push(clusterGroup); + } + if (clusterName) { this.selectedClusters.push(clusterName); } @@ -254,20 +280,22 @@ export default { case 'all': return [excludeHarvesterRule]; case 'clusters': - return this.normalizeTargets(this.selectedClusters, this.clusterSelectors); + return this.normalizeTargets(this.selectedClusters, this.clusterSelectors, this.selectedClusterGroups); case 'advanced': case 'local': return this.targets; } }, - normalizeTargets(selected: string[], clusterMatchExpressions: Selector[]) { + normalizeTargets(selected: string[], clusterMatchExpressions: Selector[], selectedClusterGroups: string[]): Target[] | undefined { const targets: Target[] = []; + // Select by name selected.forEach((clusterName) => { targets.push({ clusterName }); }); + // Select by labels clusterMatchExpressions.forEach((elem) => { const { matchLabels: labels, matchExpressions: expressions } = elem || {}; @@ -311,6 +339,11 @@ export default { } }); + // Select by cluster group + selectedClusterGroups.forEach((clusterGroup) => { + targets.push({ clusterGroup }); + }); + if (targets.length) { return targets; } @@ -321,6 +354,7 @@ export default { reset() { this.targetMode = 'all'; this.selectedClusters = []; + this.selectedClusterGroups = []; this.clusterSelectors = []; } }, @@ -356,25 +390,25 @@ export default { >

- {{ t('fleet.clusterTargets.title') }} + {{ t('fleet.clusterTargets.clusters.title') }}

-
-

- {{ t('fleet.clusterTargets.rules.title') }} -

+
+

+ {{ t('fleet.clusterTargets.clusters.byLabel.title') }} +

- {{ t('fleet.clusterTargets.rules.addSelector') }} + {{ t('fleet.clusterTargets.clusters.byLabel.addSelector') }}
+
+

+ {{ t('fleet.clusterTargets.clusterGroups.title') }} +

+ +
{ }); }); }); + + describe('clusterGroup Functionality Tests', () => { + describe('clusterGroup Data Management', () => { + it('should initialize with empty selectedClusterGroups', () => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + expect(wrapper.vm.selectedClusterGroups).toStrictEqual([]); + }); + + it('should populate allClusterGroups from store data', async() => { + const mockClusterGroups = [ + { + metadata: { namespace: 'fleet-default', name: 'production-group' }, + nameDisplay: 'Production Group' + }, + { + metadata: { namespace: 'fleet-default', name: 'staging-group' }, + nameDisplay: 'Staging Group' + } + ]; + + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + wrapper.setData({ allClusterGroups: mockClusterGroups }); + await flushPromises(); + + expect(wrapper.vm.allClusterGroups).toStrictEqual(mockClusterGroups); + }); + + it('should filter clusterGroupsOptions by namespace', () => { + const mockClusterGroups = [ + { + metadata: { namespace: 'fleet-default', name: 'group-1' }, + nameDisplay: 'Group 1' + }, + { + metadata: { namespace: 'other-namespace', name: 'group-2' }, + nameDisplay: 'Group 2' + }, + { + metadata: { namespace: 'fleet-default', name: 'group-3' }, + nameDisplay: 'Group 3' + } + ]; + + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + wrapper.setData({ allClusterGroups: mockClusterGroups }); + + const filteredOptions = wrapper.vm.clusterGroupsOptions; + + expect(filteredOptions).toHaveLength(2); + expect(filteredOptions).toStrictEqual([ + { label: 'Group 1', value: 'group-1' }, + { label: 'Group 3', value: 'group-3' } + ]); + }); + }); + + describe('clusterGroup Selection Methods', () => { + it('should update selectedClusterGroups when selectClusterGroups is called', async() => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + const updateSpy = jest.spyOn(wrapper.vm, 'update'); + + wrapper.vm.selectClusterGroups(['group-1', 'group-2']); + await flushPromises(); + + expect(wrapper.vm.selectedClusterGroups).toStrictEqual(['group-1', 'group-2']); + expect(updateSpy).toHaveBeenCalledWith(); + }); + + it('should emit update:value when selectClusterGroups is called', async() => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + wrapper.vm.selectClusterGroups(['test-group']); + await flushPromises(); + + expect(wrapper.emitted('update:value')).toBeDefined(); + }); + + it('should handle empty array in selectClusterGroups', async() => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + // First set some groups + wrapper.vm.selectClusterGroups(['group-1', 'group-2']); + await flushPromises(); + + // Then clear them + wrapper.vm.selectClusterGroups([]); + await flushPromises(); + + expect(wrapper.vm.selectedClusterGroups).toStrictEqual([]); + }); + + it('should replace existing selectedClusterGroups on new selection', async() => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + // Initial selection + wrapper.vm.selectClusterGroups(['group-1', 'group-2']); + await flushPromises(); + + // Replace with new selection + wrapper.vm.selectClusterGroups(['group-3', 'group-4', 'group-5']); + await flushPromises(); + + expect(wrapper.vm.selectedClusterGroups).toStrictEqual(['group-3', 'group-4', 'group-5']); + }); + }); + + describe('clusterGroup Target Processing', () => { + it('should parse existing targets with clusterGroup in fromTargets', () => { + const targets = [ + { clusterGroup: 'production-group' }, + { clusterGroup: 'staging-group' }, + { clusterName: 'specific-cluster' } + ]; + + const wrapper = mount(FleetClusterTargets, { + props: { + targets, + namespace: 'fleet-default', + mode: _EDIT + } + }); + + expect(wrapper.vm.selectedClusterGroups).toStrictEqual(['production-group', 'staging-group']); + expect(wrapper.vm.selectedClusters).toStrictEqual(['specific-cluster']); + }); + + it('should include clusterGroups in normalizeTargets output', () => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + const result = wrapper.vm.normalizeTargets( + ['cluster-1'], + [{ matchLabels: { env: 'prod' } }], + ['group-1', 'group-2'] + ); + + expect(result).toStrictEqual([ + { clusterName: 'cluster-1' }, + { clusterSelector: { matchLabels: { env: 'prod' } } }, + { clusterGroup: 'group-1' }, + { clusterGroup: 'group-2' } + ]); + }); + + it('should handle only clusterGroups in normalizeTargets', () => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + const result = wrapper.vm.normalizeTargets([], [], ['group-1', 'group-2']); + + expect(result).toStrictEqual([ + { clusterGroup: 'group-1' }, + { clusterGroup: 'group-2' } + ]); + }); + + it('should return undefined when normalizeTargets has no inputs', () => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + const result = wrapper.vm.normalizeTargets([], [], []); + + expect(result).toBeUndefined(); + }); + + it('should include clusterGroups in toTargets when targetMode is clusters', () => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + wrapper.setData({ + targetMode: 'clusters', + selectedClusters: ['cluster-1'], + clusterSelectors: [], + selectedClusterGroups: ['group-1', 'group-2'] + }); + + const result = wrapper.vm.toTargets(); + + expect(result).toStrictEqual([ + { clusterName: 'cluster-1' }, + { clusterGroup: 'group-1' }, + { clusterGroup: 'group-2' } + ]); + }); + }); + + describe('clusterGroup Integration with Target Modes', () => { + it('should handle clusterGroup targets and set appropriate targetMode', () => { + const targets = [ + { clusterGroup: 'test-group' } + ]; + + const wrapper = mount(FleetClusterTargets, { + props: { + targets, + namespace: 'fleet-default', + mode: _EDIT + } + }); + + // ClusterGroup targets should be parsed correctly + expect(wrapper.vm.selectedClusterGroups).toStrictEqual(['test-group']); + }); + }); + + it('should handle mixed targets with clusterGroup, clusterName, and clusterSelector', () => { + const targets = [ + { clusterName: 'specific-cluster' }, + { clusterGroup: 'production-group' }, + { clusterSelector: { matchLabels: { env: 'staging' } } }, + { clusterGroup: 'development-group' } + ]; + + const wrapper = mount(FleetClusterTargets, { + props: { + targets, + namespace: 'fleet-default', + mode: _EDIT + } + }); + + expect(wrapper.vm.selectedClusters).toStrictEqual(['specific-cluster']); + expect(wrapper.vm.selectedClusterGroups).toStrictEqual(['production-group', 'development-group']); + expect(wrapper.vm.clusterSelectors).toHaveLength(1); + expect(wrapper.vm.clusterSelectors[0].matchLabels).toStrictEqual({ env: 'staging' }); + }); + + it('should reset selectedClusterGroups when reset method is called', () => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + // Set some cluster groups + wrapper.setData({ + targetMode: 'clusters', + selectedClusterGroups: ['group-1', 'group-2'], + selectedClusters: ['cluster-1'], + clusterSelectors: [{ key: 1 }] + }); + + // Call reset + wrapper.vm.reset(); + + expect(wrapper.vm.selectedClusterGroups).toStrictEqual([]); + expect(wrapper.vm.targetMode).toBe('all'); + expect(wrapper.vm.selectedClusters).toStrictEqual([]); + expect(wrapper.vm.clusterSelectors).toStrictEqual([]); + }); + }); + + describe('clusterGroup Event Handling and Updates', () => { + it('should emit correct targets when both clusters and clusterGroups are selected', async() => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _CREATE + } + }); + + // Set target mode and selections + wrapper.setData({ targetMode: 'clusters' }); + wrapper.vm.selectClusters(['cluster-1', 'cluster-2']); + await flushPromises(); + + wrapper.vm.selectClusterGroups(['group-1']); + await flushPromises(); + + const emittedValues = wrapper.emitted('update:value'); + const lastEmitted = emittedValues?.[emittedValues.length - 1][0]; + + expect(lastEmitted).toStrictEqual([ + { clusterName: 'cluster-1' }, + { clusterName: 'cluster-2' }, + { clusterGroup: 'group-1' } + ]); + }); + + it('should handle clusterGroup selection in CREATE mode with proper event emission', async() => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _CREATE + } + }); + + wrapper.setData({ targetMode: 'clusters' }); + wrapper.vm.selectClusterGroups(['create-group-1', 'create-group-2']); + await flushPromises(); + + const emittedValues = wrapper.emitted('update:value'); + + expect(emittedValues).toBeDefined(); + + const lastEmitted = emittedValues?.[emittedValues.length - 1][0]; + + expect(lastEmitted).toStrictEqual([ + { clusterGroup: 'create-group-1' }, + { clusterGroup: 'create-group-2' } + ]); + }); + + it('should update component state correctly when clusterGroups prop changes', async() => { + const initialTargets = [{ clusterGroup: 'initial-group' }]; + + const wrapper = mount(FleetClusterTargets, { + props: { + targets: initialTargets, + namespace: 'fleet-default', + mode: _EDIT + } + }); + + expect(wrapper.vm.selectedClusterGroups).toStrictEqual(['initial-group']); + + // Update props + const newTargets = [ + { clusterGroup: 'new-group-1' }, + { clusterGroup: 'new-group-2' } + ]; + + await wrapper.setProps({ targets: newTargets }); + + // Reset and then parse new targets to simulate component update + wrapper.vm.reset(); + wrapper.vm.fromTargets(); + + expect(wrapper.vm.selectedClusterGroups).toStrictEqual(['new-group-1', 'new-group-2']); + }); + }); + + describe('clusterGroup Edge Cases and Error Handling', () => { + it('should handle undefined clusterGroup in targets gracefully', () => { + const targets = [ + { clusterGroup: undefined }, + { clusterGroup: 'valid-group' }, + { clusterName: 'cluster-1' } + ]; + + const wrapper = mount(FleetClusterTargets, { + props: { + targets: targets as any, + namespace: 'fleet-default', + mode: _EDIT + } + }); + + expect(wrapper.vm.selectedClusterGroups).toStrictEqual(['valid-group']); + }); + + it('should handle empty string clusterGroup in targets', () => { + const targets = [ + { clusterGroup: '' }, + { clusterGroup: 'valid-group' } + ]; + + const wrapper = mount(FleetClusterTargets, { + props: { + targets: targets as any, + namespace: 'fleet-default', + mode: _EDIT + } + }); + + expect(wrapper.vm.selectedClusterGroups).toStrictEqual(['valid-group']); + }); + + it('should handle empty allClusterGroups data', () => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + wrapper.setData({ allClusterGroups: [] }); + + expect(() => wrapper.vm.clusterGroupsOptions).not.toThrow(); + expect(wrapper.vm.clusterGroupsOptions).toStrictEqual([]); + }); + + it('should handle clusterGroups with missing nameDisplay', () => { + const mockClusterGroups = [ + { + metadata: { namespace: 'fleet-default', name: 'group-1' } + // Missing nameDisplay + }, + { + metadata: { namespace: 'fleet-default', name: 'group-2' }, + nameDisplay: 'Group 2' + } + ]; + + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + wrapper.setData({ allClusterGroups: mockClusterGroups }); + + const options = wrapper.vm.clusterGroupsOptions; + + expect(options).toStrictEqual([ + { label: undefined, value: 'group-1' }, + { label: 'Group 2', value: 'group-2' } + ]); + }); + }); + + describe('clusterGroup Component Lifecycle', () => { + it('should preserve clusterGroup selections during component updates', async() => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _EDIT + } + }); + + // Set initial selection + wrapper.vm.selectClusterGroups(['persistent-group']); + await flushPromises(); + + // Trigger component update by changing namespace + await wrapper.setProps({ namespace: 'different-namespace' }); + await flushPromises(); + + // In EDIT mode, selections should be preserved unless explicitly reset + expect(wrapper.vm.selectedClusterGroups).toStrictEqual(['persistent-group']); + }); + + it('should clear clusterGroup selections on namespace change in CREATE mode', async() => { + const wrapper = mount(FleetClusterTargets, { + props: { + targets: [], + namespace: 'fleet-default', + mode: _CREATE + } + }); + + // Set initial selection + wrapper.vm.selectClusterGroups(['temp-group']); + await flushPromises(); + + // Mock the reset method call that happens on namespace change in CREATE mode + const resetSpy = jest.spyOn(wrapper.vm, 'reset'); + + await wrapper.setProps({ namespace: 'different-namespace' }); + + // Manually trigger reset to simulate the watcher behavior + wrapper.vm.reset(); + + expect(resetSpy).toHaveBeenCalledWith(); + expect(wrapper.vm.selectedClusterGroups).toStrictEqual([]); + }); + }); }); diff --git a/shell/utils/__tests__/fleet.test.ts b/shell/utils/__tests__/fleet.test.ts index eb901ce4702..230aa3cefad 100644 --- a/shell/utils/__tests__/fleet.test.ts +++ b/shell/utils/__tests__/fleet.test.ts @@ -123,10 +123,17 @@ describe('fx: util.getTargetMode', () => { expect(util.getTargetMode(targets, namespace)).toBe('clusters'); }); - it('should return "advanced" if one target has clusterGroup but others have clusterName or clusterSelector', () => { + it('should return "clusters" if one target has clusterGroup but others have clusterName or clusterSelector', () => { const targets = [{ clusterName: 'cluster-x' }, { clusterGroup: 'my-group' }, { clusterSelector: { matchLabels: { env: 'prod' } } }]; const namespace = 'ws1'; + expect(util.getTargetMode(targets, namespace)).toBe('clusters'); + }); + + it('should return "advanced" if one target has clusterGroupSelector but others have clusterName or clusterSelector', () => { + const targets = [{ clusterName: 'cluster-x' }, { clusterGroupSelector: {} }, { clusterSelector: { matchLabels: { env: 'prod' } } }]; + const namespace = 'ws1'; + expect(util.getTargetMode(targets, namespace)).toBe('advanced'); }); diff --git a/shell/utils/fleet.ts b/shell/utils/fleet.ts index 880dabcf9c8..0957209676c 100644 --- a/shell/utils/fleet.ts +++ b/shell/utils/fleet.ts @@ -64,11 +64,11 @@ class Application { clusterGroupSelector, } = target; - if (clusterGroup || clusterGroupSelector) { + if (clusterGroupSelector) { return 'advanced'; } - if (clusterName) { + if (clusterName || clusterGroup) { mode = 'clusters'; }