Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions web/__test__/components/Onboarding/OnboardingModal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ vi.mock('@unraid/ui', () => ({
emits: ['update:modelValue'],
template: '<div v-if="modelValue" data-testid="dialog"><slot /></div>',
},
Spinner: {
name: 'Spinner',
template: '<div data-testid="loading-spinner" />',
},
}));

vi.mock('@heroicons/vue/24/solid', () => ({
Expand Down Expand Up @@ -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<boolean>;
resolve: (value: boolean) => void;
}
| undefined;
onboardingModalStoreState.closeModal.mockImplementation(() => {
let resolve!: (value: boolean) => void;
const promise = new Promise<boolean>((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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('closes onboarding without frontend completion logic', async () => {
const wrapper = mountComponent();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ vi.mock('@unraid/ui', () => ({
template:
'<button data-testid="brand-button" :disabled="disabled" @click="$emit(\'click\')">{{ text }}</button>',
},
Spinner: {
name: 'Spinner',
template: '<div data-testid="loading-spinner" />',
},
}));

vi.mock('@headlessui/vue', () => ({
Expand Down
1 change: 1 addition & 0 deletions web/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
49 changes: 40 additions & 9 deletions web/src/components/Onboarding/OnboardingModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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 () => {
Expand Down Expand Up @@ -438,20 +459,28 @@ const currentStepProps = computed<Record<string, unknown>>(() => {
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"
>
<XMarkIcon class="h-5 w-5" />
</button>

<div class="flex min-h-0 w-full flex-1 flex-col items-center">
<OnboardingSteps
:steps="filteredSteps"
:active-step-index="currentDynamicStepIndex"
:on-step-click="goToStep"
class="mb-8"
/>

<component v-if="currentStepComponent" :is="currentStepComponent" v-bind="currentStepProps" />
<template v-if="showModalLoadingState">
<div class="flex w-full max-w-4xl flex-1 items-center px-4 pb-4 md:px-8">
<OnboardingLoadingState :title="loadingStateTitle" :description="loadingStateDescription" />
</div>
</template>
<template v-else>
<OnboardingSteps
:steps="filteredSteps"
:active-step-index="currentDynamicStepIndex"
:on-step-click="goToStep"
class="mb-8"
/>

<component v-if="currentStepComponent" :is="currentStepComponent" v-bind="currentStepProps" />
</template>
</div>
</div>
</Dialog>
Expand Down Expand Up @@ -483,13 +512,15 @@ const currentStepProps = computed<Record<string, unknown>>(() => {
<button
type="button"
class="border-muted text-foreground hover:bg-muted rounded-md border px-4 py-2 text-sm"
:disabled="isClosingModal"
@click="handleExitCancel"
>
{{ t('onboarding.modal.exit.keepOnboarding') }}
</button>
<button
type="button"
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-medium"
:disabled="isClosingModal"
@click="handleExitConfirm"
>
{{ t('onboarding.modal.exit.confirm') }}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { Spinner as LoadingSpinner } from '@unraid/ui';

withDefaults(
defineProps<{
title?: string;
description?: string;
compact?: boolean;
}>(),
{
title: '',
description: '',
compact: false,
}
);
</script>

<template>
<div
data-testid="onboarding-loading-state"
:class="[
'border-muted bg-elevated/95 flex w-full flex-col items-center justify-center rounded-2xl border text-center shadow-sm backdrop-blur-sm',
compact ? 'min-h-[180px] px-6 py-10' : 'min-h-[320px] px-8 py-14',
]"
role="status"
aria-live="polite"
>
<LoadingSpinner class="text-primary h-10 w-10" />
<div class="mt-5 space-y-2">
<h3 v-if="title" class="text-highlighted text-lg font-semibold">
{{ title }}
</h3>
<p v-if="description" class="text-muted mx-auto max-w-xl text-sm leading-6">
{{ description }}
</p>
</div>
</div>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ExclamationTriangleIcon,
} from '@heroicons/vue/24/solid';
import { BrandButton, Select } from '@unraid/ui';
import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue';
import { REFRESH_INTERNAL_BOOT_CONTEXT_MUTATION } from '@/components/Onboarding/graphql/refreshInternalBootContext.mutation';
import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue';
Expand Down Expand Up @@ -807,11 +808,12 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
</div>
</blockquote>

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

<div
Expand Down
73 changes: 41 additions & 32 deletions web/src/components/Onboarding/steps/OnboardingPluginsStep.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useQuery } from '@vue/apollo-composable';
import { ChevronLeftIcon, Squares2X2Icon } from '@heroicons/vue/24/outline';
import { ChevronRightIcon, InformationCircleIcon } from '@heroicons/vue/24/solid';
import { BrandButton } from '@unraid/ui';
import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue';
import { INSTALLED_UNRAID_PLUGINS_QUERY } from '@/components/Onboarding/graphql/installedPlugins.query';
import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
import { Switch } from '@headlessui/vue';
Expand Down Expand Up @@ -199,41 +200,49 @@ const primaryButtonText = computed(() => t('onboarding.pluginsStep.nextStep'));
</blockquote>

<!-- Plugin List -->
<div class="mb-8 grid gap-4">
<div
v-for="plugin in availablePlugins"
:key="plugin.id"
class="border-muted bg-bg hover:border-primary/50 flex items-center justify-between rounded-lg border p-5 transition-colors"
>
<div class="flex-1 pr-4">
<h3 class="text-highlighted mb-1 text-base font-bold">
{{ plugin.name }}
</h3>
<p class="text-muted text-sm leading-relaxed">
{{ plugin.description }}
</p>
</div>

<Switch
:model-value="isPluginEnabled(plugin.id)"
@update:model-value="(val: boolean) => togglePlugin(plugin.id, val)"
:disabled="isBusy || isPluginInstalled(plugin.id)"
:class="[
isPluginEnabled(plugin.id) ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700',
'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',
]"
<div class="mb-8">
<OnboardingLoadingState
v-if="isInstalledPluginsPending"
compact
:title="t('onboarding.loading.title')"
:description="t('onboarding.pluginsStep.loading.description')"
/>
<div v-else class="grid gap-4">
<div
v-for="plugin in availablePlugins"
:key="plugin.id"
class="border-muted bg-bg hover:border-primary/50 flex items-center justify-between rounded-lg border p-5 transition-colors"
>
<span class="sr-only">{{
t('onboarding.pluginsStep.enablePluginAria', { name: plugin.name })
}}</span>
<span
aria-hidden="true"
<div class="flex-1 pr-4">
<h3 class="text-highlighted mb-1 text-base font-bold">
{{ plugin.name }}
</h3>
<p class="text-muted text-sm leading-relaxed">
{{ plugin.description }}
</p>
</div>

<Switch
:model-value="isPluginEnabled(plugin.id)"
@update:model-value="(val: boolean) => togglePlugin(plugin.id, val)"
:disabled="isBusy || isPluginInstalled(plugin.id)"
:class="[
isPluginEnabled(plugin.id) ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
isPluginEnabled(plugin.id) ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700',
'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',
]"
/>
</Switch>
>
<span class="sr-only">{{
t('onboarding.pluginsStep.enablePluginAria', { name: plugin.name })
}}</span>
<span
aria-hidden="true"
:class="[
isPluginEnabled(plugin.id) ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
/>
</Switch>
</div>
</div>
</div>

Expand Down
5 changes: 5 additions & 0 deletions web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
Loading