diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml index ccd59accf52..2e026d8a0bb 100644 --- a/shell/assets/translations/en-us.yaml +++ b/shell/assets/translations/en-us.yaml @@ -4620,6 +4620,7 @@ moveModal: description: 'You are moving the following namespaces:' moveButtonLabel: Move targetProject: Target Project + noProject: None (remove from any project) nameNsDescription: name: diff --git a/shell/dialog/MoveNamespaceDialog.vue b/shell/dialog/MoveNamespaceDialog.vue index e5aca4d0086..5d6048f0685 100644 --- a/shell/dialog/MoveNamespaceDialog.vue +++ b/shell/dialog/MoveNamespaceDialog.vue @@ -6,6 +6,8 @@ import LabeledSelect from '@shell/components/form/LabeledSelect'; import { MANAGEMENT } from '@shell/config/types'; import { PROJECT } from '@shell/config/labels-annotations'; +const NONE_VALUE = ' '; + export default { emits: ['close'], @@ -48,8 +50,12 @@ export default { return this.toMove.filter((namespace) => !!namespace.project).map((namespace) => namespace.project.shortId); }, + isAllInProject() { + return this.toMove.every((namespace) => !!namespace.project); + }, + projectOptions() { - return this.projects.reduce((inCluster, project) => { + const options = this.projects.reduce((inCluster, project) => { if (!this.excludedProjects.includes(project.shortId) && project.spec?.clusterName === this.currentCluster.id) { inCluster.push({ value: project.shortId, @@ -59,6 +65,16 @@ export default { return inCluster; }, []); + + // To be consistent with listed projects we should only provide the option if it applies too all of the namespaces + if (this.isAllInProject) { + options.unshift({ + value: NONE_VALUE, + label: this.t('moveModal.noProject') + }); + } + + return options; } }, @@ -69,10 +85,10 @@ export default { async move(finish) { const cluster = this.$store.getters['currentCluster']; - const clusterWithProjectId = `${ cluster.id }:${ this.targetProject }`; + const clusterWithProjectId = this.targetProject && this.targetProject !== NONE_VALUE ? `${ cluster.id }:${ this.targetProject }` : null; const promises = this.toMove.map((namespace) => { - namespace.setLabel(PROJECT, this.targetProject); + namespace.setLabel(PROJECT, this.targetProject && this.targetProject !== NONE_VALUE ? this.targetProject : null); namespace.setAnnotation(PROJECT, clusterWithProjectId); return namespace.save(); @@ -128,7 +144,7 @@ export default { diff --git a/shell/dialog/__tests__/MoveNamespaceDialog.test.ts b/shell/dialog/__tests__/MoveNamespaceDialog.test.ts new file mode 100644 index 00000000000..2373bf0dd4c --- /dev/null +++ b/shell/dialog/__tests__/MoveNamespaceDialog.test.ts @@ -0,0 +1,249 @@ +import { shallowMount, VueWrapper } from '@vue/test-utils'; +import MoveNamespaceDialog from '@shell/dialog/MoveNamespaceDialog.vue'; + +const t = (key: string): string => key; +const NONE_VALUE = ' '; + +describe('component: MoveNamespaceDialog', () => { + let wrapper: VueWrapper; + + const mockProjects = [ + { + shortId: 'p-abc123', + nameDisplay: 'Project A', + spec: { clusterName: 'local' } + }, + { + shortId: 'p-def456', + nameDisplay: 'Project B', + spec: { clusterName: 'local' } + }, + { + shortId: 'p-other', + nameDisplay: 'Other Cluster Project', + spec: { clusterName: 'other-cluster' } + } + ]; + + const createMockNamespace = (projectId: string | null = null) => { + const namespace: any = { + nameDisplay: 'test-namespace', + projectId, + project: projectId ? { shortId: projectId } : null, + setLabel: jest.fn(), + setAnnotation: jest.fn(), + save: jest.fn().mockResolvedValue({}), + }; + + return namespace; + }; + + const mountComponent = (propsData = {}, options = {}) => { + const store = { + dispatch: jest.fn().mockResolvedValue(mockProjects), + getters: { currentCluster: { id: 'local' } } + }; + + const defaultProps = { + resources: [createMockNamespace('p-abc123')], + movingCb: jest.fn(), + registerBackgroundClosing: jest.fn(), + }; + + return shallowMount(MoveNamespaceDialog, { + propsData: { + ...defaultProps, + ...propsData, + }, + global: { + mocks: { + $store: store, + t, + }, + }, + ...options, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + describe('projectOptions', () => { + it('should include "None" option as first item', async() => { + wrapper = mountComponent(); + await wrapper.vm.$options.fetch.call(wrapper.vm); + + const options = wrapper.vm.projectOptions; + + expect(options[0]).toStrictEqual({ + value: NONE_VALUE, + label: 'moveModal.noProject' + }); + }); + + it('should include projects from current cluster', async() => { + // Use a namespace not in any project so no projects get excluded + const namespace = createMockNamespace(null); + + wrapper = mountComponent({ resources: [namespace] }); + await wrapper.vm.$options.fetch.call(wrapper.vm); + + const options = wrapper.vm.projectOptions; + const projectLabels = options.map((o: any) => o.label); + + expect(projectLabels).toContain('Project A'); + expect(projectLabels).toContain('Project B'); + }); + + it('should exclude projects from other clusters', async() => { + wrapper = mountComponent(); + await wrapper.vm.$options.fetch.call(wrapper.vm); + + const options = wrapper.vm.projectOptions; + const projectLabels = options.map((o: any) => o.label); + + expect(projectLabels).not.toContain('Other Cluster Project'); + }); + + it('should exclude current project of namespaces being moved', async() => { + const namespace = createMockNamespace('p-abc123'); + + wrapper = mountComponent({ resources: [namespace] }); + await wrapper.vm.$options.fetch.call(wrapper.vm); + + const options = wrapper.vm.projectOptions; + const projectValues = options.map((o: any) => o.value); + + expect(projectValues).not.toContain('p-abc123'); + expect(projectValues).toContain('p-def456'); + }); + + it('should NOT include "None" option when some namespaces are not in a project', async() => { + const namespaceInProject = createMockNamespace('p-abc123'); + const namespaceNotInProject = createMockNamespace(null); + + wrapper = mountComponent({ resources: [namespaceInProject, namespaceNotInProject] }); + await wrapper.vm.$options.fetch.call(wrapper.vm); + + const options = wrapper.vm.projectOptions; + const optionValues = options.map((o: any) => o.value); + + expect(optionValues).not.toContain(NONE_VALUE); + }); + + it('should NOT include "None" option when no namespaces are in a project', async() => { + const namespace = createMockNamespace(null); + + wrapper = mountComponent({ resources: [namespace] }); + await wrapper.vm.$options.fetch.call(wrapper.vm); + + const options = wrapper.vm.projectOptions; + const optionValues = options.map((o: any) => o.value); + + expect(optionValues).not.toContain(NONE_VALUE); + }); + }); + + describe('targetProject default value', () => { + it('should default to empty string (None option)', () => { + wrapper = mountComponent(); + + expect(wrapper.vm.targetProject).toBeNull(); + }); + }); + + describe('move button disabled state', () => { + it('should be enabled when targetProject is NONE_VALUE (None)', () => { + wrapper = mountComponent(); + wrapper.vm.targetProject = NONE_VALUE; + + // The button should be enabled when targetProject !== null + expect(wrapper.vm.targetProject === null).toBe(false); + }); + + it('should be enabled when targetProject is a project id', () => { + wrapper = mountComponent(); + wrapper.vm.targetProject = 'p-def456'; + + expect(wrapper.vm.targetProject === null).toBe(false); + }); + }); + + describe('move method', () => { + it('should clear labels and annotations when targetProject is NONE_VALUE (None)', async() => { + const namespace = createMockNamespace('p-abc123'); + + wrapper = mountComponent({ resources: [namespace] }); + await wrapper.vm.$options.fetch.call(wrapper.vm); + + wrapper.vm.targetProject = NONE_VALUE; + + const finish = jest.fn(); + + await wrapper.vm.move(finish); + + expect(namespace.setLabel).toHaveBeenCalledWith('field.cattle.io/projectId', null); + expect(namespace.setAnnotation).toHaveBeenCalledWith('field.cattle.io/projectId', null); + expect(namespace.save).toHaveBeenCalledWith(); + expect(finish).toHaveBeenCalledWith(true); + }); + + it('should set labels and annotations when moving to a project', async() => { + const namespace = createMockNamespace('p-abc123'); + + wrapper = mountComponent({ resources: [namespace] }); + await wrapper.vm.$options.fetch.call(wrapper.vm); + + wrapper.vm.targetProject = 'p-def456'; + + const finish = jest.fn(); + + await wrapper.vm.move(finish); + + expect(namespace.setLabel).toHaveBeenCalledWith('field.cattle.io/projectId', 'p-def456'); + expect(namespace.setAnnotation).toHaveBeenCalledWith('field.cattle.io/projectId', 'local:p-def456'); + expect(namespace.save).toHaveBeenCalledWith(); + expect(finish).toHaveBeenCalledWith(true); + }); + + it('should handle multiple namespaces', async() => { + const namespace1 = createMockNamespace('p-abc123'); + const namespace2 = createMockNamespace('p-abc123'); + + wrapper = mountComponent({ resources: [namespace1, namespace2] }); + await wrapper.vm.$options.fetch.call(wrapper.vm); + + wrapper.vm.targetProject = NONE_VALUE; + + const finish = jest.fn(); + + await wrapper.vm.move(finish); + + expect(namespace1.setLabel).toHaveBeenCalledWith('field.cattle.io/projectId', null); + expect(namespace1.setAnnotation).toHaveBeenCalledWith('field.cattle.io/projectId', null); + expect(namespace2.setLabel).toHaveBeenCalledWith('field.cattle.io/projectId', null); + expect(namespace2.setAnnotation).toHaveBeenCalledWith('field.cattle.io/projectId', null); + expect(namespace1.save).toHaveBeenCalledWith(); + expect(namespace2.save).toHaveBeenCalledWith(); + }); + + it('should call finish with false when save fails', async() => { + const namespace = createMockNamespace('p-abc123'); + + jest.spyOn(namespace, 'save').mockImplementation().mockRejectedValue(new Error('Save failed')); + wrapper = mountComponent({ resources: [namespace] }); + await wrapper.vm.$options.fetch.call(wrapper.vm); + + wrapper.vm.targetProject = NONE_VALUE; + + const finish = jest.fn(); + + await wrapper.vm.move(finish); + + expect(finish).toHaveBeenCalledWith(false); + }); + }); +});