Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions shell/assets/translations/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4620,6 +4620,7 @@ moveModal:
description: 'You are moving the following namespaces:'
moveButtonLabel: Move
targetProject: Target Project
noProject: None (remove from project)

nameNsDescription:
name:
Expand Down
18 changes: 13 additions & 5 deletions shell/dialog/MoveNamespaceDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default {
return {
projects: [],
toMove: [],
targetProject: null
targetProject: ''
};
},

Expand All @@ -49,7 +49,7 @@ export default {
},

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,
Expand All @@ -59,6 +59,14 @@ export default {

return inCluster;
}, []);

// Add "None" option to allow removing namespace from project
options.unshift({
value: '',
label: this.t('moveModal.noProject')
});

return options;
}
},

Expand All @@ -69,10 +77,10 @@ export default {

async move(finish) {
const cluster = this.$store.getters['currentCluster'];
const clusterWithProjectId = `${ cluster.id }:${ this.targetProject }`;
const clusterWithProjectId = this.targetProject ? `${ cluster.id }:${ this.targetProject }` : null;

const promises = this.toMove.map((namespace) => {
namespace.setLabel(PROJECT, this.targetProject);
namespace.setLabel(PROJECT, this.targetProject || null);
namespace.setAnnotation(PROJECT, clusterWithProjectId);

return namespace.save();
Expand Down Expand Up @@ -128,7 +136,7 @@ export default {
<AsyncButton
:action-label="t('moveModal.moveButtonLabel')"
class="btn bg-primary ml-10"
:disabled="!targetProject"
:disabled="targetProject === null"
@click="move"
/>
</template>
Expand Down
223 changes: 223 additions & 0 deletions shell/dialog/__tests__/MoveNamespaceDialog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { shallowMount, VueWrapper } from '@vue/test-utils';
import MoveNamespaceDialog from '@shell/dialog/MoveNamespaceDialog.vue';

const t = (key: string): string => key;

describe('component: MoveNamespaceDialog', () => {
let wrapper: VueWrapper<any>;

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: '',
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');
});
});

describe('targetProject default value', () => {
it('should default to empty string (None option)', () => {
wrapper = mountComponent();

expect(wrapper.vm.targetProject).toBe('');
});
});

describe('move button disabled state', () => {
it('should be enabled when targetProject is empty string (None)', () => {
wrapper = mountComponent();
wrapper.vm.targetProject = '';

// 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 empty (None)', async() => {
const namespace = createMockNamespace('p-abc123');

wrapper = mountComponent({ resources: [namespace] });
await wrapper.vm.$options.fetch.call(wrapper.vm);

wrapper.vm.targetProject = '';

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 = '';

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 = '';

const finish = jest.fn();

await wrapper.vm.move(finish);

expect(finish).toHaveBeenCalledWith(false);
});
});
});
Loading