diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts index 87b16755d2..ee35ab6ab4 100644 --- a/web/__test__/components/Onboarding/OnboardingModal.test.ts +++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts @@ -92,6 +92,10 @@ vi.mock('@unraid/ui', () => ({ emits: ['update:modelValue'], template: '
', }, + Spinner: { + name: 'Spinner', + template: '
', + }, })); vi.mock('@heroicons/vue/24/solid', () => ({ @@ -309,6 +313,41 @@ describe('OnboardingModal.vue', () => { expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith(); }); + it('shows a loading state while exit confirmation is closing the modal', async () => { + let closeModalDeferred: + | { + promise: Promise; + resolve: (value: boolean) => void; + } + | undefined; + onboardingModalStoreState.closeModal.mockImplementation(() => { + let resolve!: (value: boolean) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + closeModalDeferred = { promise, resolve }; + return promise; + }); + + const wrapper = mountComponent(); + + await wrapper.find('button[aria-label="Close onboarding"]').trigger('click'); + await flushPromises(); + + const exitButton = wrapper.findAll('button').find((button) => button.text().includes('Exit setup')); + expect(exitButton).toBeTruthy(); + await exitButton!.trigger('click'); + await flushPromises(); + + expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Closing setup...'); + + if (closeModalDeferred) { + closeModalDeferred.resolve(true); + } + await flushPromises(); + }); + it('closes onboarding without frontend completion logic', async () => { const wrapper = mountComponent(); diff --git a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts index 6b14ea49b5..405a0d1621 100644 --- a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts @@ -30,6 +30,10 @@ vi.mock('@unraid/ui', () => ({ template: '', }, + Spinner: { + name: 'Spinner', + template: '
', + }, })); vi.mock('@headlessui/vue', () => ({ diff --git a/web/components.d.ts b/web/components.d.ts index 46664bb279..f38c0f1e37 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -100,6 +100,7 @@ declare module 'vue' { OnboardingCoreSettingsStep: typeof import('./src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue')['default'] OnboardingInternalBootStep: typeof import('./src/components/Onboarding/steps/OnboardingInternalBootStep.vue')['default'] OnboardingLicenseStep: typeof import('./src/components/Onboarding/steps/OnboardingLicenseStep.vue')['default'] + OnboardingLoadingState: typeof import('./src/components/Onboarding/components/OnboardingLoadingState.vue')['default'] OnboardingModal: typeof import('./src/components/Onboarding/OnboardingModal.vue')['default'] OnboardingNextStepsStep: typeof import('./src/components/Onboarding/steps/OnboardingNextStepsStep.vue')['default'] OnboardingOverviewStep: typeof import('./src/components/Onboarding/steps/OnboardingOverviewStep.vue')['default'] diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue index bf29252810..09b6128519 100644 --- a/web/src/components/Onboarding/OnboardingModal.vue +++ b/web/src/components/Onboarding/OnboardingModal.vue @@ -10,6 +10,7 @@ import type { BrandButtonProps } from '@unraid/ui'; import type { StepId } from '~/components/Onboarding/stepRegistry.js'; import type { Component } from 'vue'; +import OnboardingLoadingState from '~/components/Onboarding/components/OnboardingLoadingState.vue'; import { DOCS_URL_ACCOUNT, DOCS_URL_LICENSING_FAQ } from '~/components/Onboarding/constants'; import OnboardingSteps from '~/components/Onboarding/OnboardingSteps.vue'; import { stepComponents } from '~/components/Onboarding/stepRegistry.js'; @@ -131,6 +132,7 @@ const showModal = computed(() => { return isVisible.value; }); const showExitConfirmDialog = ref(false); +const isClosingModal = ref(false); const getNearestVisibleStepId = (stepId: StepId): StepId | null => { const currentOrderIndex = STEP_ORDER.indexOf(stepId); @@ -293,6 +295,14 @@ const exitDialogDescription = computed(() => ? t('onboarding.modal.exit.internalBootDescription') : t('onboarding.modal.exit.description') ); +const isAwaitingStepData = computed(() => onboardingContextLoading.value && !currentStepComponent.value); +const showModalLoadingState = computed(() => isClosingModal.value || isAwaitingStepData.value); +const loadingStateTitle = computed(() => + isClosingModal.value ? t('onboarding.modal.closing.title') : t('onboarding.loading.title') +); +const loadingStateDescription = computed(() => + isClosingModal.value ? t('onboarding.modal.closing.description') : t('onboarding.loading.description') +); const handleTimezoneComplete = async () => { await goToNextStep(); @@ -319,16 +329,27 @@ const handleInternalBootSkip = async () => { }; const handleExitIntent = () => { + if (isClosingModal.value) { + return; + } showExitConfirmDialog.value = true; }; const handleExitCancel = () => { + if (isClosingModal.value) { + return; + } showExitConfirmDialog.value = false; }; const handleExitConfirm = async () => { showExitConfirmDialog.value = false; - await closeModal(); + isClosingModal.value = true; + try { + await closeModal(); + } finally { + isClosingModal.value = false; + } }; const handleActivationSkip = async () => { @@ -438,20 +459,28 @@ const currentStepProps = computed>(() => { type="button" class="bg-background/90 text-foreground hover:bg-muted fixed top-5 right-8 z-20 rounded-md p-1.5 shadow-sm transition-colors" :aria-label="t('onboarding.modal.closeAriaLabel')" + :disabled="isClosingModal" @click="handleExitIntent" >
- - - + +
@@ -483,6 +512,7 @@ const currentStepProps = computed>(() => {
-
- {{ t('onboarding.internalBootStep.loadingOptions') }} +
+
t('onboarding.pluginsStep.nextStep')); -
-
-
-

- {{ plugin.name }} -

-

- {{ plugin.description }} -

-
- - + +
+
- {{ - t('onboarding.pluginsStep.enablePluginAria', { name: plugin.name }) - }} -
+ + - + > + {{ + t('onboarding.pluginsStep.enablePluginAria', { name: plugin.name }) + }} +
diff --git a/web/src/locales/en.json b/web/src/locales/en.json index f2a96de909..470a8b1f82 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -121,7 +121,11 @@ "onboarding.console.title": "Setup Console", "onboarding.console.waiting": "Waiting...", "onboarding.console.technicalDetails": "Technical details", + "onboarding.loading.title": "Loading setup...", + "onboarding.loading.description": "We're preparing the next step of setup.", "onboarding.modal.closeAriaLabel": "Close onboarding", + "onboarding.modal.closing.title": "Closing setup...", + "onboarding.modal.closing.description": "You can skip setup now and continue from the dashboard later.", "onboarding.modal.exit.title": "Exit onboarding?", "onboarding.modal.exit.description": "You can skip setup now and continue from the dashboard later.", "onboarding.modal.exit.internalBootDescription": "Internal boot has been configured. You'll now see a data partition on the selected boot drive, but Unraid will not switch to that boot device until you restart with both your current USB boot device and the selected internal boot drive connected. Please restart manually when convenient to finish applying this change.", @@ -146,6 +150,7 @@ "onboarding.pluginsStep.plugins.fixCommonProblems.description": "Diagnostic tool to help you identify and resolve configuration issues.", "onboarding.pluginsStep.plugins.tailscale.name": "Tailscale", "onboarding.pluginsStep.plugins.tailscale.description": "Zero-config VPN. Securely access your server from anywhere.", + "onboarding.pluginsStep.loading.description": "Checking which recommended plugins are already installed.", "onboarding.internalBootStep.stepTitle": "Setup Boot", "onboarding.internalBootStep.stepDescription": "Choose USB or storage drive boot", "onboarding.internalBootStep.title": "Setup Boot",