From 1da4322591511c6b996ffe874b126b932baaf35e Mon Sep 17 00:00:00 2001 From: Dogface2k <100990646+Dogface2k@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:25:21 +0100 Subject: [PATCH 1/5] UI: add VPC tier network ACL replacement --- ui/public/locales/en.json | 3 + ui/src/views/network/VpcTiersTab.vue | 111 ++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 775de26103a0..d7513c9f2c13 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -3968,6 +3968,9 @@ "message.remove.sticky.policy.failed": "Failed to remove sticky policy.", "message.remove.sticky.policy.processing": "Removing sticky policy...", "message.remove.vpc": "Please confirm that you want to remove the VPC", +"message.replace.acl.failed": "Failed to replace the Network ACL list", +"message.replace.acl.processing": "Replacing the Network ACL list...", +"message.replace.acl.success": "Successfully replaced the Network ACL list", "message.request.failed": "Request failed.", "message.request.no.data": "There is no data to show.", "message.required.add.least.ip": "Please add at least 1 IP Range", diff --git a/ui/src/views/network/VpcTiersTab.vue b/ui/src/views/network/VpcTiersTab.vue index 4a689f13c34d..92a9f8b83142 100644 --- a/ui/src/views/network/VpcTiersTab.vue +++ b/ui/src/views/network/VpcTiersTab.vue @@ -56,6 +56,12 @@ {{ network.aclname }} + @@ -302,6 +308,56 @@ + + + +

{{ $t('message.confirm.replace.acl.new.one') }}

