Skip to content

Commit 776c8cc

Browse files
feat(onboarding): add shared loading states (#1945)
## Summary This PR adds a shared onboarding loading state and reuses it anywhere the onboarding modal can feel stalled while waiting on async work. ## Problem We had a few spots in onboarding where the UI could appear to hang: - Closing the modal from the `x` could lag while the close flow completed. - Some onboarding steps depend on server data before they can render useful content. - Those waits did not have a consistent loading treatment, so the modal could feel unresponsive. ## What changed - Added a reusable `OnboardingLoadingState` component for onboarding-specific loading UI. - Updated `OnboardingModal` to swap into that loading state while the exit flow is finishing. - Reused the same loading state when the modal is waiting on enough onboarding context to resolve the next step. - Reused the loading state in the internal boot step while internal boot context is loading. - Reused the loading state in the plugins step while installed plugin data is loading. - Added English localization strings for the new loading copy. - Extended onboarding tests to cover the new loading behavior. ## User-facing behavior Users can now see the loading state in these places: - After confirming exit from the onboarding modal via the `x` flow. - While the modal is still waiting on onboarding context needed to determine which step to render. - In the plugins step while the installed plugins query is still pending. - In the internal boot step while storage boot options are still loading. ## Not in scope The core settings step still renders immediately from default or draft values and then hydrates server-backed values as queries resolve. This PR does not add a full loading screen for that step. ## Files of interest - `web/src/components/Onboarding/components/OnboardingLoadingState.vue` - `web/src/components/Onboarding/OnboardingModal.vue` - `web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue` - `web/src/components/Onboarding/steps/OnboardingPluginsStep.vue` - `web/src/locales/en.json` - `web/__test__/components/Onboarding/OnboardingModal.test.ts` - `web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts` ## Verification Passed locally: - `cd web && pnpm type-check` - `cd web && pnpm lint` - `cd web && pnpm exec vitest run __test__/components/Onboarding/OnboardingModal.test.ts __test__/components/Onboarding/internalBoot.test.ts __test__/components/Onboarding/OnboardingPluginsStep.test.ts` - `cd api && pnpm type-check` - `cd api && pnpm lint` - `cd api && pnpm test` Known local note: - `cd web && pnpm test` still fails in this worktree because of a local `@unraid/ui` build/module-resolution issue in the worktree setup. The failure reproduced across unrelated suites and did not point back to the onboarding change itself. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a reusable onboarding loading-state UI and used it in the modal, plugin list, and internal boot step. * Modal now shows a closing/loading state and disables interaction while the close flow is in progress. * **Tests** * Tests added/updated to assert the modal shows the loading state while close is pending; test mocks include a spinner. * **Localization** * Added i18n strings for loading and closing states. * **Chores** * Registered the new global component type. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent e064de7 commit 776c8cc

File tree

8 files changed

+175
-46
lines changed

8 files changed

+175
-46
lines changed

web/__test__/components/Onboarding/OnboardingModal.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ vi.mock('@unraid/ui', () => ({
9292
emits: ['update:modelValue'],
9393
template: '<div v-if="modelValue" data-testid="dialog"><slot /></div>',
9494
},
95+
Spinner: {
96+
name: 'Spinner',
97+
template: '<div data-testid="loading-spinner" />',
98+
},
9599
}));
96100

