From b4bf0ef26e10d078ffee09f3db990e40b0be1a33 Mon Sep 17 00:00:00 2001 From: Gary Date: Wed, 21 Oct 2026 04:23:37 +0200 Subject: [PATCH 1/4] feat(apps): open the VM page on a given VM, and the console from outside it --- src/components/VirtualMachines/VmPage.spec.js | 18 +++++++ src/components/VirtualMachines/VmPage.vue | 6 ++- src/components/VirtualMachines/VmTab.vue | 9 +++- .../VirtualMachines/__fixtures__/page.js | 1 + src/components/VirtualMachines/entry.js | 31 +++++++++-- src/components/VirtualMachines/entry.spec.js | 53 +++++++++++++++++++ 6 files changed, 113 insertions(+), 5 deletions(-) diff --git a/src/components/VirtualMachines/VmPage.spec.js b/src/components/VirtualMachines/VmPage.spec.js index 2a7aded9..bf28eb97 100644 --- a/src/components/VirtualMachines/VmPage.spec.js +++ b/src/components/VirtualMachines/VmPage.spec.js @@ -915,3 +915,21 @@ describe('an answer to a call about a VM that comes late', () => { expect(states()).toEqual([]) }) }) + +// A VM tile of the app grid that cannot show a console opens the page on its VM. +describe('the page opened on a VM', () => { + beforeEach(() => { + document.body.innerHTML = '' + vi.clearAllMocks() + }) + + it('selects the VM it was opened on', async () => { + const vms = [aVm({ id: 'vm-1', title: 'Alpha' }), aVm({ id: 'vm-2', title: 'Beta', state: 'shut-off' })] + const { wrapper } = await mountPage({ vms, props: { selectId: 'vm-2' } }) + + expect(wrapper.find('.vm-item.is-selected').text()).toContain('Beta') + // opening it on a VM starts nothing + expect(vmApi.action).not.toHaveBeenCalled() + wrapper.unmount() + }) +}) diff --git a/src/components/VirtualMachines/VmPage.vue b/src/components/VirtualMachines/VmPage.vue index 61403362..6b22a337 100644 --- a/src/components/VirtualMachines/VmPage.vue +++ b/src/components/VirtualMachines/VmPage.vue @@ -114,6 +114,10 @@ export default { OsIcon, VmDetail, }, + props: { + // the VM to show first: the app grid's tile of a VM with no console + selectId: { type: String, default: '' }, + }, emits: ['close'], data() { return { @@ -122,7 +126,7 @@ export default { order: [], busy: {}, images: [], - selectedId: '', + selectedId: this.selectId, loading: true, error: '', // How many times each VM was asked for or dropped. An answer that comes diff --git a/src/components/VirtualMachines/VmTab.vue b/src/components/VirtualMachines/VmTab.vue index bf341466..5d225089 100644 --- a/src/components/VirtualMachines/VmTab.vue +++ b/src/components/VirtualMachines/VmTab.vue @@ -1,7 +1,7 @@ @@ -13,6 +13,13 @@ export default { components: { VmPage, }, + computed: { + // #/vms?vm=: the tab opened from a VM tile of the app grid + selectId() { + const vm = this.$route?.query?.vm + return typeof vm === 'string' ? vm : '' + }, + }, mounted() { this.previousTitle = document.title document.title = `${this.$t('Virtual Machines')} · ${this.previousTitle}` diff --git a/src/components/VirtualMachines/__fixtures__/page.js b/src/components/VirtualMachines/__fixtures__/page.js index f4027c3b..7637196b 100644 --- a/src/components/VirtualMachines/__fixtures__/page.js +++ b/src/components/VirtualMachines/__fixtures__/page.js @@ -80,6 +80,7 @@ export async function mountPage(options = {}) { const api = fakeApi() const socket = fakeSocket() const wrapper = mount(VmPage, { + props: options.props, attachTo: document.body, global: { plugins: [Buefy, i18n, [socketPlugin, socket]], mocks: { $api: api } }, }) diff --git a/src/components/VirtualMachines/entry.js b/src/components/VirtualMachines/entry.js index 8b5f555f..1b7643ff 100644 --- a/src/components/VirtualMachines/entry.js +++ b/src/components/VirtualMachines/entry.js @@ -7,6 +7,7 @@ import { VMS_LAUNCH_ID, builtinOpensInNewTab } from '@/mixins/app/appLaunchPrefe // loaded when first opened: a box without the module never downloads the page const VmPage = defineAsyncComponent(() => import('./VmPage.vue')) +const VmConsoleModal = defineAsyncComponent(() => import('./VmConsoleModal.vue')) let probe = null @@ -54,10 +55,11 @@ export function forgetVirtualMachines() { } // Opens the page in a modal, or on a tab of its own when the App launching -// setting sends apps, or this one, to a new tab. -export function openVirtualMachines(buefy, state) { +// setting sends apps, or this one, to a new tab. With a VM's id, on that VM. +export function openVirtualMachines(buefy, state, vmId = '') { if (builtinOpensInNewTab(VMS_LAUNCH_ID, state)) { - window.open(`${window.location.pathname}#/vms`, '_blank') + const query = vmId ? `?vm=${encodeURIComponent(vmId)}` : '' + window.open(`${window.location.pathname}#/vms${query}`, '_blank') return null } @@ -68,5 +70,28 @@ export function openVirtualMachines(buefy, state) { scroll: 'keep', animation: 'zoom-in', customClass: 'vm-modal', + props: { selectId: vmId }, }) } + +// A VM's console, full screen, as the page opens it. Esc belongs to the VM: the +// console closes from its own button only. +export function openVmConsole(buefy, vm) { + return buefy.modal.open({ + component: VmConsoleModal, + fullScreen: true, + canCancel: [], + customClass: 'vm-console-modal', + props: { vm }, + }) +} + +// Every VM, by title, for the app grid's tiles. The client and the helpers are +// loaded on the first call: only a grid that has the module makes one. +export function listVirtualMachines() { + return Promise.all([import('./api'), import('./vms')]).then(([{ default: vmApi }, { applyOrder }]) => + vmApi.list().then((res) => { + const vms = (res.data?.data || []).filter(vm => typeof vm?.id === 'string') + return applyOrder(vms, []).sorted + })) +} diff --git a/src/components/VirtualMachines/entry.spec.js b/src/components/VirtualMachines/entry.spec.js index 3fecbf2d..018035ff 100644 --- a/src/components/VirtualMachines/entry.spec.js +++ b/src/components/VirtualMachines/entry.spec.js @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' // The card shows only where the module answers through the gateway; a 404 @@ -40,3 +41,55 @@ describe('hasVirtualMachines', () => { expect(get).toHaveBeenCalledTimes(2) }) }) + +// The app grid's tiles: one per VM, by title, each opening its console or the +// page on that VM. +describe('the VM tiles of the app grid', () => { + beforeEach(() => { + get.mockReset() + }) + + it('lists the VMs by title, numbers as numbers, and drops what is not a VM', async () => { + get.mockResolvedValue({ data: { data: [ + { id: 'b', title: 'VM 10', state: 'running' }, + { id: 'a', title: 'VM 2', state: 'shut-off' }, + { id: 42, title: 'not a VM' }, + null, + ] } }) + const { listVirtualMachines } = await freshEntry() + + const vms = await listVirtualMachines() + + expect(get).toHaveBeenCalledWith('/v2/virt_management/vms') + expect(vms.map(vm => vm.id)).toEqual(['a', 'b']) + }) + + it('opens the page on the VM it is given', async () => { + const open = vi.fn(() => ({ close: vi.fn() })) + const { openVirtualMachines } = await freshEntry() + + openVirtualMachines({ modal: { open } }, {}, 'vm-2') + + expect(open.mock.calls[0][0].props).toEqual({ selectId: 'vm-2' }) + }) + + it('opens the page on its own tab on the VM, when the setting says so', async () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + const { openVirtualMachines } = await freshEntry() + + openVirtualMachines({ modal: { open: vi.fn() } }, { appLaunchInIframe: false, appLaunchExceptions: [] }, 'a/b c') + + expect(open).toHaveBeenCalledWith(expect.stringMatching(/#\/vms\?vm=a%2Fb%20c$/), '_blank') + open.mockRestore() + }) + + it('opens the console the way the page does, closed by its own button only', async () => { + const open = vi.fn() + const { openVmConsole } = await freshEntry() + const vm = { id: 'vm-1', title: 'Debian', state: 'running' } + + openVmConsole({ modal: { open } }, vm) + + expect(open.mock.calls[0][0]).toMatchObject({ fullScreen: true, canCancel: [], customClass: 'vm-console-modal', props: { vm } }) + }) +}) From 46f5006b3cd3e13dc61d4baec1cc215ddc096a92 Mon Sep 17 00:00:00 2001 From: Gary Date: Wed, 21 Oct 2026 04:23:37 +0200 Subject: [PATCH 2/4] feat(apps): a tile per virtual machine in the app grid, opening its console --- src/assets/lang/en_US.json | 4 +- src/assets/lang/fr_FR.json | 4 +- src/components/Apps/AppSection.spec.js | 153 +++++++++++++++- src/components/Apps/AppSection.vue | 82 ++++++++- src/components/VirtualMachines/VmTile.spec.js | 119 ++++++++++++ src/components/VirtualMachines/VmTile.vue | 171 ++++++++++++++++++ 6 files changed, 526 insertions(+), 7 deletions(-) create mode 100644 src/components/VirtualMachines/VmTile.spec.js create mode 100644 src/components/VirtualMachines/VmTile.vue diff --git a/src/assets/lang/en_US.json b/src/assets/lang/en_US.json index 2e5cf83c..660e261b 100644 --- a/src/assets/lang/en_US.json +++ b/src/assets/lang/en_US.json @@ -2508,5 +2508,7 @@ "{seconds} s": "{seconds} s", "Exited with an error": "Exited with an error", "The settings are applied but could not be kept for good.": "The settings are applied but could not be kept for good.", - "System app": "System app" + "System app": "System app", + "Console of {name}": "Console of {name}", + "{name}, {state}: open in Virtual Machines": "{name}, {state}: open in Virtual Machines" } diff --git a/src/assets/lang/fr_FR.json b/src/assets/lang/fr_FR.json index 0939950d..d7db425b 100644 --- a/src/assets/lang/fr_FR.json +++ b/src/assets/lang/fr_FR.json @@ -2508,5 +2508,7 @@ "{seconds} s": "{seconds} s", "Exited with an error": "Arrêté avec une erreur", "The settings are applied but could not be kept for good.": "Les réglages sont appliqués, mais n'ont pas pu être conservés durablement.", - "System app": "Application système" + "System app": "Application système", + "Console of {name}": "Console de {name}", + "{name}, {state}: open in Virtual Machines": "{name}, {state} : ouvrir dans Machines virtuelles" } diff --git a/src/components/Apps/AppSection.spec.js b/src/components/Apps/AppSection.spec.js index 3b74ef38..1b89ca9a 100644 --- a/src/components/Apps/AppSection.spec.js +++ b/src/components/Apps/AppSection.spec.js @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { describe, expect, it, vi } from 'vitest' -import { forgetVirtualMachines, hasVirtualMachines } from '@/components/VirtualMachines/entry' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { forgetVirtualMachines, hasVirtualMachines, listVirtualMachines } from '@/components/VirtualMachines/entry' import AppSection from '@/components/Apps/AppSection.vue' import GitAppModal from '@/components/Apps/GitAppModal.vue' @@ -15,6 +15,7 @@ vi.mock('@/components/VirtualMachines/entry', async importOriginal => ({ ...await importOriginal(), hasVirtualMachines: vi.fn(), forgetVirtualMachines: vi.fn(), + listVirtualMachines: vi.fn(), vmApplication: () => ({ name: 'Virtual Machines' }), })) @@ -229,3 +230,151 @@ describe('app section built-in cards', () => { expect(vm.appList.find(app => app.name === 'Statistics')).toMatchObject({ app_type: 'system', status: 'running' }) }) }) + +// One tile per VM, right after the Virtual Machines card, by title. They are not +// apps: they never reach the saved order, whatever is dragged where. +describe('app section VM tiles', () => { + const aVm = (id, title = id, state = 'running') => ({ id, title, state }) + function section(names, vms = []) { + const vm = { + appList: names.map(name => ({ name })), + vms, + $api: { users: { setCustomStorage: vi.fn() } }, + $buefy: { toast: { open: vi.fn() } }, + } + Object.defineProperty(vm, 'gridList', { + get: () => AppSection.computed.gridList.get.call(vm), + set: list => AppSection.computed.gridList.set.call(vm, list), + }) + vm.pollVms = AppSection.methods.pollVms.bind(vm) + vm.saveSortData = AppSection.methods.saveSortData.bind(vm) + return vm + } + const shown = vm => vm.gridList.map(item => item.vmTile ? `tile:${item.vm.title}` : item.name) + const hide = (hidden) => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden }) + } + + afterEach(() => { + vi.useRealTimers() + hide(false) + listVirtualMachines.mockReset() + }) + + it('(1, 4) shows a tile per VM right after the Virtual Machines card', () => { + const vm = section(['App Store', 'Virtual Machines', 'Files'], [aVm('a', 'Alpha'), aVm('b', 'Beta')]) + + expect(shown(vm)).toEqual(['App Store', 'Virtual Machines', 'tile:Alpha', 'tile:Beta', 'Files']) + }) + + it('(3) shows no tile and asks for no VM when the probe says there is no module', async () => { + const vm = section(['App Store', 'Files'], [aVm('a')]) + + await vm.pollVms() + + expect(listVirtualMachines).not.toHaveBeenCalled() + expect(shown(vm)).toEqual(['App Store', 'Files']) + }) + + it('(3) shows no tile and no toast when the list cannot be read', async () => { + listVirtualMachines.mockRejectedValue(new Error('502')) + const vm = section(['Virtual Machines'], [aVm('a')]) + + await vm.pollVms() + + expect(shown(vm)).toEqual(['Virtual Machines']) + expect(vm.$buefy.toast.open).not.toHaveBeenCalled() + }) + + it('(4) keeps the order payload to the apps, whatever was dragged where', () => { + const vm = section(['App Store', 'Virtual Machines', 'Files', 'jellyfin'], [aVm('a'), aVm('b')]) + const [store, vms, a, b, files, jellyfin] = vm.gridList + + // jellyfin dropped between the tiles, and a tile dragged to the front + vm.gridList = [b, store, vms, a, jellyfin, files] + vm.saveSortData() + + expect(vm.$api.users.setCustomStorage).toHaveBeenCalledWith('app_order', { data: ['App Store', 'Virtual Machines', 'jellyfin', 'Files'] }) + // the tiles go back after their card + expect(shown(vm)).toEqual(['App Store', 'Virtual Machines', 'tile:a', 'tile:b', 'jellyfin', 'Files']) + }) + + it('(5) follows the states, and a VM that is deleted goes', async () => { + vi.useFakeTimers() + const vm = section(['Virtual Machines']) + listVirtualMachines.mockResolvedValueOnce([aVm('a'), aVm('b')]).mockResolvedValueOnce([aVm('a', 'a', 'shut-off')]) + + await vm.pollVms() + expect(shown(vm)).toEqual(['Virtual Machines', 'tile:a', 'tile:b']) + await vi.advanceTimersByTimeAsync(5000) + + expect(vm.vms).toEqual([aVm('a', 'a', 'shut-off')]) + AppSection.beforeUnmount.call({ ...vm, $EventBus: { $off: vi.fn() } }) + }) + + it('(3) reads the VMs as soon as the card joins the grid, and drops them with it', () => { + const pollVms = vi.fn() + + AppSection.watch.hasVmCard.call({ pollVms }) + + expect(pollVms).toHaveBeenCalledTimes(1) + expect(AppSection.computed.hasVmCard.call({ appList: [{ name: 'Virtual Machines' }] })).toBe(true) + expect(AppSection.computed.hasVmCard.call({ appList: [{ name: 'Files' }] })).toBe(false) + }) + + it('(5) asks once at a time', async () => { + let answer + listVirtualMachines.mockReturnValue(new Promise((resolve) => { + answer = resolve + })) + const vm = section(['Virtual Machines']) + + const first = vm.pollVms() + vm.pollVms() + answer([]) + await first + + expect(listVirtualMachines).toHaveBeenCalledTimes(1) + clearTimeout(vm.vmPollTimer) + }) + + it('(5) does not ask while the tab is hidden, and asks again once it is shown', async () => { + vi.useFakeTimers() + listVirtualMachines.mockResolvedValue([]) + const vm = section(['Virtual Machines']) + hide(true) + + await vm.pollVms() + await vi.advanceTimersByTimeAsync(30000) + expect(listVirtualMachines).not.toHaveBeenCalled() + + hide(false) + await AppSection.methods.onVisibility.call(vm) + expect(listVirtualMachines).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(5000) + expect(listVirtualMachines).toHaveBeenCalledTimes(2) + + // hidden again: the timer is dropped + hide(true) + AppSection.methods.onVisibility.call(vm) + await vi.advanceTimersByTimeAsync(30000) + expect(listVirtualMachines).toHaveBeenCalledTimes(2) + }) + + it('(5) stops for good when the grid goes, timer and listener with it', async () => { + vi.useFakeTimers() + listVirtualMachines.mockResolvedValue([]) + const vm = section(['Virtual Machines']) + vm.onVisibility = AppSection.methods.onVisibility.bind(vm) + const removed = vi.spyOn(document, 'removeEventListener') + await vm.pollVms() + + AppSection.beforeUnmount.call(Object.assign(vm, { $EventBus: { $off: vi.fn() } })) + await vi.advanceTimersByTimeAsync(30000) + + expect(listVirtualMachines).toHaveBeenCalledTimes(1) + expect(removed).toHaveBeenCalledWith('visibilitychange', vm.onVisibility) + expect(vi.getTimerCount()).toBe(0) + removed.mockRestore() + }) +}) diff --git a/src/components/Apps/AppSection.vue b/src/components/Apps/AppSection.vue index 0b04db03..71a3b197 100644 --- a/src/components/Apps/AppSection.vue +++ b/src/components/Apps/AppSection.vue @@ -40,7 +40,7 @@ their own grid while loading. --> + + From dc0bbaf9229ef2ae7fb4b7666312942d8d787320 Mon Sep 17 00:00:00 2001 From: Gary Date: Wed, 21 Oct 2026 04:35:52 +0200 Subject: [PATCH 3/4] fix(apps): keep VM tiles out of the drag targets, ride out a failed poll, read the state fresh on click --- src/components/Apps/AppSection.spec.js | 137 +++++++++++++++++- src/components/Apps/AppSection.vue | 47 +++++- src/components/VirtualMachines/VmTile.spec.js | 79 +++++++++- src/components/VirtualMachines/VmTile.vue | 39 ++++- src/components/VirtualMachines/entry.js | 15 ++ src/components/VirtualMachines/entry.spec.js | 38 ++++- 6 files changed, 338 insertions(+), 17 deletions(-) diff --git a/src/components/Apps/AppSection.spec.js b/src/components/Apps/AppSection.spec.js index 1b89ca9a..ed9263d3 100644 --- a/src/components/Apps/AppSection.spec.js +++ b/src/components/Apps/AppSection.spec.js @@ -280,7 +280,10 @@ describe('app section VM tiles', () => { listVirtualMachines.mockRejectedValue(new Error('502')) const vm = section(['Virtual Machines'], [aVm('a')]) - await vm.pollVms() + for (let i = 0; i < 3; i++) { + await vm.pollVms() + clearTimeout(vm.vmPollTimer) + } expect(shown(vm)).toEqual(['Virtual Machines']) expect(vm.$buefy.toast.open).not.toHaveBeenCalled() @@ -378,3 +381,135 @@ describe('app section VM tiles', () => { removed.mockRestore() }) }) + +// The tiles are not drop targets: an app is dropped among the apps only, so it +// never lands where the tiles are redrawn over it. +describe('app section VM tiles out of the drag', () => { + const tileItem = { name: 'vm-tile:a', vmTile: true, vm: { id: 'a' } } + const move = (vm, element, willInsertAfter = false) => + AppSection.methods.checkMove.call(vm, { relatedContext: { element }, willInsertAfter }) + + it('(1) starts no drag on a tile, and lets its click through', () => { + const options = AppSection.computed.dragOptions.call({}) + + expect(options.filter).toBe('.vm-tile-item') + expect(options.preventOnFilter).toBe(false) + }) + + it('(1) rejects a move over a tile', () => { + expect(move({ vms: [{ id: 'a' }] }, tileItem)).toBe(false) + expect(move({ vms: [{ id: 'a' }] }, tileItem, true)).toBe(false) + }) + + it('(1) rejects a drop right after the Virtual Machines card while tiles follow it', () => { + const card = { name: 'Virtual Machines' } + + expect(move({ vms: [{ id: 'a' }] }, card, true)).toBe(false) + expect(move({ vms: [{ id: 'a' }] }, card, false)).toBe(true) + expect(move({ vms: [] }, card, true)).toBe(true) + }) + + it('(1) lets an app move among the apps', () => { + expect(move({ vms: [{ id: 'a' }] }, { name: 'Files' })).toBe(true) + expect(move({ vms: [{ id: 'a' }] }, { name: 'Files' }, true)).toBe(true) + }) +}) + +describe('app section VM tiles riding out a failed poll', () => { + const aVm = (id, state = 'running') => ({ id, title: id, state }) + function section(vms = []) { + const vm = { appList: [{ name: 'Virtual Machines' }], vms } + vm.pollVms = AppSection.methods.pollVms.bind(vm) + return vm + } + afterEach(() => { + vi.useRealTimers() + listVirtualMachines.mockReset() + }) + const poll = async (vm) => { + await vm.pollVms() + clearTimeout(vm.vmPollTimer) + } + + it('(2) keeps the last list through one and two failed polls, and drops it on the third', async () => { + listVirtualMachines.mockRejectedValue(new Error('502')) + const vm = section([aVm('a')]) + + await poll(vm) + expect(vm.vms).toEqual([aVm('a')]) + await poll(vm) + expect(vm.vms).toEqual([aVm('a')]) + await poll(vm) + expect(vm.vms).toEqual([]) + }) + + it('(2) counts again from nothing once a poll works', async () => { + const vm = section([aVm('a')]) + listVirtualMachines.mockRejectedValueOnce(new Error('502')).mockRejectedValueOnce(new Error('502')) + .mockResolvedValueOnce([aVm('b')]) + .mockRejectedValueOnce(new Error('502')).mockRejectedValueOnce(new Error('502')) + + for (let i = 0; i < 5; i++) { + await poll(vm) + } + + expect(vm.vms).toEqual([aVm('b')]) + }) + + it('(2) drops the list at once when the module is not there', async () => { + listVirtualMachines.mockRejectedValue({ response: { status: 404 } }) + const vm = section([aVm('a')]) + + await poll(vm) + + expect(vm.vms).toEqual([]) + }) + + it('(2) drops the list at once when the card goes', async () => { + const vm = section([aVm('a')]) + vm.appList = [] + + await poll(vm) + + expect(vm.vms).toEqual([]) + }) + + it('(4) leaves the list as it is when nothing changed', async () => { + const vms = [aVm('a')] + listVirtualMachines.mockResolvedValue([aVm('a')]) + const vm = section(vms) + + await poll(vm) + + expect(vm.vms).toBe(vms) + }) + + it('(4) gives up on a read that hangs after 15 s, counts it as failed and polls again', async () => { + vi.useFakeTimers() + listVirtualMachines.mockReturnValueOnce(new Promise(() => {})).mockResolvedValue([aVm('b')]) + const vm = section([aVm('a')]) + + vm.pollVms() + await vi.advanceTimersByTimeAsync(15000) + expect(vm.vms).toEqual([aVm('a')]) + expect(vm.vmPolling).toBe(false) + expect(vm.vmFailures).toBe(1) + + await vi.advanceTimersByTimeAsync(5000) + expect(listVirtualMachines).toHaveBeenCalledTimes(2) + expect(vm.vms).toEqual([aVm('b')]) + clearTimeout(vm.vmPollTimer) + }) + + it('(5) declares its polling state when the grid is made', () => { + const vm = { getList: vi.fn(), isMobile: () => false, $EventBus: { $on: vi.fn() }, pollVms: vi.fn(), onVisibility: vi.fn() } + const added = vi.spyOn(document, 'addEventListener') + + AppSection.created.call(vm) + clearInterval(vm.ListRefreshTimer) + + expect(vm).toMatchObject({ vmPollStopped: false, vmPolling: false, vmPollTimer: null, vmFailures: 0 }) + expect(added).toHaveBeenCalledWith('visibilitychange', vm.onVisibility) + added.mockRestore() + }) +}) diff --git a/src/components/Apps/AppSection.vue b/src/components/Apps/AppSection.vue index 71a3b197..7002457c 100644 --- a/src/components/Apps/AppSection.vue +++ b/src/components/Apps/AppSection.vue @@ -42,14 +42,16 @@ v-bind="dragOptions" v-model="gridList" :draggable="draggable" + :move="checkMove" class="app-list contextmenu-canvas" item-key="name" tag="div" @end="onSortEnd" @start="drag = true"> +