Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 any project)

nameNsDescription:
name:
Expand Down
24 changes: 20 additions & 4 deletions shell/dialog/MoveNamespaceDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'],

Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
},

Expand All @@ -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();
Expand Down Expand Up @@ -128,7 +144,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
249 changes: 249 additions & 0 deletions shell/dialog/__tests__/MoveNamespaceDialog.test.ts
Original file line number Diff line number Diff line change
@@ -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<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: 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);
});
});
});