fix: resolve issue with "Continue" button when updating (#1852)

- Replaced BrandLoading with BrandButton in UpdateOs component for
better user interaction.
- Updated test cases to reflect changes in rendering logic, ensuring the
account button is displayed when no reboot is pending.
- Added functionality to navigate to account update when the button is
clicked.
- Introduced WEBGUI_REDIRECT URL for handling update installations in
the store logic.
This commit is contained in:
Eli Bosley
2025-12-19 11:44:19 -05:00
committed by GitHub
parent bb9b539732
commit d099e7521d
5 changed files with 123 additions and 30 deletions

View File

@@ -13,7 +13,9 @@ import { createTestI18n } from '../utils/i18n';
vi.mock('@unraid/ui', () => ({ vi.mock('@unraid/ui', () => ({
PageContainer: { template: '<div><slot /></div>' }, PageContainer: { template: '<div><slot /></div>' },
BrandLoading: { template: '<div data-testid="brand-loading-mock">Loading...</div>' }, BrandButton: {
template: '<button v-bind="$attrs" @click="$emit(\'click\')"><slot /></button>',
},
})); }));
const mockAccountStore = { const mockAccountStore = {
@@ -97,7 +99,7 @@ describe('UpdateOs.standalone.vue', () => {
}); });
describe('Initial Rendering and onBeforeMount Logic', () => { describe('Initial Rendering and onBeforeMount Logic', () => {
it('shows loader and calls updateOs when path matches and rebootType is empty', async () => { it('shows account button and does not auto-redirect when path matches and rebootType is empty', async () => {
window.location.pathname = '/Tools/Update'; window.location.pathname = '/Tools/Update';
mockRebootType.value = ''; mockRebootType.value = '';
@@ -105,7 +107,7 @@ describe('UpdateOs.standalone.vue', () => {
global: { global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()], plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: { stubs: {
// Rely on @unraid/ui mock for PageContainer & BrandLoading // Rely on @unraid/ui mock for PageContainer & BrandButton
UpdateOsStatus: UpdateOsStatusStub, UpdateOsStatus: UpdateOsStatusStub,
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub, UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
}, },
@@ -114,17 +116,9 @@ describe('UpdateOs.standalone.vue', () => {
await nextTick(); await nextTick();
// When path matches and rebootType is empty, updateOs should be called expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true); expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(true);
// Since v-show is used, both elements exist in DOM expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
// The loader should be visible when showLoader is true
const loaderWrapper = wrapper.find('[data-testid="brand-loading-mock"]').element.parentElement;
expect(loaderWrapper?.style.display).not.toBe('none');
// The status should be hidden when showLoader is true
const statusWrapper = wrapper.find('[data-testid="update-os-status"]').element.parentElement;
expect(statusWrapper?.style.display).toBe('none');
}); });
it('shows status and does not call updateOs when path does not match', async () => { it('shows status and does not call updateOs when path does not match', async () => {
@@ -145,8 +139,7 @@ describe('UpdateOs.standalone.vue', () => {
await nextTick(); await nextTick();
expect(mockAccountStore.updateOs).not.toHaveBeenCalled(); expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
// Since v-show is used, both elements exist in DOM expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true); expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
}); });
@@ -168,10 +161,30 @@ describe('UpdateOs.standalone.vue', () => {
await nextTick(); await nextTick();
expect(mockAccountStore.updateOs).not.toHaveBeenCalled(); expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
// Since v-show is used, both elements exist in DOM expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true); expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
}); });
it('navigates to account update when the button is clicked', async () => {
window.location.pathname = '/Tools/Update';
mockRebootType.value = '';
const wrapper = mount(UpdateOs, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
UpdateOsStatus: UpdateOsStatusStub,
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
},
},
});
await nextTick();
await wrapper.find('[data-testid="update-os-account-button"]').trigger('click');
expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true);
});
}); });
describe('Rendering based on rebootType', () => { describe('Rendering based on rebootType', () => {

View File

@@ -4,17 +4,41 @@
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { WEBGUI_REDIRECT } from '~/helpers/urls';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useUpdateOsStore } from '~/store/updateOs'; import { useUpdateOsStore } from '~/store/updateOs';
const mockSend = vi.fn();
vi.mock('@unraid/shared-callbacks', () => ({ vi.mock('@unraid/shared-callbacks', () => ({
useCallback: vi.fn(() => ({ useCallback: vi.fn(() => ({
send: vi.fn(), send: mockSend,
watcher: vi.fn(), watcher: vi.fn(),
})), })),
})); }));
vi.mock('~/composables/preventClose', () => ({
addPreventClose: vi.fn(),
removePreventClose: vi.fn(),
}));
vi.mock('~/store/account', () => ({
useAccountStore: () => ({
accountActionStatus: 'ready',
}),
}));
vi.mock('~/store/installKey', () => ({
useInstallKeyStore: () => ({
keyInstallStatus: 'ready',
}),
}));
vi.mock('~/store/updateOsActions', () => ({
useUpdateOsActionsStore: () => ({}),
}));
vi.mock('~/composables/services/webgui', () => { vi.mock('~/composables/services/webgui', () => {
return { return {
WebguiCheckForUpdate: vi.fn().mockResolvedValue({ WebguiCheckForUpdate: vi.fn().mockResolvedValue({
@@ -104,6 +128,40 @@ describe('UpdateOs Store', () => {
expect(store.updateOsModalVisible).toBe(false); expect(store.updateOsModalVisible).toBe(false);
}); });
it('should send update install through redirect.htm', () => {
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
origin: 'https://littlebox.tail45affd.ts.net',
href: 'https://littlebox.tail45affd.ts.net/Plugins',
},
});
store.fetchAndConfirmInstall('test-sha256');
const expectedUrl = new URL(WEBGUI_REDIRECT, window.location.origin).toString();
expect(mockSend).toHaveBeenCalledWith(
expectedUrl,
[
{
sha256: 'test-sha256',
type: 'updateOs',
},
],
undefined,
'forUpc'
);
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
});
it('should handle errors when checking for updates', async () => { it('should handle errors when checking for updates', async () => {
const { WebguiCheckForUpdate } = await import('~/composables/services/webgui'); const { WebguiCheckForUpdate } = await import('~/composables/services/webgui');

View File

@@ -19,7 +19,8 @@ import { computed, onBeforeMount } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { BrandLoading, PageContainer } from '@unraid/ui'; import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import { BrandButton, PageContainer } from '@unraid/ui';
import { WEBGUI_TOOLS_UPDATE } from '~/helpers/urls'; import { WEBGUI_TOOLS_UPDATE } from '~/helpers/urls';
import UpdateOsStatus from '~/components/UpdateOs/Status.vue'; import UpdateOsStatus from '~/components/UpdateOs/Status.vue';
@@ -47,25 +48,42 @@ const subtitle = computed(() => {
return ''; return '';
}); });
/** when we're not prompting for reboot /Tools/Update will automatically send the user to account.unraid.net/server/update-os */ // Show a prompt to continue in the Account app when no reboot is pending.
const showLoader = computed( const showRedirectPrompt = computed(
() => window.location.pathname === WEBGUI_TOOLS_UPDATE && rebootType.value === '' () =>
typeof window !== 'undefined' &&
window.location.pathname === WEBGUI_TOOLS_UPDATE &&
rebootType.value === ''
); );
const openAccountUpdate = () => {
accountStore.updateOs(true);
};
onBeforeMount(() => { onBeforeMount(() => {
if (showLoader.value) {
accountStore.updateOs(true);
}
serverStore.setRebootVersion(props.rebootVersion); serverStore.setRebootVersion(props.rebootVersion);
}); });
</script> </script>
<template> <template>
<PageContainer> <PageContainer>
<div v-show="showLoader"> <div
<BrandLoading class="mx-auto my-12 max-w-[160px]" /> v-if="showRedirectPrompt"
class="mx-auto flex max-w-[720px] flex-col items-center gap-4 py-8 text-center"
>
<h1 class="text-2xl font-semibold">{{ t('updateOs.updateUnraidOs') }}</h1>
<p class="text-base leading-relaxed opacity-75">
{{ t('updateOs.update.receiveTheLatestAndGreatestFor') }}
</p>
<BrandButton
data-testid="update-os-account-button"
:icon-right="ArrowTopRightOnSquareIcon"
@click="openAccountUpdate"
>
{{ t('updateOs.update.viewAvailableUpdates') }}
</BrandButton>
</div> </div>
<div v-show="!showLoader"> <div v-else>
<UpdateOsStatus <UpdateOsStatus
:show-update-check="true" :show-update-check="true"
:title="t('updateOs.updateUnraidOs')" :title="t('updateOs.updateUnraidOs')"

View File

@@ -22,6 +22,7 @@ const UNRAID_NET_SUPPORT = new URL('/support', UNRAID_NET);
const WEBGUI_GRAPHQL = '/graphql'; const WEBGUI_GRAPHQL = '/graphql';
const WEBGUI_SETTINGS_MANAGMENT_ACCESS = '/Settings/ManagementAccess'; const WEBGUI_SETTINGS_MANAGMENT_ACCESS = '/Settings/ManagementAccess';
const WEBGUI_CONNECT_SETTINGS = `${WEBGUI_SETTINGS_MANAGMENT_ACCESS}#UnraidNetSettings`; const WEBGUI_CONNECT_SETTINGS = `${WEBGUI_SETTINGS_MANAGMENT_ACCESS}#UnraidNetSettings`;
const WEBGUI_REDIRECT = '/redirect.htm';
const WEBGUI_TOOLS_DOWNGRADE = '/Tools/Downgrade'; const WEBGUI_TOOLS_DOWNGRADE = '/Tools/Downgrade';
const WEBGUI_TOOLS_REGISTRATION = '/Tools/Registration'; const WEBGUI_TOOLS_REGISTRATION = '/Tools/Registration';
const WEBGUI_TOOLS_UPDATE = '/Tools/Update'; const WEBGUI_TOOLS_UPDATE = '/Tools/Update';
@@ -66,6 +67,7 @@ export {
DOCS_REGISTRATION_LICENSING, DOCS_REGISTRATION_LICENSING,
DOCS_REGISTRATION_REPLACE_KEY, DOCS_REGISTRATION_REPLACE_KEY,
WEBGUI_CONNECT_SETTINGS, WEBGUI_CONNECT_SETTINGS,
WEBGUI_REDIRECT,
WEBGUI_GRAPHQL, WEBGUI_GRAPHQL,
WEBGUI_SETTINGS_MANAGMENT_ACCESS, WEBGUI_SETTINGS_MANAGMENT_ACCESS,
WEBGUI_TOOLS_DOWNGRADE, WEBGUI_TOOLS_DOWNGRADE,

View File

@@ -1,6 +1,7 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { WEBGUI_REDIRECT } from '~/helpers/urls';
import dayjs, { extend } from 'dayjs'; import dayjs, { extend } from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
@@ -71,8 +72,9 @@ export const useUpdateOsStore = defineStore('updateOs', () => {
// fetchAndConfirmInstall logic // fetchAndConfirmInstall logic
const callbackStore = useCallbackActionsStore(); const callbackStore = useCallbackActionsStore();
const fetchAndConfirmInstall = (sha256: string) => { const fetchAndConfirmInstall = (sha256: string) => {
const redirectUrl = new URL(WEBGUI_REDIRECT, window.location.origin).toString();
callbackStore.send( callbackStore.send(
window.location.href, redirectUrl,
[ [
{ {
sha256, sha256,