refactor(web): extract the VM delete flow into useVmDelete

This commit is contained in:
Olivier Floch
2026-10-28 13:02:59 +01:00
parent 04d86aca15
commit fb1da9305e
8 changed files with 256 additions and 54 deletions

View File

@@ -49,7 +49,7 @@ const poolStats = createPoolStats({
const dashboard = createPoolDashboardTopFiveUsage({ cpu: [createHostCpuUsage()] }, { cpu: [createVmCpuUsage()] })
```
Shared _helper_ factories live there too — `mount-composable.ts`, `mount-chart-card.ts`, `create-enhanced-data-helpers.ts`, `global-test-config.ts`, `create-test-router.ts`, `create-job-result.ts` (what a mocked `defineJob` composable returns), `create-action-item.ts` (a menu / button `LeafActionItem`), `create-plugin-actions.ts` (what a mocked `useXoPluginActions` returns), `find-rendered-values.ts`, `find-tags.ts` (the labels of the rendered tags), `find-titles.ts` (the card and section titles), `find-tooltip-content.ts` (the tooltip an element shows on hover), `get-button.ts` (by its label, or the `aria-label` of an icon button: `findButton` / `getButton` the `<button>` to click, `getUiButton` the `UiButton` whose `busy` / `disabled` props the app sets), `get-checkbox.ts` (the input of a `UiCheckbox` by its label), `drawer.ts` (submit or cancel a drawer, read its buttons), `select.ts` (open a `VtsSelect`, read or pick its options, read the selection it shows), `mount-json-schema-form.ts` (a JSON schema form, or a composable run in one of its fields), `json-schema-item-label.ts` (the labels of the items of a JSON schema list), `find-parts.ts`, `find-input-labels.ts`, `find-input-messages.ts`, `find-accented-labels.ts` (`findAccentedTags` / `findAccentedInfos`: each `UiTag` / `UiInfo` as `{ label, accent }`), `find-card-title.ts` (`findCardTitle`, the whole title; `findCardTitleLabel`, its label alone), `create-select-bindings.ts`, `find-select-stub-labels.ts`, `mount-with-select.ts`, `form-messages.ts`, `credentials-error-wiring.ts`, `v2v-target-cells.ts`, `stub-fetch-response.ts`, `stub-v2v-invalid-credentials-response.ts` (the 422 the V2V routes answer to rejected credentials), `silence-console-error.ts`, `create-v2v-wizard-router.ts` (and `V2V_VMS_FILTER_QUERY_KEY`, the route key of the VMs step filter), `connect-v2v-source.ts` (`connectV2vSource`: sets the V2V connection and inventory a wizard step reads, once Pinia is active), `mount-on-v2v-wizard.ts` (`mountOnV2vWizard`: stores a migration list, routes the wizard to a step, on the pool of the list unless given a query, mounts with that router, then connects a source), `mount-v2v-vms-step.ts` (`mountV2vVmsStep`: mounts a composable on the VMs step, with a stored list and a connected source), `create-v2v-vm-row.ts` (also `getV2vVmNames` / `getV2vVmIds`, the names / ids of V2V VMs or rows, in order), `v2v-vms-fixtures.ts` (two datastores and one inventory VM per case the VMs step tells apart: on one datastore, on both, unsupported, in the list, and the list holding that last one), `v2v-mapping-fixtures.ts` (an inventory VM of two disks, one with a snapshot chain, and two NICs, and the pool offers of a mapping: the targets of the list defaults plus one other SR, network and template; the inventory the VM is read from, the target options its selects list and the disk and NIC rows of its mapping), `v2v-wizard-fixtures.ts` (the connected source, a list stored for a pool without VMs then with some, that pool and another one), `find-table-cells.ts` (see [Testing components](./testing-components.md#tables)), `query-builder-filters.ts` (`UNPARSABLE_FILTER`), `local-storage.ts`, `v2v-remembered-connections-storage.ts`, `v2v-migration-list-storage.ts`, `fill-v2v-connection-form.ts`, `choose-v2v-destination.ts` (choose the pool of the V2V destination form, or a complete destination), `seed-v2v-destination-pools.ts` (the collections a V2V destination pool offers its prefill from), `v2v-connection-provide.ts` (the V2V connection the migration page provides), `v2v-connection-reader.ts` (a component that reads the V2V connection its parent provides), `now.ts` (the fixed date of the fake timers), `group-by.ts`, `i18n.ts` and `linear-chart-stub.ts`. The shared module mocks (`mock-xo-*.ts`, `mock-delete-modal.ts`) live there as well, see [mocking.md](./mocking.md).
Shared _helper_ factories live there too — `mount-composable.ts`, `mount-chart-card.ts`, `create-enhanced-data-helpers.ts`, `global-test-config.ts`, `create-test-router.ts`, `create-job-result.ts` (what a mocked `defineJob` composable returns), `create-action-item.ts` (a menu / button `LeafActionItem`), `create-plugin-actions.ts` (what a mocked `useXoPluginActions` returns), `find-rendered-values.ts`, `find-tags.ts` (the labels of the rendered tags), `find-titles.ts` (the card and section titles), `find-tooltip-content.ts` (the tooltip an element shows on hover), `get-button.ts` (by its label, or the `aria-label` of an icon button: `findButton` / `getButton` the `<button>` to click, `getUiButton` the `UiButton` whose `busy` / `disabled` props the app sets), `get-checkbox.ts` (the input of a `UiCheckbox` by its label), `drawer.ts` (submit or cancel a drawer, read its buttons), `select.ts` (open a `VtsSelect`, read or pick its options, read the selection it shows), `mount-json-schema-form.ts` (a JSON schema form, or a composable run in one of its fields), `json-schema-item-label.ts` (the labels of the items of a JSON schema list), `find-parts.ts`, `find-input-labels.ts`, `find-input-messages.ts`, `find-accented-labels.ts` (`findAccentedTags` / `findAccentedInfos`: each `UiTag` / `UiInfo` as `{ label, accent }`), `find-card-title.ts` (`findCardTitle`, the whole title; `findCardTitleLabel`, its label alone), `create-select-bindings.ts`, `find-select-stub-labels.ts`, `mount-with-select.ts`, `form-messages.ts`, `credentials-error-wiring.ts`, `v2v-target-cells.ts`, `stub-fetch-response.ts`, `stub-v2v-invalid-credentials-response.ts` (the 422 the V2V routes answer to rejected credentials), `fetch-requests.ts` (`hasRequested(method, endpoint)`: whether the stubbed `fetch` sent that request to the REST API), `silence-console-error.ts`, `create-v2v-wizard-router.ts` (and `V2V_VMS_FILTER_QUERY_KEY`, the route key of the VMs step filter), `connect-v2v-source.ts` (`connectV2vSource`: sets the V2V connection and inventory a wizard step reads, once Pinia is active), `mount-on-v2v-wizard.ts` (`mountOnV2vWizard`: stores a migration list, routes the wizard to a step, on the pool of the list unless given a query, mounts with that router, then connects a source), `mount-v2v-vms-step.ts` (`mountV2vVmsStep`: mounts a composable on the VMs step, with a stored list and a connected source), `create-v2v-vm-row.ts` (also `getV2vVmNames` / `getV2vVmIds`, the names / ids of V2V VMs or rows, in order), `v2v-vms-fixtures.ts` (two datastores and one inventory VM per case the VMs step tells apart: on one datastore, on both, unsupported, in the list, and the list holding that last one), `v2v-mapping-fixtures.ts` (an inventory VM of two disks, one with a snapshot chain, and two NICs, and the pool offers of a mapping: the targets of the list defaults plus one other SR, network and template; the inventory the VM is read from, the target options its selects list and the disk and NIC rows of its mapping), `v2v-wizard-fixtures.ts` (the connected source, a list stored for a pool without VMs then with some, that pool and another one), `find-table-cells.ts` (see [Testing components](./testing-components.md#tables)), `query-builder-filters.ts` (`UNPARSABLE_FILTER`), `local-storage.ts`, `v2v-remembered-connections-storage.ts`, `v2v-migration-list-storage.ts`, `fill-v2v-connection-form.ts`, `choose-v2v-destination.ts` (choose the pool of the V2V destination form, or a complete destination), `seed-v2v-destination-pools.ts` (the collections a V2V destination pool offers its prefill from), `v2v-connection-provide.ts` (the V2V connection the migration page provides), `v2v-connection-reader.ts` (a component that reads the V2V connection its parent provides), `now.ts` (the fixed date of the fake timers), `group-by.ts`, `i18n.ts` and `linear-chart-stub.ts`. The shared module mocks (`mock-xo-*.ts`, `mock-delete-modal.ts`) live there as well, see [mocking.md](./mocking.md).
Each factory returns a **fully-populated** object of the real front-end type and accepts a `Partial<T>` of overrides, spread last, so a test only states the fields relevant to its case:

View File

@@ -41,7 +41,7 @@ Collections always read together share one mock module exporting each of their c
The same goes for `mock-xo-v2v-vm-rows-collections.ts`, the VM, task and VM snapshot collections the V2V VM rows are built from: `v2vVmRowsCollectionsMock` holds `vms`, `tasks`, `snapshots` and their `are…Ready` flags (true after a reset; set one to false to hold that collection as still loading).
`mock-delete-modal.ts` stands for `useDeleteModal`, whose overlay a unit test cannot answer: `deleteModalMock.answer('onConfirm' | 'onCancel')` sets how the next confirmation is answered, `deleteModalMock.open` is the spy to read the props it was opened with, and `deleteModalMock.reset()` goes in `beforeEach`.
`mock-delete-modal.ts` stands for `useDeleteModal`, whose overlay a unit test cannot answer: `deleteModalMock.answer('onConfirm' | 'onCancel')` sets how the next confirmation is answered, `deleteModalMock.confirm()` runs the `onConfirm` event of the last confirmation for a caller that passes its events instead of awaiting the answer, `deleteModalMock.open` is the spy to read the props it was opened with, and `deleteModalMock.reset()` goes in `beforeEach`.
`mock-xo-routes.ts` stands for `useXoRoutes`, the XO 5 routes the app fetches: an XO 5 link reads `https://xo5.example.com/#<path>`.

View File

@@ -0,0 +1,64 @@
import VmDeleteButton from '@/modules/vm/components/actions/delete/VmDeleteButton.vue'
import type { useVmDelete } from '@/modules/vm/composables/use-vm-delete.composable.ts'
import { createVm } from '@/test/create-vm.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import MenuTrigger from '@core/components/menu/MenuTrigger.vue'
import { mount } from '@vue/test-utils'
import { ref, toValue } from 'vue'
const { useDelete, deleteVm } = vi.hoisted(() => ({
useDelete: vi.fn(),
deleteVm: vi.fn(),
}))
// The composable calls the REST API and opens modals
vi.mock(import('@/modules/vm/composables/use-vm-delete.composable.ts'), () => ({
useVmDelete: useDelete as unknown as typeof useVmDelete,
}))
beforeEach(() => {
deleteVm.mockReset()
useDelete.mockReset()
mockDelete()
})
function mockDelete(isDeletingVm = false) {
useDelete.mockReturnValue({ deleteVm, isDeletingVm: ref(isDeletingVm) })
}
const VM = createVm()
function mountButton() {
return mount(VmDeleteButton, { props: { vm: VM }, global: createGlobalTestConfig() })
}
it('offers to delete the VM', () => {
const menuItem = mountButton().getComponent(MenuItem)
expect([menuItem.text(), menuItem.props('icon'), menuItem.props('accent')]).toEqual([
t('action:delete'),
'action:delete',
'danger',
])
})
it('deletes the VM it is given', () => {
mountButton()
expect(toValue(useDelete.mock.lastCall?.[0])).toEqual(VM)
})
it('deletes the VM when clicking the menu item', async () => {
await mountButton().getComponent(MenuTrigger).trigger('click')
expect(deleteVm).toHaveBeenCalledOnce()
})
it('shows the menu item as busy while the VM is being deleted', () => {
mockDelete(true)
expect(mountButton().getComponent(MenuItem).props('busy')).toBe(true)
})

View File

@@ -1,20 +1,14 @@
<template>
<MenuItem icon="action:delete" :busy="isRunning" accent="danger" @click="openModal()">
<MenuItem icon="action:delete" :busy="isDeletingVm" accent="danger" @click="deleteVm()">
{{ t('action:delete') }}
</MenuItem>
</template>
<script setup lang="ts">
import { useXoVmUtils } from '@/modules/vm/composables/xo-vm-utils.composable.ts'
import { useXoVmDeleteJob } from '@/modules/vm/jobs/xo-vm-delete.job.ts'
import { useVmDelete } from '@/modules/vm/composables/use-vm-delete.composable.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { extractVmHostId } from '@/modules/vm/utils/xo-vm.util.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { useBlockedModal } from '@core/composables/modals/use-blocked-modal.ts'
import { useDeleteModal } from '@core/composables/modals/use-delete-modal.ts'
import { useRedirectAfterDelete } from '@core/composables/redirect-after-delete.composable.ts'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
const { vm } = defineProps<{
vm: FrontXoVm
@@ -22,48 +16,5 @@ const { vm } = defineProps<{
const { t } = useI18n()
const { run: deleteVm, canRun, isRunning } = useXoVmDeleteJob(() => [vm])
const { xo5VmAdvancedHref } = useXoVmUtils(() => vm)
const route = useRoute()
const { redirectIfOnObjectPage } = useRedirectAfterDelete({
isOnObjectPage: () => route.name?.includes('/vm/') ?? false,
redirectTo: () => {
const hostId = extractVmHostId(vm)
if (hostId !== undefined) {
return { name: '/host/[id]/vms', params: { id: hostId } }
}
return { name: '/pool/[id]/vms', params: { id: vm.$pool } }
},
})
const { open: openDeleteModal } = useDeleteModal()
const { open: openBlockedModal } = useBlockedModal()
function openModal() {
if (!canRun.value) {
return openBlockedModal({
props: {
blockedOperation: 'destroy',
href: xo5VmAdvancedHref.value,
},
})
}
return openDeleteModal({
props: {
subject: vm.name_label,
description: t('vm-delete-warning'),
confirmLabel: t('action:delete-n-vms', { n: 1 }),
},
events: {
onConfirm: () => deleteVm({ detached: true, onSuccess: redirectIfOnObjectPage }),
},
})
}
const { deleteVm, isDeletingVm } = useVmDelete(() => vm)
</script>

View File

@@ -0,0 +1,64 @@
import { useXoVmUtils } from '@/modules/vm/composables/xo-vm-utils.composable.ts'
import { useXoVmDeleteJob } from '@/modules/vm/jobs/xo-vm-delete.job.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { extractVmHostId } from '@/modules/vm/utils/xo-vm.util.ts'
import { useBlockedModal } from '@core/composables/modals/use-blocked-modal.ts'
import { useDeleteModal } from '@core/composables/modals/use-delete-modal.ts'
import { useRedirectAfterDelete } from '@core/composables/redirect-after-delete.composable.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
export function useVmDelete(rawVm: MaybeRefOrGetter<FrontXoVm>) {
const vm = toComputed(rawVm)
const { t } = useI18n()
const { run, canRun, isRunning: isDeletingVm } = useXoVmDeleteJob(() => [vm.value])
const { xo5VmAdvancedHref } = useXoVmUtils(vm)
const route = useRoute()
const { redirectIfOnObjectPage } = useRedirectAfterDelete({
isOnObjectPage: () => route.name?.includes('/vm/') ?? false,
redirectTo: () => {
const hostId = extractVmHostId(vm.value)
if (hostId !== undefined) {
return { name: '/host/[id]/vms', params: { id: hostId } }
}
return { name: '/pool/[id]/vms', params: { id: vm.value.$pool } }
},
})
const { open: openDeleteModal } = useDeleteModal()
const { open: openBlockedModal } = useBlockedModal()
function deleteVm() {
if (!canRun.value) {
return openBlockedModal({
props: {
blockedOperation: 'destroy',
href: xo5VmAdvancedHref.value,
},
})
}
return openDeleteModal({
props: {
subject: vm.value.name_label,
description: t('vm-delete-warning'),
confirmLabel: t('action:delete-n-vms', { n: 1 }),
},
events: {
onConfirm: () => run({ detached: true, onSuccess: redirectIfOnObjectPage }),
},
})
}
return { deleteVm, isDeletingVm }
}

View File

@@ -0,0 +1,111 @@
import { useVmDelete } from '@/modules/vm/composables/use-vm-delete.composable.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createTestRouter } from '@/test/create-test-router.ts'
import { createVm } from '@/test/create-vm.ts'
import { hasRequested } from '@/test/fetch-requests.ts'
import { t } from '@/test/i18n.ts'
import { deleteModalMock } from '@/test/mock-delete-modal.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { stubFetchResponse } from '@/test/stub-fetch-response.ts'
import type { useBlockedModal } from '@core/composables/modals/use-blocked-modal.ts'
import { flushPromises } from '@vue/test-utils'
import type { Router } from 'vue-router'
const { openBlockedModal } = vi.hoisted(() => ({ openBlockedModal: vi.fn() }))
vi.mock(import('@core/composables/modals/use-delete-modal.ts'), () => import('@/test/mock-delete-modal.ts'))
vi.mock(import('@core/composables/modals/use-blocked-modal.ts'), () => ({
useBlockedModal: (() => ({ open: openBlockedModal })) as unknown as typeof useBlockedModal,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
beforeEach(() => {
deleteModalMock.reset()
openBlockedModal.mockReset()
})
const VM = createVm()
const POOL_VM = createVm({ $container: VM.$pool })
function mountVmDelete(vm: FrontXoVm = VM, router: Router = createTestRouter()) {
return mountComposable(() => useVmDelete(vm), { router }).wrapper.vm
}
function hasDeleted(vm: FrontXoVm) {
return hasRequested('DELETE', `vms/${vm.id}`)
}
// Where the app is once the VM is deleted from that page
async function deleteFrom(path: string, vm: FrontXoVm = VM) {
stubFetchResponse(200, {})
const router = createTestRouter()
await router.push(path)
mountVmDelete(vm, router).deleteVm()
deleteModalMock.confirm()
await flushPromises()
return router.currentRoute.value.fullPath
}
describe('deleteVm', () => {
it('asks to confirm the deletion of the VM, by its name', () => {
mountVmDelete().deleteVm()
expect(deleteModalMock.open.mock.lastCall?.[0].props).toEqual({
subject: VM.name_label,
description: t('vm-delete-warning'),
confirmLabel: t('action:delete-n-vms', { n: 1 }),
})
})
it('deletes nothing until confirmed', () => {
mountVmDelete().deleteVm()
expect(hasDeleted(VM)).toBe(false)
})
it('deletes the VM once confirmed', () => {
mountVmDelete().deleteVm()
deleteModalMock.confirm()
expect(hasDeleted(VM)).toBe(true)
})
it('tells why a VM whose deletion is blocked cannot be deleted, without asking to confirm', () => {
mountVmDelete(
createVm({ blockedOperations: { destroy: 'protected' } as FrontXoVm['blockedOperations'] })
).deleteVm()
expect(openBlockedModal.mock.lastCall?.[0]).toEqual({
props: { blockedOperation: 'destroy', href: `https://xo5.example.com/#/vms/${VM.id}/advanced` },
})
expect(deleteModalMock.open).not.toHaveBeenCalled()
})
it.each([
['the VMs of its host', VM, `/host/${VM.$container}/vms`],
['the VMs of its pool when it runs on no host', POOL_VM, `/pool/${VM.$pool}/vms`],
])('leaves the page of the deleted VM for %s', async (_, vm, path) => {
expect(await deleteFrom(`/vm/${vm.id}/dashboard`, vm)).toBe(path)
})
it('stays on a page that is not the page of the VM', async () => {
expect(await deleteFrom(`/pool/${VM.$pool}/vms`)).toBe(`/pool/${VM.$pool}/vms`)
})
})
describe('isDeletingVm', () => {
it('is true while the VM is being deleted', () => {
const vmDelete = mountVmDelete()
vmDelete.deleteVm()
deleteModalMock.confirm()
expect(vmDelete.isDeletingVm).toBe(true)
})
})

View File

@@ -0,0 +1,8 @@
import { BASE_URL } from '@/shared/utils/fetch.util.ts'
// Whether the stubbed `fetch` (see `setup.ts`, `stub-fetch-response.ts`) sent this request to the REST API
export function hasRequested(method: string, endpoint: string) {
return vi
.mocked(fetch)
.mock.calls.some(([url, options]) => url === `${BASE_URL}/${endpoint}` && options?.method === method)
}

View File

@@ -8,6 +8,10 @@ export const deleteModalMock = {
answer(event: DeleteModalEvent) {
this.open.mockResolvedValue({ event })
},
// For a confirmation that runs its `onConfirm` event instead of awaiting the answer
confirm() {
this.open.mock.lastCall?.[0].events.onConfirm()
},
reset() {
this.open.mockReset()
},