fix(web): open the V2V VM settings drawer outside the wizard context

- The drawer is an overlay rendered at the app root, out of the migration page: reading the V2V connection there
  threw, so Edit settings opened nothing
- useV2vVmTargets: the targets and options the drawer edits, read from an inventory handed in; useV2vVmMapping adds
  the pool name, host and checks of the side panel on top
- useV2vVmSettingsEdit hands the drawer the inventory of the connected source
- Tests: the drawer is mounted without the wizard connection; v2vMappingConnectionProvide shared by the mapping tests
This commit is contained in:
Olivier Floch
2026-11-09 16:22:13 +01:00
parent 1667ed2a16
commit 3a41fba148
13 changed files with 116 additions and 68 deletions

File diff suppressed because one or more lines are too long

View File

@@ -20,6 +20,7 @@ import {
V2V_DISK_ROWS,
V2V_LAN_NIC,
V2V_MAPPED_VM,
V2V_MAPPING_INVENTORY,
V2V_NIC_OVERRIDES,
V2V_NIC_ROWS,
V2V_POOL_OFFERS,
@@ -60,15 +61,14 @@ vi.mock(
)
// The tables and the fields render what the drawer hands them, each covered by its own test: here only the wiring
// matters
// matters. As an overlay, the drawer renders outside the wizard: it is handed the inventory, never its connection
function mountDrawer({ vm = V2V_MAPPED_VM, listVm }: { vm?: XoV2vVm; listVm?: V2vMigrationListVm } = {}) {
return mountV2vVmMappingComponent(
(list, router, provide) =>
(list, router) =>
mount(V2vVmSettingsDrawer, {
props: { vm, list },
props: { vm, list, inventory: V2V_MAPPING_INVENTORY },
global: createGlobalTestConfig({
router,
provide,
stubs: { V2vDiskMappingTable: true, V2vNicMappingTable: true, V2vVmOptionsFields: true },
}),
attachTo: document.body,

View File

@@ -60,12 +60,13 @@ import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelBut
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import type { XoV2vVm } from '@vates/types'
import type { XoV2vInventory, XoV2vVm } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { vm, list } = defineProps<{
const { vm, list, inventory } = defineProps<{
vm: XoV2vVm
list: V2vMigrationList
inventory: XoV2vInventory | undefined
}>()
const emit = defineEmits<{
@@ -91,7 +92,7 @@ const {
setKeepMac,
setTags,
toOverrides,
} = useV2vVmMappingDraft(() => vm, { list: () => list })
} = useV2vVmMappingDraft(() => vm, { list: () => list, inventory: () => inventory })
</script>
<style lang="postcss" scoped>

View File

@@ -10,6 +10,7 @@ import V2vDestinationMappingCard from '@/modules/v2v/components/mapping/V2vDesti
import V2vVmChecksCard from '@/modules/v2v/components/mapping/V2vVmChecksCard.vue'
import V2vVmSourceCard from '@/modules/v2v/components/mapping/V2vVmSourceCard.vue'
import V2vVmOptionsCard from '@/modules/v2v/components/panel/V2vVmOptionsCard.vue'
import { useV2vConnection } from '@/modules/v2v/composables/use-v2v-connection.composable.ts'
import type { V2vNewMappingStepVm } from '@/modules/v2v/composables/use-v2v-mapping-step.composable.ts'
import { useV2vVmMapping } from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
@@ -30,5 +31,9 @@ const {
areOffersReady,
keepMac,
tags,
} = useV2vVmMapping(() => vm.vm, { list: () => vm.list, listIssue: () => vm.listIssue })
} = useV2vVmMapping(() => vm.vm, {
list: () => vm.list,
listIssue: () => vm.listIssue,
inventory: useV2vConnection().inventory,
})
</script>

View File

@@ -4,14 +4,13 @@ import { createV2vVm } from '@/test/create-v2v-inventory.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { vmDeviceCollectionsMock } from '@/test/mock-xo-vm-device-collections.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { v2vMappingConnectionProvide } from '@/test/mount-v2v-vm-mapping.ts'
import { seedV2vResumedVmCollections } from '@/test/seed-v2v-resumed-vm.ts'
import { createConnectedV2vContext, v2vConnectionProvide } from '@/test/v2v-connection-provide.ts'
import {
V2V_ACTIVE_DISK_ROWS,
V2V_ACTIVE_NIC_ROWS,
V2V_DEFAULT_NETWORK,
V2V_MAPPED_VM,
V2V_MAPPING_INVENTORY,
V2V_RESUMED_IMPORT_VM,
V2V_SSD_SR,
V2V_SYNCED_VM,
@@ -50,7 +49,7 @@ function mountResumedVm(vm: XoV2vVm = V2V_SYNCED_VM, importVm: FrontXoVm = V2V_R
seedV2vResumedVmCollections()
return mountComposableWithI18n(() => useV2vResumedVm(vm, importVm), {
provide: v2vConnectionProvide(createConnectedV2vContext({ inventory: V2V_MAPPING_INVENTORY })),
provide: v2vMappingConnectionProvide(),
})
}

View File

@@ -7,6 +7,7 @@ import type { useVmDelete } from '@/modules/vm/composables/use-vm-delete.composa
import { V2V_NEW_STEP_VM, V2V_RESUMED_STEP_VM, V2V_NOT_FOUND_STEP_VM } from '@/test/create-v2v-mapping-step-vm.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { v2vMappingConnectionProvide } from '@/test/mount-v2v-vm-mapping.ts'
import { ref, toValue } from 'vue'
const { useDelete, deleteVm } = vi.hoisted(() => ({
@@ -37,7 +38,10 @@ function mockDelete(isDeletingVm = false) {
function mountNewVmActions() {
const remove = vi.fn()
const actions = mountComposableWithI18n(() => useV2vNewVmActions(V2V_NEW_STEP_VM, { updateVm: vi.fn(), remove }))
// The drawer is opened from the mapping step, under the connection of the wizard
const actions = mountComposableWithI18n(() => useV2vNewVmActions(V2V_NEW_STEP_VM, { updateVm: vi.fn(), remove }), {
provide: v2vMappingConnectionProvide(),
})
return { actions, remove }
}
@@ -61,14 +65,13 @@ describe('useV2vNewVmActions', () => {
expect(actions.editAction).toMatchObject({ label: actions.t('action:edit-settings'), icon: 'action:edit' })
})
it('opens the settings drawer of the VM and its list', () => {
// What the drawer is opened with is told by the settings edit composable
it('opens the settings drawer', () => {
const { actions } = mountNewVmActions()
actions.editAction.onClick()
expect(overlayMock.getOverlay().open).toHaveBeenCalledWith({
props: { vm: V2V_NEW_STEP_VM.vm, list: V2V_NEW_STEP_VM.list },
})
expect(overlayMock.getOverlay().open).toHaveBeenCalledOnce()
})
})

View File

@@ -1,7 +1,8 @@
import {
useV2vVmMapping,
useV2vVmTargets,
useV2vVmMappingSetters,
type V2vVmMappingContext,
type V2vVmMappingOptions,
type V2vVmOverrides,
} from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
import type { V2vMigrationListVm } from '@/modules/v2v/types/v2v-migration-list.type.ts'
@@ -11,7 +12,10 @@ import { omit, omitBy } from 'lodash-es'
import { type MaybeRefOrGetter, ref, toValue } from 'vue'
// The settings of a VM are edited on a copy of its stored overrides, taken once: the stored list is never written
export function useV2vVmMappingDraft(rawVm: MaybeRefOrGetter<XoV2vVm>, { list }: Pick<V2vVmMappingContext, 'list'>) {
export function useV2vVmMappingDraft(
rawVm: MaybeRefOrGetter<XoV2vVm>,
{ list, inventory }: Pick<V2vVmMappingOptions, 'list' | 'inventory'>
) {
const draft = ref<V2vMigrationListVm>({ ...findV2vMigrationListVm(toValue(list), toValue(rawVm).id) })
const updateDraft: V2vVmMappingContext['updateVm'] = (_id, update) => {
@@ -24,7 +28,7 @@ export function useV2vVmMappingDraft(rawVm: MaybeRefOrGetter<XoV2vVm>, { list }:
}
return {
...useV2vVmMapping(rawVm, { list, listVm: draft }),
...useV2vVmTargets(rawVm, { list, listVm: draft, inventory }),
...useV2vVmMappingSetters(rawVm, { list, updateVm: updateDraft }),
toOverrides,
}

View File

@@ -52,8 +52,8 @@ vi.mock(
function mountVmMappingDraft(options?: MountV2vVmMappingOptions) {
return mountV2vVmMapping(
(migrationList, list) => ({
...useV2vVmMappingDraft(V2V_MAPPED_VM, { list }),
(migrationList, list, inventory) => ({
...useV2vVmMappingDraft(V2V_MAPPED_VM, { list, inventory }),
updateStoredVm: migrationList.updateVm,
}),
options

View File

@@ -1,7 +1,6 @@
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { useXoSrUtils } from '@/modules/storage-repository/composables/xo-sr-utils.composable.ts'
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { useV2vConnection } from '@/modules/v2v/composables/use-v2v-connection.composable.ts'
import type { useV2vMigrationList } from '@/modules/v2v/composables/use-v2v-migration-list.composable.ts'
import { useV2vPoolDestinationChoices } from '@/modules/v2v/composables/use-v2v-pool-destination-choices.composable.ts'
import { useV2vPoolName } from '@/modules/v2v/composables/use-v2v-pool-name.composable.ts'
@@ -21,33 +20,40 @@ import { getV2vVmChecks } from '@/modules/v2v/utils/v2v-vm-checks.util.ts'
import type { FrontXoVmTemplate } from '@/modules/vm/remote-resources/use-xo-vm-template-collection.ts'
import type { InfoAccent } from '@core/components/ui/info/UiInfo.vue'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { XoV2vDisk, XoV2vNic, XoV2vVm } from '@vates/types'
import type { XoV2vDisk, XoV2vInventory, XoV2vNic, XoV2vVm } from '@vates/types'
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
// The issue of the VM in the list, if any, is told with the checks of its source.
// `listVm` is where the overrides are read from, the VM of the list by default: the `updateVm` of the setters must
// write to it
export type V2vVmMappingContext = {
list: MaybeRefOrGetter<V2vMigrationList>
updateVm: ReturnType<typeof useV2vMigrationList>['updateVm']
listIssue?: MaybeRefOrGetter<V2vAccentedLabel<InfoAccent> | undefined>
listVm?: MaybeRefOrGetter<V2vMigrationListVm>
}
export type V2vVmOverrides = Omit<V2vMigrationListVm, 'id'>
// The inventory the disks and NICs are located in: handed in, since the settings drawer renders outside the wizard.
// `listVm` is where the overrides are read from, the VM of the list by default: a draft hands its copy, which the
// `updateVm` of its setters must write to
type V2vVmTargetsOptions = Pick<V2vVmMappingContext, 'list'> & {
listVm?: MaybeRefOrGetter<V2vMigrationListVm>
inventory: MaybeRefOrGetter<XoV2vInventory | undefined>
}
// The issue of the VM in the list, if any, is told with the checks of its source
export type V2vVmMappingOptions = Omit<V2vVmTargetsOptions, 'listVm'> & {
listIssue?: MaybeRefOrGetter<V2vAccentedLabel<InfoAccent> | undefined>
}
function toTargetOption<TId>({ id, name_label }: { id: TId; name_label: string }): V2vTargetOption<TId> {
return { id, label: name_label }
}
// A new VM is mapped on the pool of its list, its overrides on top of the list defaults
export function useV2vVmMapping(
// The targets and options of a new VM on the pool of its list, its overrides on top of the list defaults: what its
// settings drawer edits, so nothing here reads the wizard
export function useV2vVmTargets(
rawVm: MaybeRefOrGetter<XoV2vVm>,
{ list: rawList, listIssue, listVm: rawListVm }: Omit<V2vVmMappingContext, 'updateVm'>
{ list: rawList, listVm: rawListVm, inventory: rawInventory }: V2vVmTargetsOptions
) {
const { inventory } = useV2vConnection()
const { getCheckInfo, getPortGroupParts } = useV2vVmLabels()
const { getPortGroupParts } = useV2vVmLabels()
const { getSrSelectLabel } = useXoSrUtils()
@@ -55,18 +61,14 @@ export function useV2vVmMapping(
const list = toComputed(rawList)
const poolId = computed(() => list.value.poolId)
const inventory = toComputed(rawInventory)
const { choices: offers, areChoicesReady: areOffersReady } = useV2vPoolDestinationChoices(poolId)
const poolName = useV2vPoolName(poolId)
const { choices: offers, areChoicesReady: areOffersReady } = useV2vPoolDestinationChoices(() => list.value.poolId)
const listVm = computed(() => toValue(rawListVm) ?? findV2vMigrationListVm(list.value, vm.value.id))
const mapping = computed(() => getV2vVmMapping(vm.value, listVm.value, list.value.defaults, offers.value))
const hostName = computed(() => findV2vVmHost(inventory.value, vm.value)?.name)
const diskRows = computed(() =>
mapping.value.disks.map(
({ disk, sr }): V2vDiskMappingRow => ({
@@ -93,13 +95,6 @@ export function useV2vVmMapping(
const template = computed(() => mapping.value.template)
const checks = computed(() => {
const vmChecks = getV2vVmChecks(vm.value).map(check => getCheckInfo(check))
const issue = toValue(listIssue)
return issue === undefined ? vmChecks : [...vmChecks, issue]
})
const srOptions = computed(() =>
offers.value.srs.map((sr): V2vTargetOption<FrontXoSr['id']> => ({ id: sr.id, label: getSrSelectLabel(sr) }))
)
@@ -113,9 +108,6 @@ export function useV2vVmMapping(
const tags = computed(() => listVm.value.tags ?? V2V_DEFAULT_TAGS)
return {
poolName,
hostName,
checks,
diskRows,
nicRows,
template,
@@ -128,10 +120,32 @@ export function useV2vVmMapping(
}
}
// What the side panel of a new VM shows: its targets and options, plus the pool, the source host and the checks
export function useV2vVmMapping(rawVm: MaybeRefOrGetter<XoV2vVm>, options: V2vVmMappingOptions) {
const { getCheckInfo } = useV2vVmLabels()
const vm = toComputed(rawVm)
const list = toComputed(options.list)
const poolName = useV2vPoolName(() => list.value.poolId)
const hostName = computed(() => findV2vVmHost(toValue(options.inventory), vm.value)?.name)
const checks = computed(() => {
const vmChecks = getV2vVmChecks(vm.value).map(check => getCheckInfo(check))
const issue = toValue(options.listIssue)
return issue === undefined ? vmChecks : [...vmChecks, issue]
})
return { poolName, hostName, checks, ...useV2vVmTargets(rawVm, options) }
}
// Each change goes through `updateVm`, as an override of the list defaults
export function useV2vVmMappingSetters(
rawVm: MaybeRefOrGetter<XoV2vVm>,
{ list: rawList, updateVm }: Pick<V2vVmMappingContext, 'list' | 'updateVm'>
{ list: rawList, updateVm }: V2vVmMappingContext
) {
const vm = toComputed(rawVm)

View File

@@ -67,8 +67,8 @@ type MountOptions = MountV2vVmMappingOptions & {
function mountVmMapping({ vm = V2V_MAPPED_VM, listIssue, ...options }: MountOptions = {}) {
return mountV2vVmMapping(
(migrationList, list) => ({
...useV2vVmMapping(vm, { list, listIssue }),
(migrationList, list, inventory) => ({
...useV2vVmMapping(vm, { list, listIssue, inventory }),
...useV2vVmMappingSetters(vm, { list, updateVm: migrationList.updateVm }),
getSrSelectLabel: useXoSrUtils().getSrSelectLabel,
}),

View File

@@ -1,3 +1,4 @@
import { useV2vConnection } from '@/modules/v2v/composables/use-v2v-connection.composable.ts'
import type { V2vVmMappingContext, V2vVmOverrides } from '@/modules/v2v/composables/use-v2v-vm-mapping.composable.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
@@ -5,15 +6,18 @@ import type { XoV2vVm } from '@vates/types'
import { reactiveComputed } from '@vueuse/core'
import type { MaybeRefOrGetter } from 'vue'
// The drawer confirms the whole settings of the VM: they replace its stored overrides
// The drawer confirms the whole settings of the VM: they replace its stored overrides. As an overlay, it renders
// outside the wizard: it is handed the inventory of the connected source
export function useV2vVmSettingsEdit(
rawVm: MaybeRefOrGetter<XoV2vVm>,
{ list: rawList, updateVm }: Pick<V2vVmMappingContext, 'list' | 'updateVm'>
{ list: rawList, updateVm }: V2vVmMappingContext
) {
const vm = toComputed(rawVm)
const list = toComputed(rawList)
const { inventory } = useV2vConnection()
const { open } = useOverlay({
component: () => import('@/modules/v2v/components/drawer/V2vVmSettingsDrawer.vue'),
events: {
@@ -27,6 +31,7 @@ export function useV2vVmSettingsEdit(
const props = reactiveComputed(() => ({
vm: vm.value,
list: list.value,
inventory: inventory.value,
}))
function editVmSettings() {

View File

@@ -2,7 +2,12 @@ import type { V2vVmOverrides } from '@/modules/v2v/composables/use-v2v-vm-mappin
import { useV2vVmSettingsEdit } from '@/modules/v2v/composables/use-v2v-vm-settings-edit.composable.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountV2vVmMapping, readV2vStoredListVm } from '@/test/mount-v2v-vm-mapping.ts'
import { V2V_DEBIAN_TEMPLATE, V2V_DISK_OVERRIDES, V2V_MAPPED_VM } from '@/test/v2v-mapping-fixtures.ts'
import {
V2V_DEBIAN_TEMPLATE,
V2V_DISK_OVERRIDES,
V2V_MAPPED_VM,
V2V_MAPPING_INVENTORY,
} from '@/test/v2v-mapping-fixtures.ts'
type OverlayOptions = {
events: {
@@ -34,12 +39,14 @@ function getOverlayOptions(): OverlayOptions {
}
describe('editVmSettings', () => {
it('opens the drawer with the VM and its list', () => {
it('opens the drawer with the VM, its list and the inventory of the connected source', () => {
const { result } = mountVmSettingsEdit()
result.editVmSettings()
expect(overlayMock.getOverlay().open).toHaveBeenCalledWith({ props: { vm: V2V_MAPPED_VM, list: result.list } })
expect(overlayMock.getOverlay().open).toHaveBeenCalledWith({
props: { vm: V2V_MAPPED_VM, list: result.list, inventory: V2V_MAPPING_INVENTORY },
})
})
it('closes the drawer when it is cancelled', () => {

View File

@@ -22,7 +22,7 @@ import {
} from '@/test/v2v-mapping-fixtures.ts'
import { readV2vMigrationListStorage, seedV2vMigrationList } from '@/test/v2v-migration-list-storage.ts'
import { V2V_LIST_POOL } from '@/test/v2v-wizard-fixtures.ts'
import type { XoV2vVm } from '@vates/types'
import type { XoV2vInventory, XoV2vVm } from '@vates/types'
import type { Router } from 'vue-router'
/** The overrides of the mapped VM in the list: on its data disk and on its VLAN NIC. */
@@ -68,6 +68,11 @@ function seedV2vVmMappingList(listVm: V2vMigrationListVm) {
return list
}
/** The connection of the wizard, its source connected on the mapping inventory. */
export function v2vMappingConnectionProvide() {
return v2vConnectionProvide(createConnectedV2vContext({ inventory: V2V_MAPPING_INVENTORY }))
}
export type MountV2vVmMappingOptions = {
listVm?: V2vMigrationListVm
isSourceConnected?: boolean
@@ -75,11 +80,16 @@ export type MountV2vVmMappingOptions = {
/**
* Stores a list holding `listVm` on a pool offering the mapping fixtures (another pool offers one target of each
* kind), then mounts `useMapping` with the migration list of that pool and the mapping inventory as connected source.
* kind), then mounts `useMapping` with the migration list of that pool and the mapping inventory as connected source,
* also handed to `useMapping` (`undefined` without a connected source).
* Needs the destination collections mocked with `mock-xo-collections.ts`.
*/
export function mountV2vVmMapping<TResult extends Record<string, unknown>>(
useMapping: (migrationList: ReturnType<typeof useV2vMigrationList>, list: () => V2vMigrationList) => TResult,
useMapping: (
migrationList: ReturnType<typeof useV2vMigrationList>,
list: () => V2vMigrationList,
inventory: XoV2vInventory | undefined
) => TResult,
{ listVm = V2V_OVERRIDDEN_LIST_VM, isSourceConnected = true }: MountV2vVmMappingOptions = {}
) {
const storedList = seedV2vVmMappingList(listVm)
@@ -88,13 +98,13 @@ export function mountV2vVmMapping<TResult extends Record<string, unknown>>(
() => {
const migrationList = useV2vMigrationList(storedList.sourceId, storedList.poolId)
return useMapping(migrationList, () => migrationList.list.value as V2vMigrationList)
return useMapping(
migrationList,
() => migrationList.list.value as V2vMigrationList,
isSourceConnected ? V2V_MAPPING_INVENTORY : undefined
)
},
{
provide: v2vConnectionProvide(
isSourceConnected ? createConnectedV2vContext({ inventory: V2V_MAPPING_INVENTORY }) : undefined
),
}
{ provide: isSourceConnected ? v2vMappingConnectionProvide() : v2vConnectionProvide() }
)
}