97101
vi.mock('@heroicons/vue/24/solid', () => ({
@@ -309,6 +313,41 @@ describe('OnboardingModal.vue', () => {
309313
expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith();
310314
});
311315

316+
it('shows a loading state while exit confirmation is closing the modal', async () => {
317+
let closeModalDeferred:
318+
| {
319+
promise: Promise<boolean>;
320+
resolve: (value: boolean) => void;
321+
}
322+
| undefined;
323+
onboardingModalStoreState.closeModal.mockImplementation(() => {
324+
let resolve!: (value: boolean) => void;
325+
const promise = new Promise<boolean>((innerResolve) => {
326+
resolve = innerResolve;
327+
});
328+
closeModalDeferred = { promise, resolve };
329+
return promise;
330+
});
331+
332+
const wrapper = mountComponent();
333+
334+
await wrapper.find('button[aria-label="Close onboarding"]').trigger('click');
335+
await flushPromises();
336+
337+
const exitButton = wrapper.findAll('button').find((button) => button.text().includes('Exit setup'));
338+
expect(exitButton).toBeTruthy();
339+
await exitButton!.trigger('click');
340+
await flushPromises();
341+
342+
expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
343+
expect(wrapper.text()).toContain('Closing setup...');
344+
345+
if (closeModalDeferred) {
346+
closeModalDeferred.resolve(true);
347+
}
348+
await flushPromises();
349+
});
350+
312351
it('closes onboarding without frontend completion logic', async () => {
313352
const wrapper = mountComponent();
314353

web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ vi.mock('@unraid/ui', () => ({
3030
template:
3131
'<button data-testid="brand-button" :disabled="disabled" @click="$emit(\'click\')">{{ text }}</button>',
3232
},
33+
Spinner: {
34+
name: 'Spinner',
35+
template: '<div data-testid="loading-spinner" />',
36+
},
3337
}));
3438

3539
vi.mock('@headlessui/vue', () => ({

web/components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ declare module 'vue' {
100100
OnboardingCoreSettingsStep: typeof import('./src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue')['default']
101101
OnboardingInternalBootStep: typeof import('./src/components/Onboarding/steps/OnboardingInternalBootStep.vue')['default']
102102
OnboardingLicenseStep: typeof import('./src/components/Onboarding/steps/OnboardingLicenseStep.vue')['default']
103+
OnboardingLoadingState: typeof import('./src/components/Onboarding/components/OnboardingLoadingState.vue')['default']
103104
OnboardingModal: typeof import('./src/components/Onboarding/OnboardingModal.vue')['default']
104105
OnboardingNextStepsStep: typeof import('./src/components/Onboarding/steps/OnboardingNextStepsStep.vue')['default']
105106
OnboardingOverviewStep: typeof import('./src/components/Onboarding/steps/OnboardingOverviewStep.vue')['default']

web/src/components/Onboarding/OnboardingModal.vue

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { BrandButtonProps } from '@unraid/ui';
1010
import type { StepId } from '~/components/Onboarding/stepRegistry.js';
1111
import type { Component } from 'vue';
1212
13+
import OnboardingLoadingState from '~/components/Onboarding/components/OnboardingLoadingState.vue';
1314
import { DOCS_URL_ACCOUNT, DOCS_URL_LICENSING_FAQ } from '~/components/Onboarding/constants';
1415
import OnboardingSteps from '~/components/Onboarding/OnboardingSteps.vue';
1516
import { stepComponents } from '~/components/Onboarding/stepRegistry.js';
@@ -131,6 +132,7 @@ const showModal = computed(() => {
131132
return isVisible.value;
132133
});
133134
const showExitConfirmDialog = ref(false);
135+
const isClosingModal = ref(false);
134136
135137
const getNearestVisibleStepId = (stepId: StepId): StepId | null => {
136138
const currentOrderIndex = STEP_ORDER.indexOf(stepId);
@@ -293,6 +295,14 @@ const exitDialogDescription = computed(() =>
293295
? t('onboarding.modal.exit.internalBootDescription')
294296
: t('onboarding.modal.exit.description')
295297
);
298+
const isAwaitingStepData = computed(() => onboardingContextLoading.value && !currentStepComponent.value);
299+
const showModalLoadingState = computed(() => isClosingModal.value || isAwaitingStepData.value);
300+
const loadingStateTitle = computed(() =>
301+
isClosingModal.value ? t('onboarding.modal.closing.title') : t('onboarding.loading.title')
302+
);
303+
const loadingStateDescription = computed(() =>
304+
isClosingModal.value ? t('onboarding.modal.closing.description') : t('onboarding.loading.description')
305+
);
296306
297307
const handleTimezoneComplete = async () => {
298308
await goToNextStep();
@@ -319,16 +329,27 @@ const handleInternalBootSkip = async () => {
319329
};
320330
321331
const handleExitIntent = () => {
332+
if (isClosingModal.value) {
333+
return;
334+
}
322335
showExitConfirmDialog.value = true;
323336
};
324337
325338
const handleExitCancel = () => {
339+
if (isClosingModal.value) {
340+
return;
341+
}
326342
showExitConfirmDialog.value = false;
327343
};
328344
329345
const handleExitConfirm = async () => {
330346
showExitConfirmDialog.value = false;
331-
await closeModal();
347+
isClosingModal.value = true;
348+
try {
349+
await closeModal();
350+
} finally {
351+
isClosingModal.value = false;
352+
}
332353
};
333354
334355
const handleActivationSkip = async () => {
@@ -438,20 +459,28 @@ const currentStepProps = computed<Record<string, unknown>>(() => {
438459
type="button"
439460
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"
440461
:aria-label="t('onboarding.modal.closeAriaLabel')"
462+
:disabled="isClosingModal"
441463
@click="handleExitIntent"
442464
>
443465
<XMarkIcon class="h-5 w-5" />
444466
</button>
445467

446468
<div class="flex min-h-0 w-full flex-1 flex-col items-center">
447-
<OnboardingSteps
448-
:steps="filteredSteps"
449-
:active-step-index="currentDynamicStepIndex"
450-
:on-step-click="goToStep"
451-
class="mb-8"
452-
/>
453-
454-
<component v-if="currentStepComponent" :is="currentStepComponent" v-bind="currentStepProps" />
469+
<template v-if="showModalLoadingState">
470+
<div class="flex w-full max-w-4xl flex-1 items-center px-4 pb-4 md:px-8">
471+
<OnboardingLoadingState :title="loadingStateTitle" :description="loadingStateDescription" />
472+
</div>
473+
</template>
474+
<template v-else>
475+
<OnboardingSteps
476+
:steps="filteredSteps"
477+
:active-step-index="currentDynamicStepIndex"
478+
:on-step-click="goToStep"
479+
class="mb-8"
480+
/>
481+
482+
<component v-if="currentStepComponent" :is="currentStepComponent" v-bind="currentStepProps" />
483+
</template>
455484
</div>
456485
</div>
457486
</Dialog>
@@ -483,13 +512,15 @@ const currentStepProps = computed<Record<string, unknown>>(() => {
483512
<button
484513
type="button"
485514
class="border-muted text-foreground hover:bg-muted rounded-md border px-4 py-2 text-sm"
515+
:disabled="isClosingModal"
486516
@click="handleExitCancel"
487517
>
488518
{{ t('onboarding.modal.exit.keepOnboarding') }}
489519
</button>
490520
<button
491521
type="button"
492522
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-medium"
523+
:disabled="isClosingModal"
493524
@click="handleExitConfirm"
494525
>
495526
{{ t('onboarding.modal.exit.confirm') }}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script setup lang="ts">
2+
import { Spinner as LoadingSpinner } from '@unraid/ui';
3+
4+
withDefaults(
5+
defineProps<{
6+
title?: string;
7+
description?: string;
8+
compact?: boolean;
9+
}>(),
10+
{
11+
title: '',
12+
description: '',
13+
compact: false,
14+
}
15+
);
16+
</script>
17+
18+
<template>
19+
<div
20+
data-testid="onboarding-loading-state"
21+
:class="[
22+
'border-muted bg-elevated/95 flex w-full flex-col items-center justify-center rounded-2xl border text-center shadow-sm backdrop-blur-sm',
23+
compact ? 'min-h-[180px] px-6 py-10' : 'min-h-[320px] px-8 py-14',
24+
]"
25+
role="status"
26+
aria-live="polite"
27+
>
28+
<LoadingSpinner class="text-primary h-10 w-10" />
29+
<div class="mt-5 space-y-2">
30+
<h3 v-if="title" class="text-highlighted text-lg font-semibold">
31+
{{ title }}
32+
</h3>
33+
<p v-if="description" class="text-muted mx-auto max-w-xl text-sm leading-6">
34+
{{ description }}
35+
</p>
36+
</div>
37+
</div>
38+
</template>

web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
ExclamationTriangleIcon,
1212
} from '@heroicons/vue/24/solid';
1313
import { BrandButton, Select } from '@unraid/ui';
14+
import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue';
1415
import { REFRESH_INTERNAL_BOOT_CONTEXT_MUTATION } from '@/components/Onboarding/graphql/refreshInternalBootContext.mutation';
1516
import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
1617
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue';
@@ -807,11 +808,12 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
807808
</div>
808809
</blockquote>
809810

810-
<div
811-
v-if="isStorageBootSelected && isLoading"
812-
class="text-muted rounded-lg border border-dashed p-4 text-sm"
813-
>
814-
{{ t('onboarding.internalBootStep.loadingOptions') }}
811+
<div v-if="isStorageBootSelected && isLoading" class="mt-2">
812+
<OnboardingLoadingState
813+
compact
814+
:title="t('common.loading')"
815+
:description="t('onboarding.internalBootStep.loadingOptions')"
816+
/>
815817
</div>
816818

817819
<div

web/src/components/Onboarding/steps/OnboardingPluginsStep.vue

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useQuery } from '@vue/apollo-composable';
66
import { ChevronLeftIcon, Squares2X2Icon } from '@heroicons/vue/24/outline';
77
import { ChevronRightIcon, InformationCircleIcon } from '@heroicons/vue/24/solid';
88
import { BrandButton } from '@unraid/ui';
9+
import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue';
910
import { INSTALLED_UNRAID_PLUGINS_QUERY } from '@/components/Onboarding/graphql/installedPlugins.query';
1011
import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
1112
import { Switch } from '@headlessui/vue';
@@ -199,41 +200,49 @@ const primaryButtonText = computed(() => t('onboarding.pluginsStep.nextStep'));
199200
</blockquote>
200201

201202
<!-- Plugin List -->
202-
<div class="mb-8 grid gap-4">
203-
<div
204-
v-for="plugin in availablePlugins"
205-
:key="plugin.id"
206-
class="border-muted bg-bg hover:border-primary/50 flex items-center justify-between rounded-lg border p-5 transition-colors"
207-
>
208-
<div class="flex-1 pr-4">
209-
<h3 class="text-highlighted mb-1 text-base font-bold">
210-
{{ plugin.name }}
211-
</h3>
212-
<p class="text-muted text-sm leading-relaxed">
213-
{{ plugin.description }}
214-
</p>
215-
</div>
216-
217-
<Switch
218-
:model-value="isPluginEnabled(plugin.id)"
219-
@update:model-value="(val: boolean) => togglePlugin(plugin.id, val)"
220-
:disabled="isBusy || isPluginInstalled(plugin.id)"
221-
:class="[
222-
isPluginEnabled(plugin.id) ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700',
223-
'focus:ring-primary relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50',
224-
]"
203+
<div class="mb-8">
204+
<OnboardingLoadingState
205+
v-if="isInstalledPluginsPending"
206+
compact
207+
:title="t('onboarding.loading.title')"
208+
:description="t('onboarding.pluginsStep.loading.description')"
209+
/>
210+
<div v-else class="grid gap-4">
211+
<div
212+
v-for="plugin in availablePlugins"
213+
:key="plugin.id"
214+
class="border-muted bg-bg hover:border-primary/50 flex items-center justify-between rounded-lg border p-5 transition-colors"
225215
>
226-
<span class="sr-only">{{
227-
t('onboarding.pluginsStep.enablePluginAria', { name: plugin.name })
228-
}}</span>
229-
<span
230-
aria-hidden="true"
216+
<div class="flex-1 pr-4">
217+
<h3 class="text-highlighted mb-1 text-base font-bold">
218+
{{ plugin.name }}
219+
</h3>
220+
<p class="text-muted text-sm leading-relaxed">
221+
{{ plugin.description }}
222+
</p>
223+
</div>
224+
225+
<Switch
226+
:model-value="isPluginEnabled(plugin.id)"
227+
@update:model-value="(val: boolean) => togglePlugin(plugin.id, val)"
228+
:disabled="isBusy || isPluginInstalled(plugin.id)"
231229
:class="[
232-
isPluginEnabled(plugin.id) ? 'translate-x-5' : 'translate-x-0',
233-
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
230+
isPluginEnabled(plugin.id) ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700',
231+
'focus:ring-primary relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50',
234232
]"
235-
/>
236-
</Switch>
233+
>
234+
<span class="sr-only">{{
235+
t('onboarding.pluginsStep.enablePluginAria', { name: plugin.name })
236+
}}</span>
237+
<span
238+
aria-hidden="true"
239+
:class="[
240+
isPluginEnabled(plugin.id) ? 'translate-x-5' : 'translate-x-0',
241+
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
242+
]"
243+
/>
244+
</Switch>
245+
</div>
237246
</div>
238247
</div>
239248

web/src/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,11 @@
121121
"onboarding.console.title": "Setup Console",
122122
"onboarding.console.waiting": "Waiting...",
123123
"onboarding.console.technicalDetails": "Technical details",
124+
"onboarding.loading.title": "Loading setup...",
125+
"onboarding.loading.description": "We're preparing the next step of setup.",
124126
"onboarding.modal.closeAriaLabel": "Close onboarding",
127+
"onboarding.modal.closing.title": "Closing setup...",
128+
"onboarding.modal.closing.description": "You can skip setup now and continue from the dashboard later.",
125129
"onboarding.modal.exit.title": "Exit onboarding?",
126130
"onboarding.modal.exit.description": "You can skip setup now and continue from the dashboard later.",
127131
"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 @@
146150
"onboarding.pluginsStep.plugins.fixCommonProblems.description": "Diagnostic tool to help you identify and resolve configuration issues.",
147151
"onboarding.pluginsStep.plugins.tailscale.name": "Tailscale",
148152
"onboarding.pluginsStep.plugins.tailscale.description": "Zero-config VPN. Securely access your server from anywhere.",
153+
"onboarding.pluginsStep.loading.description": "Checking which recommended plugins are already installed.",
149154
"onboarding.internalBootStep.stepTitle": "Setup Boot",
150155
"onboarding.internalBootStep.stepDescription": "Choose USB or storage drive boot",
151156
"onboarding.internalBootStep.title": "Setup Boot",

0 commit comments

Comments
 (0)