+ + + + + {{ item.name }} ({{ item.description }}) + + + + + + + + + +
+ {{ $t('label.cancel') }} + {{ $t('label.ok') }} +
+
+
+
+ { this.networkAclList = json.listnetworkacllistsresponse.networkacllist || [] - this.handleNetworkAclChange(null) + this.form.acl = selectedAclId + this.handleNetworkAclChange(selectedAclId) }).catch(error => { this.$notifyError(error) }).finally(() => { @@ -695,7 +755,7 @@ export default { }, handleNetworkAclChange (aclId) { if (aclId) { - this.selectedNetworkAcl = this.networkAclList.filter(acl => acl.id === aclId)[0] + this.selectedNetworkAcl = this.networkAclList.filter(acl => acl.id === aclId)[0] || {} } else { this.selectedNetworkAcl = {} } @@ -724,6 +784,51 @@ export default { vlan: [{ required: true, message: this.$t('message.please.enter.value') }] } }, + handleOpenReplaceAclModal (network) { + this.initForm() + this.networkid = network.id + this.fetchNetworkAclList(network.aclid) + this.showReplaceAclModal = true + this.rules = { + acl: [{ required: true, message: this.$t('label.required') }] + } + }, + handleReplaceAclSubmit () { + if (this.modalLoading) return + + this.formRef.value.validate().then(() => { + const values = this.handleRemoveFields(toRaw(this.form)) + + this.fetchLoading = true + this.modalLoading = true + this.showReplaceAclModal = false + + postAPI('replaceNetworkACLList', { + aclid: values.acl, + networkid: this.networkid + }).then(response => { + this.$pollJob({ + jobId: response.replacenetworkacllistresponse.jobid, + title: this.$t('label.replace.acl'), + description: this.networkid, + successMessage: this.$t('message.replace.acl.success'), + successMethod: () => { + this.parentFetchData() + }, + errorMessage: this.$t('message.replace.acl.failed'), + loadingMessage: this.$t('message.replace.acl.processing'), + catchMessage: this.$t('error.fetching.async.job.result') + }) + }).catch(error => { + this.$notifyError(error) + }).finally(() => { + this.fetchLoading = false + this.modalLoading = false + }) + }).catch((error) => { + this.formRef.value.scrollToField(error.errorFields[0].name) + }) + }, handleAddInternalLB (id) { this.initForm() this.showAddInternalLB = true From abdded0988b85c853da69b1d9db41a8ef490b99b Mon Sep 17 00:00:00 2001 From: Brad <100990646+Dogface2k@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:16:51 +0100 Subject: [PATCH 2/5] UI: isolate VPC tier ACL replacement state --- ui/src/views/network/VpcTiersTab.vue | 122 ++++++-- .../unit/views/network/VpcTiersTab.spec.js | 285 ++++++++++++++++++ 2 files changed, 377 insertions(+), 30 deletions(-) create mode 100644 ui/tests/unit/views/network/VpcTiersTab.spec.js diff --git a/ui/src/views/network/VpcTiersTab.vue b/ui/src/views/network/VpcTiersTab.vue index 92a9f8b83142..7e84975b57e2 100644 --- a/ui/src/views/network/VpcTiersTab.vue +++ b/ui/src/views/network/VpcTiersTab.vue @@ -315,43 +315,43 @@ :closable="true" :footer="null" :destroyOnClose="true" - @cancel="showReplaceAclModal = false"> - + @cancel="handleCloseReplaceAclModal"> +

{{ $t('message.confirm.replace.acl.new.one') }}

- + - + {{ item.name }} ({{ item.description }}) - + - +
- {{ $t('label.cancel') }} + {{ $t('label.cancel') }} {{ $t('label.ok') }}
@@ -458,6 +458,15 @@ export default { networkOfferings: [], networkAclList: [], selectedNetworkAcl: {}, + replaceAclFormRef: null, + replaceAclForm: {}, + replaceAclRules: {}, + replaceAclList: [], + replaceAclSelected: {}, + replaceAclNetworkId: '', + replaceAclLoading: false, + replaceAclFetchLoading: false, + replaceAclInteractionGeneration: 0, modalLoading: false, internalLB: {}, LBPublicIPs: {}, @@ -619,6 +628,25 @@ export default { this.modalLoading = false }) }, + fetchReplaceAclList (interactionGeneration, selectedAclId) { + this.replaceAclFetchLoading = true + return getAPI('listNetworkACLLists', { vpcid: this.resource.id }).then(json => { + if (interactionGeneration !== this.replaceAclInteractionGeneration) { + return + } + this.replaceAclList = json.listnetworkacllistsresponse.networkacllist || [] + this.replaceAclForm.acl = selectedAclId + this.handleReplaceAclChange(selectedAclId) + }).catch(error => { + if (interactionGeneration === this.replaceAclInteractionGeneration) { + this.$notifyError(error) + } + }).finally(() => { + if (interactionGeneration === this.replaceAclInteractionGeneration) { + this.replaceAclFetchLoading = false + } + }) + }, getNetworkOffering (networkId) { return new Promise((resolve, reject) => { getAPI('listNetworkOfferings', { @@ -760,6 +788,13 @@ export default { this.selectedNetworkAcl = {} } }, + handleReplaceAclChange (aclId) { + if (aclId) { + this.replaceAclSelected = this.replaceAclList.filter(acl => acl.id === aclId)[0] || {} + } else { + this.replaceAclSelected = {} + } + }, closeModal () { this.$emit('close-action') }, @@ -785,32 +820,52 @@ export default { } }, handleOpenReplaceAclModal (network) { - this.initForm() - this.networkid = network.id - this.fetchNetworkAclList(network.aclid) - this.showReplaceAclModal = true - this.rules = { + const interactionGeneration = ++this.replaceAclInteractionGeneration + this.replaceAclFormRef = ref() + this.replaceAclForm = reactive({ acl: network.aclid }) + this.replaceAclRules = reactive({ acl: [{ required: true, message: this.$t('label.required') }] - } + }) + this.replaceAclList = [] + this.replaceAclSelected = {} + this.replaceAclNetworkId = network.id + this.replaceAclLoading = false + this.replaceAclFetchLoading = false + this.showReplaceAclModal = true + return this.fetchReplaceAclList(interactionGeneration, network.aclid) + }, + handleCloseReplaceAclModal () { + this.showReplaceAclModal = false + this.replaceAclInteractionGeneration += 1 + this.replaceAclList = [] + this.replaceAclSelected = {} + this.replaceAclNetworkId = '' + this.replaceAclLoading = false + this.replaceAclFetchLoading = false }, handleReplaceAclSubmit () { - if (this.modalLoading) return + if (this.replaceAclLoading || this.replaceAclFetchLoading) return - this.formRef.value.validate().then(() => { - const values = this.handleRemoveFields(toRaw(this.form)) + const interactionGeneration = this.replaceAclInteractionGeneration + const networkId = this.replaceAclNetworkId + const aclId = this.replaceAclForm.acl + const formRef = this.replaceAclFormRef + this.replaceAclLoading = true - this.fetchLoading = true - this.modalLoading = true + return formRef.value.validate().then(() => { + if (interactionGeneration !== this.replaceAclInteractionGeneration) { + return + } this.showReplaceAclModal = false - postAPI('replaceNetworkACLList', { - aclid: values.acl, - networkid: this.networkid + return postAPI('replaceNetworkACLList', { + aclid: aclId, + networkid: networkId }).then(response => { this.$pollJob({ jobId: response.replacenetworkacllistresponse.jobid, title: this.$t('label.replace.acl'), - description: this.networkid, + description: networkId, successMessage: this.$t('message.replace.acl.success'), successMethod: () => { this.parentFetchData() @@ -822,11 +877,18 @@ export default { }).catch(error => { this.$notifyError(error) }).finally(() => { - this.fetchLoading = false - this.modalLoading = false + if (interactionGeneration === this.replaceAclInteractionGeneration) { + this.replaceAclLoading = false + } }) }).catch((error) => { - this.formRef.value.scrollToField(error.errorFields[0].name) + if (interactionGeneration !== this.replaceAclInteractionGeneration) { + return + } + this.replaceAclLoading = false + if (error?.errorFields?.length > 0) { + formRef.value.scrollToField(error.errorFields[0].name) + } }) }, handleAddInternalLB (id) { diff --git a/ui/tests/unit/views/network/VpcTiersTab.spec.js b/ui/tests/unit/views/network/VpcTiersTab.spec.js new file mode 100644 index 000000000000..4fc3fa1631d8 --- /dev/null +++ b/ui/tests/unit/views/network/VpcTiersTab.spec.js @@ -0,0 +1,285 @@ +// 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 common from '../../../common' +import mockAxios from '../../../mock/mockAxios' +import TooltipButton from '@/components/widgets/TooltipButton' +import VpcTiersTab from '@/views/network/VpcTiersTab' + +jest.mock('axios', () => mockAxios) +jest.mock('@/vue-app', () => ({ + vueProps: { + $localStorage: { + get: jest.fn(() => null) + } + } +})) + +const deferred = () => { + let resolve + let reject + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + +const aclListResponse = (id, name = id) => ({ + listnetworkacllistsresponse: { + networkacllist: [{ id, name, description: `${name} description` }] + } +}) + +const createContext = () => { + const context = { + resource: { id: 'vpc-id' }, + form: { acl: 'create-acl' }, + networkAclList: [{ id: 'create-acl', name: 'create ACL' }], + selectedNetworkAcl: { id: 'create-acl', name: 'create ACL' }, + replaceAclFormRef: { + value: { + validate: jest.fn().mockResolvedValue(), + scrollToField: jest.fn() + } + }, + replaceAclForm: {}, + replaceAclRules: {}, + replaceAclList: [], + replaceAclSelected: {}, + replaceAclNetworkId: '', + replaceAclLoading: false, + replaceAclFetchLoading: false, + replaceAclInteractionGeneration: 0, + showReplaceAclModal: false, + $t: key => key, + $notifyError: jest.fn(), + $pollJob: jest.fn(), + parentFetchData: jest.fn() + } + Object.assign(context, VpcTiersTab.methods) + return context +} + +describe('Views > network > VpcTiersTab.vue', () => { + beforeEach(() => { + mockAxios.mockReset() + }) + + it('does not let a closed replacement request mutate create-tier state', async () => { + const request = deferred() + const context = createContext() + mockAxios.mockImplementationOnce(() => request.promise) + + const openPromise = context.handleOpenReplaceAclModal({ id: 'tier-a', aclid: 'acl-a' }) + context.handleCloseReplaceAclModal() + request.resolve(aclListResponse('acl-a')) + await openPromise + + expect(context.form).toEqual({ acl: 'create-acl' }) + expect(context.networkAclList).toEqual([{ id: 'create-acl', name: 'create ACL' }]) + expect(context.selectedNetworkAcl).toEqual({ id: 'create-acl', name: 'create ACL' }) + expect(context.replaceAclList).toEqual([]) + expect(context.replaceAclSelected).toEqual({}) + }) + + it('keeps the latest tier state when an older ACL request completes last', async () => { + const requestA = deferred() + const requestB = deferred() + const context = createContext() + mockAxios + .mockImplementationOnce(() => requestA.promise) + .mockImplementationOnce(() => requestB.promise) + + const openA = context.handleOpenReplaceAclModal({ id: 'tier-a', aclid: 'acl-a' }) + const openB = context.handleOpenReplaceAclModal({ id: 'tier-b', aclid: 'acl-b' }) + + requestB.resolve(aclListResponse('acl-b', 'ACL B')) + await openB + requestA.resolve(aclListResponse('acl-a', 'ACL A')) + await openA + + expect(context.replaceAclNetworkId).toBe('tier-b') + expect(context.replaceAclForm.acl).toBe('acl-b') + expect(context.replaceAclList).toEqual([{ + id: 'acl-b', + name: 'ACL B', + description: 'ACL B description' + }]) + expect(context.replaceAclSelected.id).toBe('acl-b') + expect(context.replaceAclFetchLoading).toBe(false) + }) + + it('does not submit after the interaction is closed during validation', async () => { + const validation = deferred() + const context = createContext() + context.replaceAclInteractionGeneration = 1 + context.replaceAclNetworkId = 'tier-a' + context.replaceAclForm = { acl: 'acl-a' } + context.replaceAclFormRef = { + value: { + validate: jest.fn(() => validation.promise), + scrollToField: jest.fn() + } + } + context.showReplaceAclModal = true + + const submission = context.handleReplaceAclSubmit() + context.handleCloseReplaceAclModal() + validation.resolve() + await submission + + expect(mockAxios).not.toHaveBeenCalled() + expect(context.$pollJob).not.toHaveBeenCalled() + expect(context.replaceAclLoading).toBe(false) + }) + + it('submits captured IDs without clearing a newer replacement interaction', async () => { + const firstSubmissionRequest = deferred() + const nextAclRequest = deferred() + const nextSubmissionRequest = deferred() + const context = createContext() + context.replaceAclInteractionGeneration = 1 + context.replaceAclNetworkId = 'tier-a' + context.replaceAclForm = { acl: 'acl-a' } + context.replaceAclFormRef = { + value: { + validate: jest.fn().mockResolvedValue(), + scrollToField: jest.fn() + } + } + context.showReplaceAclModal = true + mockAxios + .mockImplementationOnce(() => firstSubmissionRequest.promise) + .mockImplementationOnce(() => nextAclRequest.promise) + .mockImplementationOnce(() => nextSubmissionRequest.promise) + + const firstSubmission = context.handleReplaceAclSubmit() + await flushPromises() + + const firstPostRequest = mockAxios.mock.calls[0][0] + expect(firstPostRequest.method).toBe('POST') + expect(firstPostRequest.data.get('command')).toBe('replaceNetworkACLList') + expect(firstPostRequest.data.get('aclid')).toBe('acl-a') + expect(firstPostRequest.data.get('networkid')).toBe('tier-a') + + const nextInteraction = context.handleOpenReplaceAclModal({ id: 'tier-b', aclid: 'acl-b' }) + expect(context.replaceAclLoading).toBe(false) + expect(context.replaceAclFetchLoading).toBe(true) + + nextAclRequest.resolve(aclListResponse('acl-b', 'ACL B')) + await nextInteraction + expect(context.replaceAclFetchLoading).toBe(false) + context.replaceAclFormRef.value = { + validate: jest.fn().mockResolvedValue(), + scrollToField: jest.fn() + } + + const nextSubmission = context.handleReplaceAclSubmit() + await flushPromises() + + const nextPostRequest = mockAxios.mock.calls[2][0] + expect(nextPostRequest.method).toBe('POST') + expect(nextPostRequest.data.get('aclid')).toBe('acl-b') + expect(nextPostRequest.data.get('networkid')).toBe('tier-b') + expect(context.replaceAclLoading).toBe(true) + + firstSubmissionRequest.resolve({ replacenetworkacllistresponse: { jobid: 'job-a' } }) + await firstSubmission + + expect(context.$pollJob).toHaveBeenCalledWith(expect.objectContaining({ + jobId: 'job-a', + description: 'tier-a' + })) + expect(context.replaceAclNetworkId).toBe('tier-b') + expect(context.replaceAclLoading).toBe(true) + + nextSubmissionRequest.resolve({ replacenetworkacllistresponse: { jobid: 'job-b' } }) + await nextSubmission + expect(context.replaceAclLoading).toBe(false) + }) + + it('keeps the overview replacement action disabled without API permission', async () => { + mockAxios.mockImplementation(request => { + const command = request.params?.command + switch (command) { + case 'listZones': + return Promise.resolve({ listzonesresponse: { zone: [{}] } }) + case 'listVPCOfferings': + return Promise.resolve({ listvpcofferingsresponse: { vpcoffering: [{}] } }) + case 'listLoadBalancers': + return Promise.resolve({ listloadbalancersresponse: { loadbalancer: [], count: 0 } }) + case 'listVirtualMachines': + return Promise.resolve({ listvirtualmachinesresponse: { virtualmachine: [], count: 0 } }) + case 'listNetworkOfferings': + return Promise.resolve({ listnetworkofferingsresponse: { networkoffering: [{ supportsinternallb: false }] } }) + case 'listNetworks': + return Promise.resolve({ listnetworksresponse: { network: [] } }) + default: + return Promise.resolve({}) + } + }) + const store = common.createMockStore({ + user: { + apis: {}, + info: {} + } + }) + const wrapper = shallowMount(VpcTiersTab, { + props: { + resource: { + id: 'vpc-id', + zoneid: 'zone-id', + vpcofferingid: 'vpc-offering-id', + network: [{ + id: 'tier-id', + zoneid: 'zone-id', + networkofferingid: 'network-offering-id', + name: 'tier', + state: 'Implemented', + cidr: '10.0.0.0/24', + aclid: 'acl-id', + aclname: 'ACL', + service: [] + }] + } + }, + global: { + plugins: [store], + mocks: { + $t: key => key, + $router: { + push: jest.fn() + } + }, + provide: { + parentFetchData: jest.fn() + } + } + }) + + await flushPromises() + const replaceButton = wrapper.findAllComponents(TooltipButton) + .find(button => button.props('tooltip') === 'label.replace.acl') + + expect(replaceButton).toBeDefined() + expect(replaceButton.props('disabled')).toBe(true) + wrapper.unmount() + }) +}) From 2b2224fa442c12ac9a644970a37d1a2c04800dd7 Mon Sep 17 00:00:00 2001 From: Brad <100990646+Dogface2k@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:18:52 +0100 Subject: [PATCH 3/5] UI: preserve VPC tier ACL replacement lifecycle --- ui/src/views/network/VpcTiersTab.vue | 34 +++-- .../unit/views/network/VpcTiersTab.spec.js | 120 ++++++++++++++++++ 2 files changed, 140 insertions(+), 14 deletions(-) diff --git a/ui/src/views/network/VpcTiersTab.vue b/ui/src/views/network/VpcTiersTab.vue index 7e84975b57e2..d6b4f0c29b9d 100644 --- a/ui/src/views/network/VpcTiersTab.vue +++ b/ui/src/views/network/VpcTiersTab.vue @@ -458,7 +458,6 @@ export default { networkOfferings: [], networkAclList: [], selectedNetworkAcl: {}, - replaceAclFormRef: null, replaceAclForm: {}, replaceAclRules: {}, replaceAclList: [], @@ -544,6 +543,7 @@ export default { }, created () { this.initForm() + this.replaceAclFormRef = ref() this.fetchData() }, watch: { @@ -551,6 +551,11 @@ export default { if (!newData && this.resource.id) { this.fetchData() } + }, + 'resource.id' (newId, oldId) { + if (oldId && newId !== oldId) { + this.handleCloseReplaceAclModal() + } } }, methods: { @@ -614,13 +619,12 @@ export default { this.zoneExtNetProvider = json?.listzonesresponse?.zone?.[0]?.provider || null }) }, - fetchNetworkAclList (selectedAclId) { + fetchNetworkAclList () { this.fetchLoading = true this.modalLoading = true getAPI('listNetworkACLLists', { vpcid: this.resource.id }).then(json => { this.networkAclList = json.listnetworkacllistsresponse.networkacllist || [] - this.form.acl = selectedAclId - this.handleNetworkAclChange(selectedAclId) + this.handleNetworkAclChange(null) }).catch(error => { this.$notifyError(error) }).finally(() => { @@ -628,21 +632,21 @@ export default { this.modalLoading = false }) }, - fetchReplaceAclList (interactionGeneration, selectedAclId) { + fetchReplaceAclList (interactionGeneration, vpcId, selectedAclId) { this.replaceAclFetchLoading = true - return getAPI('listNetworkACLLists', { vpcid: this.resource.id }).then(json => { - if (interactionGeneration !== this.replaceAclInteractionGeneration) { + return getAPI('listNetworkACLLists', { vpcid: vpcId }).then(json => { + if (interactionGeneration !== this.replaceAclInteractionGeneration || vpcId !== this.resource.id) { return } this.replaceAclList = json.listnetworkacllistsresponse.networkacllist || [] this.replaceAclForm.acl = selectedAclId this.handleReplaceAclChange(selectedAclId) }).catch(error => { - if (interactionGeneration === this.replaceAclInteractionGeneration) { + if (interactionGeneration === this.replaceAclInteractionGeneration && vpcId === this.resource.id) { this.$notifyError(error) } }).finally(() => { - if (interactionGeneration === this.replaceAclInteractionGeneration) { + if (interactionGeneration === this.replaceAclInteractionGeneration && vpcId === this.resource.id) { this.replaceAclFetchLoading = false } }) @@ -783,7 +787,7 @@ export default { }, handleNetworkAclChange (aclId) { if (aclId) { - this.selectedNetworkAcl = this.networkAclList.filter(acl => acl.id === aclId)[0] || {} + this.selectedNetworkAcl = this.networkAclList.filter(acl => acl.id === aclId)[0] } else { this.selectedNetworkAcl = {} } @@ -821,6 +825,7 @@ export default { }, handleOpenReplaceAclModal (network) { const interactionGeneration = ++this.replaceAclInteractionGeneration + const vpcId = this.resource.id this.replaceAclFormRef = ref() this.replaceAclForm = reactive({ acl: network.aclid }) this.replaceAclRules = reactive({ @@ -832,7 +837,7 @@ export default { this.replaceAclLoading = false this.replaceAclFetchLoading = false this.showReplaceAclModal = true - return this.fetchReplaceAclList(interactionGeneration, network.aclid) + return this.fetchReplaceAclList(interactionGeneration, vpcId, network.aclid) }, handleCloseReplaceAclModal () { this.showReplaceAclModal = false @@ -847,13 +852,14 @@ export default { if (this.replaceAclLoading || this.replaceAclFetchLoading) return const interactionGeneration = this.replaceAclInteractionGeneration + const vpcId = this.resource.id const networkId = this.replaceAclNetworkId const aclId = this.replaceAclForm.acl const formRef = this.replaceAclFormRef this.replaceAclLoading = true return formRef.value.validate().then(() => { - if (interactionGeneration !== this.replaceAclInteractionGeneration) { + if (interactionGeneration !== this.replaceAclInteractionGeneration || vpcId !== this.resource.id) { return } this.showReplaceAclModal = false @@ -877,12 +883,12 @@ export default { }).catch(error => { this.$notifyError(error) }).finally(() => { - if (interactionGeneration === this.replaceAclInteractionGeneration) { + if (interactionGeneration === this.replaceAclInteractionGeneration && vpcId === this.resource.id) { this.replaceAclLoading = false } }) }).catch((error) => { - if (interactionGeneration !== this.replaceAclInteractionGeneration) { + if (interactionGeneration !== this.replaceAclInteractionGeneration || vpcId !== this.resource.id) { return } this.replaceAclLoading = false diff --git a/ui/tests/unit/views/network/VpcTiersTab.spec.js b/ui/tests/unit/views/network/VpcTiersTab.spec.js index 4fc3fa1631d8..56073fa32e87 100644 --- a/ui/tests/unit/views/network/VpcTiersTab.spec.js +++ b/ui/tests/unit/views/network/VpcTiersTab.spec.js @@ -15,6 +15,7 @@ // specific language governing permissions and limitations // under the License. +import { isRef } from 'vue' import { flushPromises, shallowMount } from '@vue/test-utils' import common from '../../../common' @@ -126,6 +127,30 @@ describe('Views > network > VpcTiersTab.vue', () => { expect(context.replaceAclFetchLoading).toBe(false) }) + it('ignores a stale VPC response and closes the interaction when the parent VPC changes', async () => { + const request = deferred() + const context = createContext() + context.resource.id = 'vpc-a' + mockAxios.mockImplementationOnce(() => request.promise) + + const openPromise = context.handleOpenReplaceAclModal({ id: 'tier-a', aclid: 'acl-a' }) + const interactionGeneration = context.replaceAclInteractionGeneration + + context.resource.id = 'vpc-b' + request.resolve(aclListResponse('acl-a')) + await openPromise + + expect(context.replaceAclList).toEqual([]) + expect(context.replaceAclSelected).toEqual({}) + + VpcTiersTab.watch['resource.id'].call(context, 'vpc-b', 'vpc-a') + expect(context.showReplaceAclModal).toBe(false) + expect(context.replaceAclInteractionGeneration).toBe(interactionGeneration + 1) + expect(context.replaceAclNetworkId).toBe('') + expect(context.replaceAclFetchLoading).toBe(false) + expect(context.replaceAclLoading).toBe(false) + }) + it('does not submit after the interaction is closed during validation', async () => { const validation = deferred() const context = createContext() @@ -215,6 +240,101 @@ describe('Views > network > VpcTiersTab.vue', () => { expect(context.replaceAclLoading).toBe(false) }) + it('keeps the replacement form reference as a Vue ref and submits captured IDs', async () => { + const pollJob = jest.fn() + mockAxios.mockImplementation(request => { + const command = request.params?.command || request.data?.get('command') + switch (command) { + case 'listZones': + return Promise.resolve({ listzonesresponse: { zone: [{}] } }) + case 'listVPCOfferings': + return Promise.resolve({ listvpcofferingsresponse: { vpcoffering: [{}] } }) + case 'listLoadBalancers': + return Promise.resolve({ listloadbalancersresponse: { loadbalancer: [], count: 0 } }) + case 'listVirtualMachines': + return Promise.resolve({ listvirtualmachinesresponse: { virtualmachine: [], count: 0 } }) + case 'listNetworkOfferings': + return Promise.resolve({ listnetworkofferingsresponse: { networkoffering: [{ supportsinternallb: false }] } }) + case 'listNetworks': + return Promise.resolve({ listnetworksresponse: { network: [] } }) + case 'listNetworkACLLists': + return Promise.resolve(aclListResponse('acl-id', 'ACL')) + case 'replaceNetworkACLList': + return Promise.resolve({ replacenetworkacllistresponse: { jobid: 'job-id' } }) + default: + return Promise.resolve({}) + } + }) + const store = common.createMockStore({ + user: { + apis: { replaceNetworkACLList: {} }, + info: {} + } + }) + const wrapper = shallowMount(VpcTiersTab, { + props: { + resource: { + id: 'vpc-id', + zoneid: 'zone-id', + vpcofferingid: 'vpc-offering-id', + network: [{ + id: 'tier-id', + zoneid: 'zone-id', + networkofferingid: 'network-offering-id', + name: 'tier', + state: 'Implemented', + cidr: '10.0.0.0/24', + aclid: 'acl-id', + aclname: 'ACL', + service: [] + }] + } + }, + global: { + plugins: [store], + mocks: { + $t: key => key, + $router: { + push: jest.fn() + }, + $pollJob: pollJob, + $notifyError: jest.fn() + }, + provide: { + parentFetchData: jest.fn() + } + } + }) + + await flushPromises() + expect(isRef(wrapper.vm.replaceAclFormRef)).toBe(true) + + await wrapper.vm.handleOpenReplaceAclModal({ id: 'tier-id', aclid: 'acl-id' }) + await flushPromises() + expect(isRef(wrapper.vm.replaceAclFormRef)).toBe(true) + + const form = { + validate: jest.fn().mockResolvedValue(), + scrollToField: jest.fn() + } + wrapper.vm.replaceAclFormRef.value = form + await wrapper.vm.handleReplaceAclSubmit() + + const replaceRequest = mockAxios.mock.calls + .map(call => call[0]) + .find(request => request.data?.get('command') === 'replaceNetworkACLList') + + expect(form.validate).toHaveBeenCalled() + expect(replaceRequest).toBeDefined() + expect(replaceRequest.data.get('aclid')).toBe('acl-id') + expect(replaceRequest.data.get('networkid')).toBe('tier-id') + expect(pollJob).toHaveBeenCalledWith(expect.objectContaining({ + jobId: 'job-id', + description: 'tier-id' + })) + wrapper.unmount() + }) + it('keeps the overview replacement action disabled without API permission', async () => { mockAxios.mockImplementation(request => { const command = request.params?.command From 4f9b7ea6c53c21b3a7ba158e59fd1dc68d967f2e Mon Sep 17 00:00:00 2001 From: Brad <100990646+Dogface2k@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:37:58 +0100 Subject: [PATCH 4/5] test: render VPC tier ACL test slots --- ui/tests/unit/views/network/VpcTiersTab.spec.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/tests/unit/views/network/VpcTiersTab.spec.js b/ui/tests/unit/views/network/VpcTiersTab.spec.js index 56073fa32e87..ce6117c40893 100644 --- a/ui/tests/unit/views/network/VpcTiersTab.spec.js +++ b/ui/tests/unit/views/network/VpcTiersTab.spec.js @@ -291,6 +291,7 @@ describe('Views > network > VpcTiersTab.vue', () => { } }, global: { + renderStubDefaultSlot: true, plugins: [store], mocks: { $t: key => key, @@ -312,6 +313,7 @@ describe('Views > network > VpcTiersTab.vue', () => { await wrapper.vm.handleOpenReplaceAclModal({ id: 'tier-id', aclid: 'acl-id' }) await flushPromises() expect(isRef(wrapper.vm.replaceAclFormRef)).toBe(true) + expect(wrapper.vm.replaceAclFormRef.value).toBeTruthy() const form = { validate: jest.fn().mockResolvedValue(), @@ -381,6 +383,7 @@ describe('Views > network > VpcTiersTab.vue', () => { } }, global: { + renderStubDefaultSlot: true, plugins: [store], mocks: { $t: key => key, From b59a3acde82449098683203c80ca9815f3170951 Mon Sep 17 00:00:00 2001 From: Brad <100990646+Dogface2k@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:36:17 +0100 Subject: [PATCH 5/5] UI: reuse existing VPC tier ACL action --- ui/public/locales/en.json | 3 - ui/src/views/network/VpcTiersTab.vue | 191 ++------ .../unit/views/network/VpcTiersTab.spec.js | 448 +++++------------- 3 files changed, 144 insertions(+), 498 deletions(-) diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index d7513c9f2c13..775de26103a0 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -3968,9 +3968,6 @@ "message.remove.sticky.policy.failed": "Failed to remove sticky policy.", "message.remove.sticky.policy.processing": "Removing sticky policy...", "message.remove.vpc": "Please confirm that you want to remove the VPC", -"message.replace.acl.failed": "Failed to replace the Network ACL list", -"message.replace.acl.processing": "Replacing the Network ACL list...", -"message.replace.acl.success": "Successfully replaced the Network ACL list", "message.request.failed": "Request failed.", "message.request.no.data": "There is no data to show.", "message.required.add.least.ip": "Please add at least 1 IP Range", diff --git a/ui/src/views/network/VpcTiersTab.vue b/ui/src/views/network/VpcTiersTab.vue index d6b4f0c29b9d..d8a3ea092c55 100644 --- a/ui/src/views/network/VpcTiersTab.vue +++ b/ui/src/views/network/VpcTiersTab.vue @@ -61,7 +61,7 @@ icon="swap-outlined" size="small" :disabled="!('replaceNetworkACLList' in $store.getters.apis)" - @onClick="() => handleOpenReplaceAclModal(network)" /> + @onClick="() => handleOpenReplaceAclAction(network)" /> @@ -308,56 +308,6 @@
- - - -

{{ $t('message.confirm.replace.acl.new.one') }}

- - - - - {{ item.name }} ({{ item.description }}) - - - - - - - - - -
- {{ $t('label.cancel') }} - {{ $t('label.ok') }} -
-
-
-
- { - if (interactionGeneration !== this.replaceAclInteractionGeneration || vpcId !== this.resource.id) { - return - } - this.replaceAclList = json.listnetworkacllistsresponse.networkacllist || [] - this.replaceAclForm.acl = selectedAclId - this.handleReplaceAclChange(selectedAclId) - }).catch(error => { - if (interactionGeneration === this.replaceAclInteractionGeneration && vpcId === this.resource.id) { - this.$notifyError(error) - } - }).finally(() => { - if (interactionGeneration === this.replaceAclInteractionGeneration && vpcId === this.resource.id) { - this.replaceAclFetchLoading = false - } - }) - }, getNetworkOffering (networkId) { return new Promise((resolve, reject) => { getAPI('listNetworkOfferings', { @@ -792,13 +709,6 @@ export default { this.selectedNetworkAcl = {} } }, - handleReplaceAclChange (aclId) { - if (aclId) { - this.replaceAclSelected = this.replaceAclList.filter(acl => acl.id === aclId)[0] || {} - } else { - this.replaceAclSelected = {} - } - }, closeModal () { this.$emit('close-action') }, @@ -823,78 +733,35 @@ export default { vlan: [{ required: true, message: this.$t('message.please.enter.value') }] } }, - handleOpenReplaceAclModal (network) { - const interactionGeneration = ++this.replaceAclInteractionGeneration - const vpcId = this.resource.id - this.replaceAclFormRef = ref() - this.replaceAclForm = reactive({ acl: network.aclid }) - this.replaceAclRules = reactive({ - acl: [{ required: true, message: this.$t('label.required') }] - }) - this.replaceAclList = [] - this.replaceAclSelected = {} - this.replaceAclNetworkId = network.id - this.replaceAclLoading = false - this.replaceAclFetchLoading = false - this.showReplaceAclModal = true - return this.fetchReplaceAclList(interactionGeneration, vpcId, network.aclid) - }, - handleCloseReplaceAclModal () { - this.showReplaceAclModal = false - this.replaceAclInteractionGeneration += 1 - this.replaceAclList = [] - this.replaceAclSelected = {} - this.replaceAclNetworkId = '' - this.replaceAclLoading = false - this.replaceAclFetchLoading = false - }, - handleReplaceAclSubmit () { - if (this.replaceAclLoading || this.replaceAclFetchLoading) return - - const interactionGeneration = this.replaceAclInteractionGeneration - const vpcId = this.resource.id - const networkId = this.replaceAclNetworkId - const aclId = this.replaceAclForm.acl - const formRef = this.replaceAclFormRef - this.replaceAclLoading = true - - return formRef.value.validate().then(() => { - if (interactionGeneration !== this.replaceAclInteractionGeneration || vpcId !== this.resource.id) { - return - } - this.showReplaceAclModal = false - - return postAPI('replaceNetworkACLList', { - aclid: aclId, - networkid: networkId - }).then(response => { - this.$pollJob({ - jobId: response.replacenetworkacllistresponse.jobid, - title: this.$t('label.replace.acl'), - description: networkId, - successMessage: this.$t('message.replace.acl.success'), - successMethod: () => { - this.parentFetchData() + handleOpenReplaceAclAction (network) { + const action = this.$router.resolve({ path: `/guestnetwork/${network.id}` }) + .meta.actions?.find(candidate => candidate.dataView && candidate.api === 'replaceNetworkACLList') + const apis = this.$store.getters.apis + const tierResource = { ...network, vpcid: network.vpcid || this.resource.id } + if (!action || !(action.api in apis) || + (action.show && !action.show(tierResource, this.$store.getters)) || + (action.disabled && action.disabled(tierResource, this.$store.getters))) { + return + } + const aclMapping = action.mapping?.aclid || {} + const networkMapping = action.mapping?.networkid || {} + eventBus.emit('exec-action', { + action: { + ...action, + resource: this.resource, + mapping: { + ...action.mapping, + aclid: { + ...aclMapping, + params: () => ({ vpcid: tierResource.vpcid }) }, - errorMessage: this.$t('message.replace.acl.failed'), - loadingMessage: this.$t('message.replace.acl.processing'), - catchMessage: this.$t('error.fetching.async.job.result') - }) - }).catch(error => { - this.$notifyError(error) - }).finally(() => { - if (interactionGeneration === this.replaceAclInteractionGeneration && vpcId === this.resource.id) { - this.replaceAclLoading = false + networkid: { + ...networkMapping, + value: () => network.id + } } - }) - }).catch((error) => { - if (interactionGeneration !== this.replaceAclInteractionGeneration || vpcId !== this.resource.id) { - return - } - this.replaceAclLoading = false - if (error?.errorFields?.length > 0) { - formRef.value.scrollToField(error.errorFields[0].name) - } + }, + isGroupAction: false }) }, handleAddInternalLB (id) { diff --git a/ui/tests/unit/views/network/VpcTiersTab.spec.js b/ui/tests/unit/views/network/VpcTiersTab.spec.js index ce6117c40893..71d1d56380b1 100644 --- a/ui/tests/unit/views/network/VpcTiersTab.spec.js +++ b/ui/tests/unit/views/network/VpcTiersTab.spec.js @@ -15,11 +15,11 @@ // specific language governing permissions and limitations // under the License. -import { isRef } from 'vue' import { flushPromises, shallowMount } from '@vue/test-utils' import common from '../../../common' import mockAxios from '../../../mock/mockAxios' +import eventBus from '@/config/eventBus' import TooltipButton from '@/components/widgets/TooltipButton' import VpcTiersTab from '@/views/network/VpcTiersTab' @@ -32,219 +32,84 @@ jest.mock('@/vue-app', () => ({ } })) -const deferred = () => { - let resolve - let reject - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - return { promise, resolve, reject } +const vpc = { + id: 'vpc-id', + zoneid: 'zone-id', + vpcofferingid: 'vpc-offering-id', + service: [], + network: [{ + id: 'tier-id', + zoneid: 'zone-id', + networkofferingid: 'network-offering-id', + name: 'tier', + state: 'Implemented', + cidr: '10.0.0.0/24', + aclid: 'acl-id', + aclname: 'ACL', + service: [] + }] } -const aclListResponse = (id, name = id) => ({ - listnetworkacllistsresponse: { - networkacllist: [{ id, name, description: `${name} description` }] - } -}) - -const createContext = () => { - const context = { - resource: { id: 'vpc-id' }, - form: { acl: 'create-acl' }, - networkAclList: [{ id: 'create-acl', name: 'create ACL' }], - selectedNetworkAcl: { id: 'create-acl', name: 'create ACL' }, - replaceAclFormRef: { - value: { - validate: jest.fn().mockResolvedValue(), - scrollToField: jest.fn() - } +const createReplaceAction = (overrides = {}) => ({ + api: 'replaceNetworkACLList', + dataView: true, + show: record => Boolean(record.vpcid), + args: ['aclid', 'networkid'], + mapping: { + aclid: { + api: 'listNetworkACLLists', + params: record => ({ vpcid: record.vpcid }) }, - replaceAclForm: {}, - replaceAclRules: {}, - replaceAclList: [], - replaceAclSelected: {}, - replaceAclNetworkId: '', - replaceAclLoading: false, - replaceAclFetchLoading: false, - replaceAclInteractionGeneration: 0, - showReplaceAclModal: false, - $t: key => key, - $notifyError: jest.fn(), - $pollJob: jest.fn(), - parentFetchData: jest.fn() - } - Object.assign(context, VpcTiersTab.methods) - return context -} - -describe('Views > network > VpcTiersTab.vue', () => { - beforeEach(() => { - mockAxios.mockReset() - }) - - it('does not let a closed replacement request mutate create-tier state', async () => { - const request = deferred() - const context = createContext() - mockAxios.mockImplementationOnce(() => request.promise) - - const openPromise = context.handleOpenReplaceAclModal({ id: 'tier-a', aclid: 'acl-a' }) - context.handleCloseReplaceAclModal() - request.resolve(aclListResponse('acl-a')) - await openPromise - - expect(context.form).toEqual({ acl: 'create-acl' }) - expect(context.networkAclList).toEqual([{ id: 'create-acl', name: 'create ACL' }]) - expect(context.selectedNetworkAcl).toEqual({ id: 'create-acl', name: 'create ACL' }) - expect(context.replaceAclList).toEqual([]) - expect(context.replaceAclSelected).toEqual({}) - }) - - it('keeps the latest tier state when an older ACL request completes last', async () => { - const requestA = deferred() - const requestB = deferred() - const context = createContext() - mockAxios - .mockImplementationOnce(() => requestA.promise) - .mockImplementationOnce(() => requestB.promise) - - const openA = context.handleOpenReplaceAclModal({ id: 'tier-a', aclid: 'acl-a' }) - const openB = context.handleOpenReplaceAclModal({ id: 'tier-b', aclid: 'acl-b' }) - - requestB.resolve(aclListResponse('acl-b', 'ACL B')) - await openB - requestA.resolve(aclListResponse('acl-a', 'ACL A')) - await openA - - expect(context.replaceAclNetworkId).toBe('tier-b') - expect(context.replaceAclForm.acl).toBe('acl-b') - expect(context.replaceAclList).toEqual([{ - id: 'acl-b', - name: 'ACL B', - description: 'ACL B description' - }]) - expect(context.replaceAclSelected.id).toBe('acl-b') - expect(context.replaceAclFetchLoading).toBe(false) - }) - - it('ignores a stale VPC response and closes the interaction when the parent VPC changes', async () => { - const request = deferred() - const context = createContext() - context.resource.id = 'vpc-a' - mockAxios.mockImplementationOnce(() => request.promise) - - const openPromise = context.handleOpenReplaceAclModal({ id: 'tier-a', aclid: 'acl-a' }) - const interactionGeneration = context.replaceAclInteractionGeneration - - context.resource.id = 'vpc-b' - request.resolve(aclListResponse('acl-a')) - await openPromise - - expect(context.replaceAclList).toEqual([]) - expect(context.replaceAclSelected).toEqual({}) - - VpcTiersTab.watch['resource.id'].call(context, 'vpc-b', 'vpc-a') - expect(context.showReplaceAclModal).toBe(false) - expect(context.replaceAclInteractionGeneration).toBe(interactionGeneration + 1) - expect(context.replaceAclNetworkId).toBe('') - expect(context.replaceAclFetchLoading).toBe(false) - expect(context.replaceAclLoading).toBe(false) - }) - - it('does not submit after the interaction is closed during validation', async () => { - const validation = deferred() - const context = createContext() - context.replaceAclInteractionGeneration = 1 - context.replaceAclNetworkId = 'tier-a' - context.replaceAclForm = { acl: 'acl-a' } - context.replaceAclFormRef = { - value: { - validate: jest.fn(() => validation.promise), - scrollToField: jest.fn() - } + networkid: { + value: record => record.id } - context.showReplaceAclModal = true - - const submission = context.handleReplaceAclSubmit() - context.handleCloseReplaceAclModal() - validation.resolve() - await submission + }, + ...overrides +}) - expect(mockAxios).not.toHaveBeenCalled() - expect(context.$pollJob).not.toHaveBeenCalled() - expect(context.replaceAclLoading).toBe(false) +const createWrapper = ({ permission = true, action = createReplaceAction() } = {}) => { + const store = common.createMockStore({ + user: { + apis: permission ? { replaceNetworkACLList: {} } : {}, + info: {} + } }) - - it('submits captured IDs without clearing a newer replacement interaction', async () => { - const firstSubmissionRequest = deferred() - const nextAclRequest = deferred() - const nextSubmissionRequest = deferred() - const context = createContext() - context.replaceAclInteractionGeneration = 1 - context.replaceAclNetworkId = 'tier-a' - context.replaceAclForm = { acl: 'acl-a' } - context.replaceAclFormRef = { - value: { - validate: jest.fn().mockResolvedValue(), - scrollToField: jest.fn() + const router = { + resolve: jest.fn(() => ({ + meta: { actions: action ? [action] : [] } + })) + } + const wrapper = shallowMount(VpcTiersTab, { + props: { resource: vpc }, + global: { + renderStubDefaultSlot: true, + plugins: [store], + mocks: { + $t: key => key, + $route: { path: '/vpc/vpc-id', query: {}, params: {} }, + $router: router, + $notifyError: jest.fn() + }, + provide: { + parentFetchData: jest.fn() } } - context.showReplaceAclModal = true - mockAxios - .mockImplementationOnce(() => firstSubmissionRequest.promise) - .mockImplementationOnce(() => nextAclRequest.promise) - .mockImplementationOnce(() => nextSubmissionRequest.promise) - - const firstSubmission = context.handleReplaceAclSubmit() - await flushPromises() - - const firstPostRequest = mockAxios.mock.calls[0][0] - expect(firstPostRequest.method).toBe('POST') - expect(firstPostRequest.data.get('command')).toBe('replaceNetworkACLList') - expect(firstPostRequest.data.get('aclid')).toBe('acl-a') - expect(firstPostRequest.data.get('networkid')).toBe('tier-a') - - const nextInteraction = context.handleOpenReplaceAclModal({ id: 'tier-b', aclid: 'acl-b' }) - expect(context.replaceAclLoading).toBe(false) - expect(context.replaceAclFetchLoading).toBe(true) - - nextAclRequest.resolve(aclListResponse('acl-b', 'ACL B')) - await nextInteraction - expect(context.replaceAclFetchLoading).toBe(false) - context.replaceAclFormRef.value = { - validate: jest.fn().mockResolvedValue(), - scrollToField: jest.fn() - } - - const nextSubmission = context.handleReplaceAclSubmit() - await flushPromises() - - const nextPostRequest = mockAxios.mock.calls[2][0] - expect(nextPostRequest.method).toBe('POST') - expect(nextPostRequest.data.get('aclid')).toBe('acl-b') - expect(nextPostRequest.data.get('networkid')).toBe('tier-b') - expect(context.replaceAclLoading).toBe(true) + }) + return { wrapper, router } +} - firstSubmissionRequest.resolve({ replacenetworkacllistresponse: { jobid: 'job-a' } }) - await firstSubmission +const findReplaceButton = wrapper => wrapper.findAllComponents(TooltipButton) + .find(button => button.props('tooltip') === 'label.replace.acl') - expect(context.$pollJob).toHaveBeenCalledWith(expect.objectContaining({ - jobId: 'job-a', - description: 'tier-a' - })) - expect(context.replaceAclNetworkId).toBe('tier-b') - expect(context.replaceAclLoading).toBe(true) +describe('VPC tier ACL action reuse', () => { + let execAction - nextSubmissionRequest.resolve({ replacenetworkacllistresponse: { jobid: 'job-b' } }) - await nextSubmission - expect(context.replaceAclLoading).toBe(false) - }) - - it('keeps the replacement form reference as a Vue ref and submits captured IDs', async () => { - const pollJob = jest.fn() + beforeEach(() => { + execAction = jest.fn() + eventBus.on('exec-action', execAction) + mockAxios.mockReset() mockAxios.mockImplementation(request => { - const command = request.params?.command || request.data?.get('command') - switch (command) { + switch (request.params?.command) { case 'listZones': return Promise.resolve({ listzonesresponse: { zone: [{}] } }) case 'listVPCOfferings': @@ -257,152 +122,69 @@ describe('Views > network > VpcTiersTab.vue', () => { return Promise.resolve({ listnetworkofferingsresponse: { networkoffering: [{ supportsinternallb: false }] } }) case 'listNetworks': return Promise.resolve({ listnetworksresponse: { network: [] } }) - case 'listNetworkACLLists': - return Promise.resolve(aclListResponse('acl-id', 'ACL')) - case 'replaceNetworkACLList': - return Promise.resolve({ replacenetworkacllistresponse: { jobid: 'job-id' } }) default: return Promise.resolve({}) } }) - const store = common.createMockStore({ - user: { - apis: { replaceNetworkACLList: {} }, - info: {} - } - }) - const wrapper = shallowMount(VpcTiersTab, { - props: { - resource: { - id: 'vpc-id', - zoneid: 'zone-id', - vpcofferingid: 'vpc-offering-id', - network: [{ - id: 'tier-id', - zoneid: 'zone-id', - networkofferingid: 'network-offering-id', - name: 'tier', - state: 'Implemented', - cidr: '10.0.0.0/24', - aclid: 'acl-id', - aclname: 'ACL', - service: [] - }] - } - }, - global: { - renderStubDefaultSlot: true, - plugins: [store], - mocks: { - $t: key => key, - $router: { - push: jest.fn() - }, - $pollJob: pollJob, - $notifyError: jest.fn() - }, - provide: { - parentFetchData: jest.fn() - } - } - }) + }) - await flushPromises() - expect(isRef(wrapper.vm.replaceAclFormRef)).toBe(true) + afterEach(() => { + eventBus.off('exec-action', execAction) + }) - await wrapper.vm.handleOpenReplaceAclModal({ id: 'tier-id', aclid: 'acl-id' }) + it('dispatches the existing registered action with tier-specific mappings', async () => { + const registeredAction = createReplaceAction() + const originalAclParams = registeredAction.mapping.aclid.params + const originalNetworkValue = registeredAction.mapping.networkid.value + const { wrapper, router } = createWrapper({ action: registeredAction }) await flushPromises() - expect(isRef(wrapper.vm.replaceAclFormRef)).toBe(true) - expect(wrapper.vm.replaceAclFormRef.value).toBeTruthy() - const form = { - validate: jest.fn().mockResolvedValue(), - scrollToField: jest.fn() - } - wrapper.vm.replaceAclFormRef.value = form - await wrapper.vm.handleReplaceAclSubmit() - - const replaceRequest = mockAxios.mock.calls - .map(call => call[0]) - .find(request => request.data?.get('command') === 'replaceNetworkACLList') - - expect(form.validate).toHaveBeenCalled() - expect(replaceRequest).toBeDefined() - expect(replaceRequest.data.get('aclid')).toBe('acl-id') - expect(replaceRequest.data.get('networkid')).toBe('tier-id') - expect(pollJob).toHaveBeenCalledWith(expect.objectContaining({ - jobId: 'job-id', - description: 'tier-id' - })) + const replaceButton = findReplaceButton(wrapper) + expect(replaceButton).toBeDefined() + expect(replaceButton.props('disabled')).toBe(false) + + mockAxios.mockClear() + replaceButton.vm.$emit('onClick') + await wrapper.vm.$nextTick() + + expect(router.resolve).toHaveBeenCalledWith({ path: '/guestnetwork/tier-id' }) + expect(execAction).toHaveBeenCalledTimes(1) + const payload = execAction.mock.calls[0][0] + expect(payload.isGroupAction).toBe(false) + expect(payload.action.api).toBe('replaceNetworkACLList') + expect(payload.action.resource).toBe(vpc) + expect(payload.action.mapping.aclid.params()).toEqual({ vpcid: 'vpc-id' }) + expect(payload.action.mapping.networkid.value()).toBe('tier-id') + expect(registeredAction.mapping.aclid.params).toBe(originalAclParams) + expect(registeredAction.mapping.networkid.value).toBe(originalNetworkValue) + expect(mockAxios).not.toHaveBeenCalled() wrapper.unmount() }) - it('keeps the overview replacement action disabled without API permission', async () => { - mockAxios.mockImplementation(request => { - const command = request.params?.command - switch (command) { - case 'listZones': - return Promise.resolve({ listzonesresponse: { zone: [{}] } }) - case 'listVPCOfferings': - return Promise.resolve({ listvpcofferingsresponse: { vpcoffering: [{}] } }) - case 'listLoadBalancers': - return Promise.resolve({ listloadbalancersresponse: { loadbalancer: [], count: 0 } }) - case 'listVirtualMachines': - return Promise.resolve({ listvirtualmachinesresponse: { virtualmachine: [], count: 0 } }) - case 'listNetworkOfferings': - return Promise.resolve({ listnetworkofferingsresponse: { networkoffering: [{ supportsinternallb: false }] } }) - case 'listNetworks': - return Promise.resolve({ listnetworksresponse: { network: [] } }) - default: - return Promise.resolve({}) - } - }) - const store = common.createMockStore({ - user: { - apis: {}, - info: {} - } - }) - const wrapper = shallowMount(VpcTiersTab, { - props: { - resource: { - id: 'vpc-id', - zoneid: 'zone-id', - vpcofferingid: 'vpc-offering-id', - network: [{ - id: 'tier-id', - zoneid: 'zone-id', - networkofferingid: 'network-offering-id', - name: 'tier', - state: 'Implemented', - cidr: '10.0.0.0/24', - aclid: 'acl-id', - aclname: 'ACL', - service: [] - }] - } - }, - global: { - renderStubDefaultSlot: true, - plugins: [store], - mocks: { - $t: key => key, - $router: { - push: jest.fn() - } - }, - provide: { - parentFetchData: jest.fn() - } - } - }) - + it('keeps the shortcut disabled and does not dispatch without API permission', async () => { + const { wrapper } = createWrapper({ permission: false }) await flushPromises() - const replaceButton = wrapper.findAllComponents(TooltipButton) - .find(button => button.props('tooltip') === 'label.replace.acl') + const replaceButton = findReplaceButton(wrapper) expect(replaceButton).toBeDefined() expect(replaceButton.props('disabled')).toBe(true) + + wrapper.vm.handleOpenReplaceAclAction(vpc.network[0]) + expect(execAction).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it.each([ + ['missing', null], + ['hidden', createReplaceAction({ show: () => false })], + ['disabled', createReplaceAction({ disabled: () => true })], + ['non-data-view', createReplaceAction({ dataView: false })] + ])('does not dispatch a %s registered action', async (name, action) => { + const { wrapper } = createWrapper({ action }) + await flushPromises() + + wrapper.vm.handleOpenReplaceAclAction(vpc.network[0]) + expect(execAction).not.toHaveBeenCalled() wrapper.unmount() }) })