From 35813afdbdb748ce88b5447085dcc68f9e2cdc9a Mon Sep 17 00:00:00 2001 From: andrijapanicsb Date: Mon, 10 Aug 2026 08:29:01 +0200 Subject: [PATCH] ui: fix import network owner scope Signed-off-by: andrijapanicsb --- .../compute/wizard/MultiNetworkSelection.vue | 76 +++++++-- .../views/tools/ImportUnmanagedInstance.vue | 22 ++- .../wizard/MultiNetworkSelection.spec.js | 155 ++++++++++++++++++ .../tools/ImportUnmanagedInstance.spec.js | 55 +++++++ 4 files changed, 293 insertions(+), 15 deletions(-) create mode 100644 ui/tests/unit/views/compute/wizard/MultiNetworkSelection.spec.js create mode 100644 ui/tests/unit/views/tools/ImportUnmanagedInstance.spec.js diff --git a/ui/src/views/compute/wizard/MultiNetworkSelection.vue b/ui/src/views/compute/wizard/MultiNetworkSelection.vue index f593d0f3d91e..e7629bfb4bc4 100644 --- a/ui/src/views/compute/wizard/MultiNetworkSelection.vue +++ b/ui/src/views/compute/wizard/MultiNetworkSelection.vue @@ -107,6 +107,10 @@ export default { type: String, default: '' }, + projectid: { + type: String, + default: '' + }, selectionEnabled: { type: Boolean, default: true @@ -153,7 +157,8 @@ export default { ipAddresses: {}, indexNum: 1, sendValuesTimer: null, - accountNetworkUpdateTimer: null + networkUpdateTimer: null, + networkFetchSequence: 0 } }, computed: { @@ -181,6 +186,14 @@ export default { } } return null + }, + networkScope () { + return { + zoneId: this.zoneId, + domainid: this.domainid, + account: this.account, + projectid: this.projectid + } } }, watch: { @@ -191,25 +204,51 @@ export default { this.fetchNetworks() } }, - zoneId () { - this.fetchNetworks() - }, - account () { - clearTimeout(this.accountNetworkUpdateTimer) - this.accountNetworkUpdateTimer = setTimeout(() => { - if (this.account) { - this.fetchNetworks() - } - }, 750) + networkScope (newScope, oldScope) { + const accountChanged = oldScope && newScope.account !== oldScope.account + this.queueNetworkFetch(accountChanged && !newScope.projectid ? 750 : 0) } }, created () { this.fetchNetworks() }, + beforeUnmount () { + clearTimeout(this.sendValuesTimer) + clearTimeout(this.networkUpdateTimer) + this.networkFetchSequence++ + }, methods: { - fetchNetworks () { + clearNetworkSelection () { this.networks = [] + this.validNetworks = {} + this.unableToMatch = false + this.values = {} + this.ipAddresses = {} + this.ipAddressesEnabled = {} + this.selectedRowKeys = [] + clearTimeout(this.sendValuesTimer) + this.sendValues() + }, + queueNetworkFetch (delay) { + clearTimeout(this.networkUpdateTimer) + this.networkFetchSequence++ + this.clearNetworkSelection() if (!this.zoneId || this.zoneId.length === 0) { + this.loading = false + return + } + this.loading = true + if (delay > 0) { + this.networkUpdateTimer = setTimeout(() => this.fetchNetworks(), delay) + return + } + this.fetchNetworks() + }, + fetchNetworks () { + const fetchSequence = ++this.networkFetchSequence + this.clearNetworkSelection() + if (!this.zoneId || this.zoneId.length === 0) { + this.loading = false return } this.loading = true @@ -217,15 +256,26 @@ export default { zoneid: this.zoneId, listall: true } - if (this.domainid && this.account) { + if (this.projectid) { + params.projectid = this.projectid + } else if (this.domainid && this.account) { params.domainid = this.domainid params.account = this.account } getAPI('listNetworks', params).then(response => { + if (fetchSequence !== this.networkFetchSequence) { + return + } this.networks = response.listnetworksresponse.network || [] }).catch(() => { + if (fetchSequence !== this.networkFetchSequence) { + return + } this.networks = [] }).finally(() => { + if (fetchSequence !== this.networkFetchSequence) { + return + } this.orderNetworks() this.loading = false }) diff --git a/ui/src/views/tools/ImportUnmanagedInstance.vue b/ui/src/views/tools/ImportUnmanagedInstance.vue index ffa0d9344335..d2af47d19161 100644 --- a/ui/src/views/tools/ImportUnmanagedInstance.vue +++ b/ui/src/views/tools/ImportUnmanagedInstance.vue @@ -71,7 +71,7 @@ }" :loading="optionsLoading.domains" :placeholder="apiParams.domainid.description" - @change="val => { this.selectedDomainId = val }"> + @change="handleDomainChange"> @@ -101,7 +101,8 @@ return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0 }" :loading="optionsLoading.projects" - :placeholder="apiParams.projectid.description"> + :placeholder="apiParams.projectid.description" + @change="handleProjectChange"> @@ -369,6 +370,7 @@ :zoneId="cluster.zoneid" :domainid="form.domainid" :account="form.account" + :projectid="form.projectid" :selectionEnabled="false" :filterUnimplementedNetworks="true" :hypervisor="this.cluster.hypervisortype" @@ -963,6 +965,20 @@ export default { updateMultiNetworkOffering (data) { this.nicsNetworksMapping = data }, + handleDomainChange (domainId) { + this.selectedDomainId = domainId + this.updateFieldValue('account', undefined) + this.updateFieldValue('projectid', undefined) + this.nicsNetworksMapping = {} + }, + handleProjectChange (projectId) { + if (projectId) { + this.selectedDomainId = null + this.updateFieldValue('domainid', undefined) + this.updateFieldValue('account', undefined) + } + this.nicsNetworksMapping = {} + }, defaultTemplateType () { if (this.cluster.hypervisortype === 'VMware') { return 'auto' @@ -1435,6 +1451,8 @@ export default { this.form.forceconverttopool = false this.form.forcemstoimportvmfiles = false this.userModifiedVddkSetting = false + this.selectedDomainId = null + this.nicsNetworksMapping = {} this.resetStorageOptionsForConversion() }, closeAction () { diff --git a/ui/tests/unit/views/compute/wizard/MultiNetworkSelection.spec.js b/ui/tests/unit/views/compute/wizard/MultiNetworkSelection.spec.js new file mode 100644 index 000000000000..9114cc23c7a6 --- /dev/null +++ b/ui/tests/unit/views/compute/wizard/MultiNetworkSelection.spec.js @@ -0,0 +1,155 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { flushPromises, shallowMount } from '@vue/test-utils' + +import { getAPI } from '@/api' +import MultiNetworkSelection from '@/views/compute/wizard/MultiNetworkSelection' + +jest.mock('@/api', () => ({ + getAPI: jest.fn() +})) + +const responseWith = (...networks) => ({ + listnetworksresponse: { + count: networks.length, + network: networks + } +}) + +const network = id => ({ + id, + name: id, + displaytext: id, + state: 'Implemented', + type: 'Isolated' +}) + +const factory = (props = {}) => shallowMount(MultiNetworkSelection, { + global: { + mocks: { + $t: key => key + } + }, + props: { + items: [{ id: 'nic-1', name: 'nic-1' }], + zoneId: 'zone-1', + selectionEnabled: false, + ...props + } +}) + +describe('Views > compute > wizard > MultiNetworkSelection.vue', () => { + beforeEach(() => { + getAPI.mockReset() + getAPI.mockResolvedValue(responseWith(network('network-1'))) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('lists networks in the selected account and domain scope', async () => { + const wrapper = factory({ domainid: 'domain-1', account: 'account-1' }) + await flushPromises() + + expect(getAPI).toHaveBeenLastCalledWith('listNetworks', { + zoneid: 'zone-1', + listall: true, + domainid: 'domain-1', + account: 'account-1' + }) + wrapper.unmount() + }) + + it('uses project scope instead of account and domain scope', async () => { + const wrapper = factory({ + domainid: 'domain-1', + account: 'account-1', + projectid: 'project-1' + }) + await flushPromises() + + expect(getAPI).toHaveBeenLastCalledWith('listNetworks', { + zoneid: 'zone-1', + listall: true, + projectid: 'project-1' + }) + wrapper.unmount() + }) + + it('refetches networks when the selected domain changes', async () => { + const wrapper = factory({ domainid: 'domain-1', account: 'account-1' }) + await flushPromises() + + await wrapper.setProps({ domainid: 'domain-2' }) + await flushPromises() + + expect(getAPI).toHaveBeenLastCalledWith('listNetworks', { + zoneid: 'zone-1', + listall: true, + domainid: 'domain-2', + account: 'account-1' + }) + wrapper.unmount() + }) + + it('clears a stale selection while an account scope change is pending', async () => { + const wrapper = factory({ domainid: 'domain-1', account: 'account-1' }) + await flushPromises() + expect(wrapper.vm.networks).toHaveLength(1) + + jest.useFakeTimers() + await wrapper.setProps({ account: '' }) + + expect(wrapper.vm.networks).toEqual([]) + expect(wrapper.emitted('select-multi-network').at(-1)).toEqual([{}]) + expect(getAPI).toHaveBeenCalledTimes(1) + + jest.advanceTimersByTime(750) + await Promise.resolve() + await Promise.resolve() + await wrapper.vm.$nextTick() + + expect(getAPI).toHaveBeenLastCalledWith('listNetworks', { + zoneid: 'zone-1', + listall: true + }) + wrapper.unmount() + }) + + it('ignores an older response after the target scope changes', async () => { + let resolveAdminRequest + let resolveProjectRequest + getAPI + .mockReset() + .mockImplementationOnce(() => new Promise(resolve => { resolveAdminRequest = resolve })) + .mockImplementationOnce(() => new Promise(resolve => { resolveProjectRequest = resolve })) + + const wrapper = factory() + await wrapper.setProps({ projectid: 'project-1' }) + + resolveProjectRequest(responseWith(network('project-network'))) + await flushPromises() + expect(wrapper.vm.networks.map(item => item.id)).toEqual(['project-network']) + + resolveAdminRequest(responseWith(network('admin-network'))) + await flushPromises() + expect(wrapper.vm.networks.map(item => item.id)).toEqual(['project-network']) + wrapper.unmount() + }) +}) diff --git a/ui/tests/unit/views/tools/ImportUnmanagedInstance.spec.js b/ui/tests/unit/views/tools/ImportUnmanagedInstance.spec.js new file mode 100644 index 000000000000..d742ffe8890e --- /dev/null +++ b/ui/tests/unit/views/tools/ImportUnmanagedInstance.spec.js @@ -0,0 +1,55 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ImportUnmanagedInstance from '@/views/tools/ImportUnmanagedInstance' + +jest.mock('@views/compute/wizard/ComputeOfferingSelection', () => ({}), { virtual: true }) +jest.mock('@views/compute/wizard/ComputeSelection', () => ({}), { virtual: true }) +jest.mock('@views/compute/wizard/MultiDiskSelection', () => ({}), { virtual: true }) +jest.mock('@views/compute/wizard/MultiNetworkSelection', () => ({}), { virtual: true }) + +describe('Views > tools > ImportUnmanagedInstance.vue', () => { + it('clears project and network selections when an account domain is selected', () => { + const context = { + selectedDomainId: null, + nicsNetworksMapping: { 'nic-1': { network: 'network-1' } }, + updateFieldValue: jest.fn() + } + + ImportUnmanagedInstance.methods.handleDomainChange.call(context, 'domain-1') + + expect(context.selectedDomainId).toBe('domain-1') + expect(context.updateFieldValue).toHaveBeenCalledWith('account', undefined) + expect(context.updateFieldValue).toHaveBeenCalledWith('projectid', undefined) + expect(context.nicsNetworksMapping).toEqual({}) + }) + + it('clears account, domain and network selections when a project is selected', () => { + const context = { + selectedDomainId: 'domain-1', + nicsNetworksMapping: { 'nic-1': { network: 'network-1' } }, + updateFieldValue: jest.fn() + } + + ImportUnmanagedInstance.methods.handleProjectChange.call(context, 'project-1') + + expect(context.selectedDomainId).toBeNull() + expect(context.updateFieldValue).toHaveBeenCalledWith('domainid', undefined) + expect(context.updateFieldValue).toHaveBeenCalledWith('account', undefined) + expect(context.nicsNetworksMapping).toEqual({}) + }) +})