Skip to content

Commit afe1ae6

Browse files
refactor(onboarding): migrate to Nuxt UI + browser history support (#1949)
## Summary - Standardize the onboarding flow on Nuxt UI primitives across all steps (Core Settings, Plugins, Internal Boot, License, Summary, Next Steps) - Replace HeadlessUI Disclosure components with @unraid/ui Accordion (enhanced with v-model and open state slot props) - Integrate browser back/forward navigation support and "No Updates Needed" messaging (from #1964) - Polish UX: disabled button states, alert styling, skip button behavior, radio button boot mode selection ## What Changed ### HeadlessUI → Nuxt UI Migration - **All onboarding steps** now use Nuxt UI components (`USelectMenu`, `UInput`, `UCheckbox`, `UAlert`, `UModal`, `UButton`, `URadioGroup`) instead of HeadlessUI or native HTML elements - **All `@headlessui/vue` imports removed from onboarding** - Custom `<transition>` wrappers removed — Accordion uses Reka UI's built-in animations ### Accordion Enhancement (@unraid/ui) - Extended `Accordion` component with optional `v-model` support, `itemClass` prop, and `{ open }` boolean in scoped slots - Changes are **additive** — existing accordion usage is unaffected (no global style changes) ### UX Polish - Radio buttons for boot mode selection (USB vs Storage) - Disabled state on BrandButton via `aria-disabled` with `pointer-events-none` - `cursor-not-allowed` on disabled Back/Skip navigation buttons - Neutral-colored UAlerts for info panels and warnings - Solid green UAlert for initialization ready state - Outline variant for Skip button in license step - Placeholder text in empty device select menus - Guard against `slotCount` NaN from undefined USelectMenu values - `:disabled` on reboot modal buttons to prevent double-click ### Browser History + No-Changes Messaging (from #1964) - Browser back/forward navigation support within onboarding flows - "No Updates Needed" messaging when no configuration changes are applied - Enhanced session handling with improved distinction between automatic and manual onboarding modes ### Test Updates - Replaced HeadlessUI Disclosure mocks with Accordion stubs - Fixed auto-import bypass: tests use VM state for USelectMenu interaction (Nuxt UI auto-imports bypass `global.stubs`) - Updated "Setup Applied" → "No Updates Needed" assertions for no-changes path - All 618 web tests pass, all 1962 API tests pass ## Why - The onboarding flow mixed native controls, HeadlessUI components, and older modal patterns — making the UI feel uneven - HeadlessUI was an unnecessary dependency when @unraid/ui (backed by Reka UI) provides equivalent components - Disabled states were not communicated visually to users ## Includes - #1964 (feat: onboarding use history) — merged into this branch ## Verification ```bash pnpm --dir web test -- --run pnpm --dir web lint pnpm --dir web type-check pnpm --dir api test -- --run pnpm --dir api lint pnpm --dir api type-check ``` ## Smoke Test 1. Walk the full onboarding flow — confirm all controls use consistent Nuxt UI styling 2. Setup Boot: verify radio buttons for USB/Storage selection, disabled states on Back/Skip during load 3. Internal Boot: expand/collapse eligibility details, verify device selects have placeholder text 4. License: test Skip dialog with solid info alert and outline Skip button 5. Summary: expand/collapse plugins accordion, verify "No Updates Needed" when no changes apply 6. Next Steps: verify reboot confirmation modal buttons disable during submission 7. Browser back/forward: navigate between steps using browser history buttons --------- Co-authored-by: Eli Bosley <eli@bosley.dev>
1 parent 9e0e89e commit afe1ae6

23 files changed

+1325
-695
lines changed

plugin/source/dynamix.unraid.net/install/doinst.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,9 @@ cp usr/local/unraid-api/.env.production usr/local/unraid-api/.env
3131
( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm )
3232
( cd usr/local/bin ; rm -rf npx )
3333
( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx )
34+
( cd usr/local/bin ; rm -rf corepack )
35+
( cd usr/local/bin ; ln -sf ../lib/node_modules/corepack/dist/corepack.js corepack )
36+
( cd usr/local/bin ; rm -rf npm )
37+
( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm )
38+
( cd usr/local/bin ; rm -rf npx )
39+
( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx )

unraid-ui/src/components/brand/brand-button.variants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { cva, type VariantProps } from 'class-variance-authority';
22

33
export const brandButtonVariants = cva(
4-
'group text-center font-semibold leading-none relative z-0 flex flex-row items-center justify-center border-2 border-solid shadow-none cursor-pointer rounded-md hover:shadow-md focus:shadow-md disabled:opacity-25 disabled:hover:opacity-25 disabled:focus:opacity-25 disabled:cursor-not-allowed',
4+
'group text-center font-semibold leading-none relative z-0 flex flex-row items-center justify-center border-2 border-solid shadow-none cursor-pointer rounded-md hover:shadow-md focus:shadow-md disabled:opacity-25 disabled:hover:opacity-25 disabled:focus:opacity-25 disabled:cursor-not-allowed aria-disabled:opacity-25 aria-disabled:cursor-not-allowed aria-disabled:hover:opacity-25 aria-disabled:hover:shadow-none',
55
{
66
variants: {
77
variant: {

unraid-ui/src/components/common/accordion/Accordion.vue

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
AccordionRoot,
66
AccordionTrigger,
77
} from '@/components/ui/accordion';
8+
import { computed, ref, watch } from 'vue';
89
910
export interface AccordionItemData {
1011
value: string;
@@ -18,21 +19,50 @@ export interface AccordionProps {
1819
type?: 'single' | 'multiple';
1920
collapsible?: boolean;
2021
defaultValue?: string | string[];
22+
modelValue?: string | string[];
2123
class?: string;
24+
itemClass?: string;
25+
triggerClass?: string;
2226
}
2327
2428
const props = withDefaults(defineProps<AccordionProps>(), {
2529
type: 'single',
2630
collapsible: true,
2731
});
32+
33+
const emit = defineEmits<{
34+
'update:modelValue': [value: string | string[]];
35+
}>();
36+
37+
const openValue = ref<string | string[] | undefined>(props.modelValue ?? props.defaultValue);
38+
39+
watch(
40+
() => props.modelValue,
41+
(val) => {
42+
if (val !== undefined) openValue.value = val;
43+
}
44+
);
45+
46+
function isItemOpen(itemValue: string): boolean {
47+
if (!openValue.value) return false;
48+
if (Array.isArray(openValue.value)) return openValue.value.includes(itemValue);
49+
return openValue.value === itemValue;
50+
}
51+
52+
function handleUpdate(value: string | string[]) {
53+
openValue.value = value;
54+
emit('update:modelValue', value);
55+
}
2856
</script>
2957

3058
<template>
3159
<AccordionRoot
3260
:type="type"
3361
:collapsible="collapsible"
3462
:default-value="defaultValue"
63+
:model-value="openValue"
3564
:class="props.class"
65+
@update:model-value="handleUpdate"
3666
>
3767
<!-- Default slot for direct composition -->
3868
<slot />
@@ -44,14 +74,15 @@ const props = withDefaults(defineProps<AccordionProps>(), {
4474
:key="item.value"
4575
:value="item.value"
4676
:disabled="item.disabled"
77+
:class="props.itemClass"
4778
>
48-
<AccordionTrigger>
49-
<slot name="trigger" :item="item">
79+
<AccordionTrigger :class="props.triggerClass">
80+
<slot name="trigger" :item="item" :open="isItemOpen(item.value)">
5081
{{ item.title }}
5182
</slot>
5283
</AccordionTrigger>
5384
<AccordionContent>
54-
<slot name="content" :item="item">
85+
<slot name="content" :item="item" :open="isItemOpen(item.value)">
5586
{{ item.content }}
5687
</slot>
5788
</AccordionContent>

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

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -69,36 +69,6 @@ vi.mock('@unraid/ui', () => ({
6969
template:
7070
'<button data-testid="brand-button" :disabled="disabled" @click="$emit(\'click\')"><slot />{{ text }}</button>',
7171
},
72-
Select: {
73-
props: ['modelValue', 'items', 'disabled'],
74-
emits: ['update:modelValue'],
75-
template: `
76-
<select
77-
data-testid="select"
78-
:disabled="disabled"
79-
:value="modelValue"
80-
@change="$emit('update:modelValue', $event.target.value)"
81-
>
82-
<option v-for="item in items" :key="item.value" :value="item.value">{{ item.label }}</option>
83-
</select>
84-
`,
85-
},
86-
}));
87-
88-
vi.mock('@headlessui/vue', () => ({
89-
Switch: {
90-
props: ['modelValue', 'disabled'],
91-
emits: ['update:modelValue'],
92-
template: `
93-
<input
94-
data-testid="switch"
95-
type="checkbox"
96-
:checked="modelValue"
97-
:disabled="disabled"
98-
@change="$emit('update:modelValue', $event.target.checked)"
99-
/>
100-
`,
101-
},
10272
}));
10373

10474
vi.mock('@vvo/tzdb', () => ({
@@ -173,6 +143,35 @@ const mountComponent = (props: Record<string, unknown> = {}) => {
173143
},
174144
global: {
175145
plugins: [createTestI18n()],
146+
stubs: {
147+
USelectMenu: {
148+
props: ['modelValue', 'items', 'disabled'],
149+
emits: ['update:modelValue'],
150+
template: `
151+
<select
152+
data-testid="select-menu"
153+
:disabled="disabled"
154+
:value="modelValue"
155+
@change="$emit('update:modelValue', $event.target.value)"
156+
>
157+
<option v-for="item in items" :key="item.value" :value="item.value">{{ item.label }}</option>
158+
</select>
159+
`,
160+
},
161+
USwitch: {
162+
props: ['modelValue', 'disabled'],
163+
emits: ['update:modelValue'],
164+
template: `
165+
<input
166+
data-testid="switch"
167+
type="checkbox"
168+
:checked="modelValue"
169+
:disabled="disabled"
170+
@change="$emit('update:modelValue', $event.target.checked)"
171+
/>
172+
`,
173+
},
174+
},
176175
},
177176
});
178177

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

Lines changed: 133 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { flushPromises, mount } from '@vue/test-utils';
1+
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils';
22

3-
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
44

55
import type {
66
InternalBootApplyMessages,
@@ -11,6 +11,14 @@ import type {
1111
import OnboardingInternalBootStandalone from '~/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue';
1212
import { createTestI18n } from '../../utils/i18n';
1313

14+
const INTERNAL_BOOT_HISTORY_STATE_KEY = '__unraidOnboardingInternalBoot';
15+
16+
type InternalBootHistoryState = {
17+
sessionId: string;
18+
stepId: 'CONFIGURE_BOOT' | 'SUMMARY';
19+
position: number;
20+
};
21+
1422
const {
1523
draftStore,
1624
applyInternalBootSelectionMock,
@@ -129,10 +137,50 @@ const mountComponent = () =>
129137
},
130138
});
131139

140+
enableAutoUnmount(afterEach);
141+
142+
const getInternalBootHistoryState = (): InternalBootHistoryState | null => {
143+
const state =
144+
typeof window.history.state === 'object' && window.history.state !== null
145+
? (window.history.state as Record<string, unknown>)
146+
: null;
147+
const candidate = state?.[INTERNAL_BOOT_HISTORY_STATE_KEY];
148+
if (!candidate || typeof candidate !== 'object') {
149+
return null;
150+
}
151+
152+
const sessionId =
153+
typeof (candidate as Record<string, unknown>).sessionId === 'string'
154+
? ((candidate as Record<string, unknown>).sessionId as string)
155+
: null;
156+
const stepId =
157+
(candidate as Record<string, unknown>).stepId === 'CONFIGURE_BOOT' ||
158+
(candidate as Record<string, unknown>).stepId === 'SUMMARY'
159+
? ((candidate as Record<string, unknown>).stepId as InternalBootHistoryState['stepId'])
160+
: null;
161+
const position = Number((candidate as Record<string, unknown>).position);
162+
163+
if (!sessionId || !stepId || !Number.isInteger(position)) {
164+
return null;
165+
}
166+
167+
return {
168+
sessionId,
169+
stepId,
170+
position,
171+
};
172+
};
173+
174+
const dispatchPopstate = (state: Record<string, unknown> | null) => {
175+
window.history.replaceState(state, '', window.location.href);
176+
window.dispatchEvent(new PopStateEvent('popstate', { state }));
177+
};
178+
132179
describe('OnboardingInternalBoot.standalone.vue', () => {
133180
beforeEach(() => {
134181
vi.useFakeTimers();
135182
vi.clearAllMocks();
183+
window.history.replaceState(null, '', window.location.href);
136184

137185
draftStore.internalBootSelection = null;
138186
draftStore.internalBootApplySucceeded = false;
@@ -182,8 +230,9 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
182230

183231
expect(applyInternalBootSelectionMock).not.toHaveBeenCalled();
184232
expect(draftStore.setInternalBootApplySucceeded).toHaveBeenCalledWith(false);
185-
expect(wrapper.text()).toContain('Setup Applied');
186-
expect(wrapper.text()).toContain('No settings changed. Skipping configuration mutations.');
233+
expect(wrapper.text()).toContain('No Updates Needed');
234+
expect(wrapper.text()).toContain('No changes needed. Skipping configuration updates.');
235+
expect(wrapper.find('[data-testid="internal-boot-standalone-edit-again"]').exists()).toBe(true);
187236
expect(stepperPropsRef.value).toMatchObject({
188237
activeStepIndex: 1,
189238
});
@@ -278,7 +327,69 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
278327
expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true);
279328
});
280329

330+
it('restores the configure step when browser back leaves a reversible result', async () => {
331+
const wrapper = mountComponent();
332+
333+
await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
334+
await flushPromises();
335+
336+
const currentHistoryState = getInternalBootHistoryState();
337+
expect(currentHistoryState).toMatchObject({
338+
stepId: 'SUMMARY',
339+
position: 1,
340+
});
341+
342+
dispatchPopstate({
343+
[INTERNAL_BOOT_HISTORY_STATE_KEY]: {
344+
sessionId: currentHistoryState?.sessionId,
345+
stepId: 'CONFIGURE_BOOT',
346+
position: 0,
347+
},
348+
});
349+
await flushPromises();
350+
await wrapper.vm.$nextTick();
351+
352+
expect(stepperPropsRef.value).toMatchObject({
353+
activeStepIndex: 0,
354+
});
355+
expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true);
356+
});
357+
358+
it('closes when browser back leaves a fully applied result', async () => {
359+
draftStore.internalBootSelection = {
360+
poolName: 'cache',
361+
slotCount: 1,
362+
devices: ['DISK-A'],
363+
bootSizeMiB: 16384,
364+
updateBios: true,
365+
};
366+
367+
const wrapper = mountComponent();
368+
369+
await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
370+
await flushPromises();
371+
372+
const currentHistoryState = getInternalBootHistoryState();
373+
expect(currentHistoryState).toMatchObject({
374+
stepId: 'SUMMARY',
375+
position: 1,
376+
});
377+
378+
dispatchPopstate({
379+
[INTERNAL_BOOT_HISTORY_STATE_KEY]: {
380+
sessionId: currentHistoryState?.sessionId,
381+
stepId: 'CONFIGURE_BOOT',
382+
position: 0,
383+
},
384+
});
385+
await flushPromises();
386+
387+
expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
388+
expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false);
389+
});
390+
281391
it('closes locally after showing a result', async () => {
392+
const historyGoSpy = vi.spyOn(window.history, 'go').mockImplementation(() => {});
282393
const wrapper = mountComponent();
283394

284395
await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
@@ -287,24 +398,38 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
287398
await wrapper.get('[data-testid="internal-boot-standalone-result-close"]').trigger('click');
288399
await flushPromises();
289400

401+
expect(historyGoSpy).toHaveBeenCalledWith(-2);
402+
dispatchPopstate(null);
403+
await flushPromises();
404+
290405
expect(wrapper.find('[data-testid="internal-boot-standalone-result"]').exists()).toBe(false);
291406
});
292407

293408
it('closes when the shared dialog requests dismissal', async () => {
409+
const historyGoSpy = vi.spyOn(window.history, 'go').mockImplementation(() => {});
294410
const wrapper = mountComponent();
295411

296412
await wrapper.get('[data-testid="dialog-dismiss"]').trigger('click');
297413
await flushPromises();
298414

415+
expect(historyGoSpy).toHaveBeenCalledWith(-1);
416+
dispatchPopstate(null);
417+
await flushPromises();
418+
299419
expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false);
300420
});
301421

302422
it('closes via the top-right X button', async () => {
423+
const historyGoSpy = vi.spyOn(window.history, 'go').mockImplementation(() => {});
303424
const wrapper = mountComponent();
304425

305426
await wrapper.get('[data-testid="internal-boot-standalone-close"]').trigger('click');
306427
await flushPromises();
307428

429+
expect(historyGoSpy).toHaveBeenCalledWith(-1);
430+
dispatchPopstate(null);
431+
await flushPromises();
432+
308433
expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
309434
expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false);
310435
});
@@ -337,6 +462,7 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
337462
});
338463

339464
it('clears onboarding storage when closing after a successful result', async () => {
465+
vi.spyOn(window.history, 'go').mockImplementation(() => {});
340466
const wrapper = mountComponent();
341467

342468
await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
@@ -347,6 +473,9 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
347473
await wrapper.get('[data-testid="internal-boot-standalone-result-close"]').trigger('click');
348474
await flushPromises();
349475

476+
dispatchPopstate(null);
477+
await flushPromises();
478+
350479
expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
351480
});
352481
});

0 commit comments

Comments
 (0)