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);
+ });
+ });
+});