From 6cd5e06f172d7f01c44f0eb5ece50523e122540e Mon Sep 17 00:00:00 2001 From: Ajit Mehrotra Date: Fri, 20 Mar 2026 17:07:59 -0400 Subject: [PATCH 1/3] feat(onboarding): add shared loading states - Purpose: add a reusable onboarding loading UI for modal close and server-backed onboarding steps. - Before: closing onboarding could visibly lag and some steps waited on server data without a dedicated loading state. - Problem: the modal could feel stalled or unresponsive while async onboarding work completed. - Accomplishes: shows a consistent loading experience during onboarding close, modal bootstrap waits, and plugin/internal boot data fetches; adds localized copy and test coverage. - How: introduces OnboardingLoadingState, wires it into OnboardingModal plus the internal boot and plugins steps, updates en.json strings, and extends onboarding tests. --- .../Onboarding/OnboardingModal.test.ts | 30 ++++++++ .../Onboarding/OnboardingPluginsStep.test.ts | 4 + web/components.d.ts | 1 + .../components/Onboarding/OnboardingModal.vue | 49 ++++++++++--- .../components/OnboardingLoadingState.vue | 38 ++++++++++ .../steps/OnboardingInternalBootStep.vue | 12 +-- .../steps/OnboardingPluginsStep.vue | 73 +++++++++++-------- web/src/locales/en.json | 5 ++ 8 files changed, 166 insertions(+), 46 deletions(-) create mode 100644 web/src/components/Onboarding/components/OnboardingLoadingState.vue diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts index 87b16755d2..9a95befbf3 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,32 @@ describe('OnboardingModal.vue', () => { expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith(); }); + it('shows a loading state while exit confirmation is closing the modal', async () => { + let resolveCloseModal: ((value: boolean) => void) | null = null; + onboardingModalStoreState.closeModal.mockImplementation( + () => + new Promise((resolve) => { + resolveCloseModal = resolve; + }) + ); + + 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('Loading...'); + + resolveCloseModal?.(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", From b0f412d7eca2cec8807823c7ddeac1755a6163df Mon Sep 17 00:00:00 2001 From: Ajit Mehrotra Date: Fri, 20 Mar 2026 17:17:23 -0400 Subject: [PATCH 2/3] fix(onboarding): restore web verification - Purpose: make the onboarding loading changes pass local web verification cleanly. - Before: web type-check failed on the loading-state component/test, and the updated exit-copy assertion no longer matched the localized UI. - Problem: the branch could not pass the full verification matrix even though the runtime behavior was correct. - Accomplishes: removes the stale TypeScript issue in the shared loader, updates the onboarding modal test to the new loading copy, and keeps the branch green in web. - How: drops the unused props binding in OnboardingLoadingState and tightens the close-loading test assertion in OnboardingModal.test. --- web/__test__/components/Onboarding/OnboardingModal.test.ts | 6 ++++-- .../Onboarding/components/OnboardingLoadingState.vue | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts index 9a95befbf3..91c3e1a989 100644 --- a/web/__test__/components/Onboarding/OnboardingModal.test.ts +++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts @@ -333,9 +333,11 @@ describe('OnboardingModal.vue', () => { await flushPromises(); expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true); - expect(wrapper.text()).toContain('Loading...'); + expect(wrapper.text()).toContain('Closing setup...'); - resolveCloseModal?.(true); + if (resolveCloseModal) { + resolveCloseModal(true); + } await flushPromises(); }); diff --git a/web/src/components/Onboarding/components/OnboardingLoadingState.vue b/web/src/components/Onboarding/components/OnboardingLoadingState.vue index 11fe0551d0..bc91016b02 100644 --- a/web/src/components/Onboarding/components/OnboardingLoadingState.vue +++ b/web/src/components/Onboarding/components/OnboardingLoadingState.vue @@ -1,7 +1,7 @@