Skip to content

Commit ad9ff32

Browse files
committed
refactor(onboarding): standardize onboarding controls on Nuxt UI
- Purpose: replace remaining mixed onboarding form primitives and modal actions with Nuxt UI components so the flow feels visually and behaviorally consistent end to end. - Before: onboarding still mixed native inputs, bespoke Headless UI switches, custom callouts, and older dialog/button patterns across Core Settings, Plugins, Internal Boot, License, Summary, and Next Steps. - Problem: the flow looked uneven, dark-mode surfaces were inconsistent, and the test suite still assumed pre-refactor modal behavior. - Now: onboarding uses Nuxt UI switches, inputs, select menus, checkboxes, alerts, modals, and dialog buttons throughout the targeted steps while keeping the intentional native footer CTA buttons unchanged. - How: migrated the step components, regenerated Nuxt auto-import typings, and updated the onboarding Vitest specs to assert the new modal/result flows and storage-boot confirmation behavior.
1 parent 776c8cc commit ad9ff32

14 files changed

+607
-528
lines changed

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/OnboardingInternalBootStep.test.ts

Lines changed: 89 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ type MockInternalBootSelection = {
1717
updateBios: boolean;
1818
};
1919

20+
type InternalBootVm = {
21+
getDeviceSelectItems: (index: number) => Array<{ value: string; label: string; disabled?: boolean }>;
22+
};
23+
2024
const {
2125
draftStore,
2226
contextResult,
@@ -61,28 +65,6 @@ vi.mock('@unraid/ui', () => ({
6165
template:
6266
'<button data-testid="brand-button" :disabled="disabled" @click="$emit(\'click\')">{{ text }}</button>',
6367
},
64-
Select: {
65-
props: ['modelValue', 'items', 'disabled', 'placeholder'],
66-
emits: ['update:modelValue'],
67-
template: `
68-
<select
69-
data-testid="select"
70-
:disabled="disabled"
71-
:value="modelValue ?? ''"
72-
@change="$emit('update:modelValue', $event.target.value)"
73-
>
74-
<option v-if="placeholder" value="">{{ placeholder }}</option>
75-
<option
76-
v-for="item in items"
77-
:key="item.value"
78-
:value="item.value"
79-
:disabled="item.disabled"
80-
>
81-
{{ item.label }}
82-
</option>
83-
</select>
84-
`,
85-
},
8668
}));
8769

8870
vi.mock('@vue/apollo-composable', () => ({
@@ -138,6 +120,67 @@ const mountComponent = () =>
138120
},
139121
global: {
140122
plugins: [createTestI18n()],
123+
stubs: {
124+
UButton: {
125+
props: ['disabled'],
126+
emits: ['click'],
127+
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
128+
},
129+
UAlert: {
130+
props: ['title', 'description'],
131+
template:
132+
'<div><slot name="title" />{{ title }}<slot name="description" />{{ description }}<slot /></div>',
133+
},
134+
UCheckbox: {
135+
props: ['modelValue', 'disabled'],
136+
emits: ['update:modelValue'],
137+
template: `
138+
<input
139+
type="checkbox"
140+
:checked="modelValue"
141+
:disabled="disabled"
142+
@change="$emit('update:modelValue', $event.target.checked)"
143+
/>
144+
`,
145+
},
146+
UInput: {
147+
props: ['modelValue', 'type', 'disabled', 'maxlength', 'min', 'max'],
148+
emits: ['update:modelValue'],
149+
template: `
150+
<input
151+
:type="type || 'text'"
152+
:disabled="disabled"
153+
:maxlength="maxlength"
154+
:min="min"
155+
:max="max"
156+
:value="modelValue"
157+
@input="$emit('update:modelValue', $event.target.value)"
158+
/>
159+
`,
160+
},
161+
USelectMenu: {
162+
props: ['modelValue', 'items', 'disabled', 'placeholder'],
163+
emits: ['update:modelValue'],
164+
template: `
165+
<select
166+
data-testid="select"
167+
:disabled="disabled"
168+
:value="modelValue ?? ''"
169+
@change="$emit('update:modelValue', $event.target.value)"
170+
>
171+
<option v-if="placeholder" value="">{{ placeholder }}</option>
172+
<option
173+
v-for="item in items"
174+
:key="item.value"
175+
:value="item.value"
176+
:disabled="item.disabled"
177+
>
178+
{{ item.label }}
179+
</option>
180+
</select>
181+
`,
182+
},
183+
},
141184
},
142185
});
143186

@@ -248,8 +291,15 @@ describe('OnboardingInternalBootStep', () => {
248291
const wrapper = mountComponent();
249292
await flushPromises();
250293

251-
expect(wrapper.text()).toContain('WD-TEST-1234 - 34.4 GB (sda)');
252-
expect(wrapper.text()).not.toContain('eligible-disk - 34.4 GB (sda)');
294+
const vm = wrapper.vm as unknown as InternalBootVm;
295+
expect(vm.getDeviceSelectItems(0)).toEqual(
296+
expect.arrayContaining([
297+
expect.objectContaining({
298+
value: 'WD-TEST-1234',
299+
label: 'WD-TEST-1234 - 34.4 GB (sda)',
300+
}),
301+
])
302+
);
253303
});
254304

255305
it('defaults the storage pool name to cache', async () => {
@@ -372,13 +422,17 @@ describe('OnboardingInternalBootStep', () => {
372422
await flushPromises();
373423

374424
expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(true);
375-
const selects = wrapper.findAll('select');
376-
expect(selects).toHaveLength(3);
377-
const deviceSelect = selects[1];
378-
expect(deviceSelect.text()).toContain('ELIGIBLE-1');
379-
expect(deviceSelect.text()).toContain('USB-1');
380-
expect(deviceSelect.text()).not.toContain('CACHE-1');
381-
expect(deviceSelect.text()).not.toContain('SMALL-1');
425+
const vm = wrapper.vm as unknown as InternalBootVm;
426+
const deviceItems = vm.getDeviceSelectItems(0);
427+
expect(deviceItems).toEqual(
428+
expect.arrayContaining([
429+
expect.objectContaining({ value: 'ELIGIBLE-1' }),
430+
expect.objectContaining({ value: 'USB-1' }),
431+
])
432+
);
433+
expect(deviceItems).not.toEqual(
434+
expect.arrayContaining([expect.objectContaining({ value: 'SMALL-1' })])
435+
);
382436
const biosWarning = wrapper.get('[data-testid="internal-boot-update-bios-warning"]');
383437
const eligibilityPanel = wrapper.get('[data-testid="internal-boot-eligibility-panel"]');
384438
expect(
@@ -410,9 +464,10 @@ describe('OnboardingInternalBootStep', () => {
410464

411465
expect(wrapper.find('[data-testid="internal-boot-intro-panel"]').exists()).toBe(true);
412466
expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(false);
413-
const selects = wrapper.findAll('select');
414-
expect(selects).toHaveLength(3);
415-
expect(selects[1]?.text()).toContain('UNASSIGNED-1');
467+
const vm = wrapper.vm as unknown as InternalBootVm;
468+
expect(vm.getDeviceSelectItems(0)).toEqual(
469+
expect.arrayContaining([expect.objectContaining({ value: 'UNASSIGNED-1' })])
470+
);
416471
expect(wrapper.text()).not.toContain('ASSIGNED_TO_ARRAY');
417472
expect(wrapper.text()).not.toContain('NO_UNASSIGNED_DISKS');
418473
expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeUndefined();

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ vi.mock('@heroicons/vue/24/solid', () => {
5353
'ArrowTopRightOnSquareIcon',
5454
'ChevronLeftIcon',
5555
'ChevronRightIcon',
56-
'ExclamationTriangleIcon',
5756
'EyeIcon',
5857
'EyeSlashIcon',
5958
'KeyIcon',
@@ -71,6 +70,7 @@ vi.mock('@heroicons/vue/24/solid', () => {
7170
describe('OnboardingLicenseStep.vue', () => {
7271
beforeEach(() => {
7372
vi.clearAllMocks();
73+
document.body.innerHTML = '';
7474
serverStoreMock.state.value = 'ENOKEYFILE';
7575
activationStoreMock.registrationState.value = 'ENOKEYFILE';
7676
activationStoreMock.activationCode.value = { code: 'TEST-GUID-123' };
@@ -90,6 +90,27 @@ describe('OnboardingLicenseStep.vue', () => {
9090
return mount(OnboardingLicenseStep, {
9191
global: {
9292
plugins: [createTestI18n()],
93+
stubs: {
94+
UAlert: {
95+
props: ['description'],
96+
template: '<div>{{ description }}<slot name="description" /></div>',
97+
},
98+
UButton: {
99+
props: ['disabled'],
100+
emits: ['click'],
101+
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
102+
},
103+
UModal: {
104+
props: ['open', 'title'],
105+
template: `
106+
<div v-if="open" data-testid="modal">
107+
<div>{{ title }}</div>
108+
<slot name="body" />
109+
<slot name="footer" />
110+
</div>
111+
`,
112+
},
113+
},
93114
},
94115
props: {
95116
activateHref: 'https://unraid.net/activate',
@@ -154,7 +175,7 @@ describe('OnboardingLicenseStep.vue', () => {
154175
await wrapper.vm.$nextTick();
155176

156177
const confirmSkipButton = wrapper
157-
.findAll('[data-testid="brand-button"]')
178+
.findAll('button')
158179
.find((button) => button.text().toLowerCase().includes('understand'));
159180

160181
expect(confirmSkipButton).toBeTruthy();

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,6 @@ vi.mock('@unraid/ui', () => ({
4141
template:
4242
'<button data-testid="brand-button" :disabled="disabled" @click="$emit(\'click\')"><slot />{{ text }}</button>',
4343
},
44-
Dialog: {
45-
props: ['modelValue'],
46-
template: '<div v-if="modelValue" data-testid="dialog"><slot /></div>',
47-
},
4844
}));
4945

5046
vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({
@@ -66,6 +62,7 @@ vi.mock('~/components/Onboarding/store/onboardingStorageCleanup', () => ({
6662
describe('OnboardingNextStepsStep', () => {
6763
beforeEach(() => {
6864
vi.clearAllMocks();
65+
document.body.innerHTML = '';
6966
draftStore.internalBootApplySucceeded = false;
7067
});
7168

@@ -78,6 +75,28 @@ describe('OnboardingNextStepsStep', () => {
7875
},
7976
global: {
8077
plugins: [createTestI18n()],
78+
stubs: {
79+
UAlert: {
80+
props: ['description'],
81+
template: '<div>{{ description }}<slot name="description" /></div>',
82+
},
83+
UButton: {
84+
props: ['disabled'],
85+
emits: ['click'],
86+
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
87+
},
88+
UModal: {
89+
props: ['open', 'title', 'description'],
90+
template: `
91+
<div v-if="open" data-testid="dialog">
92+
<div>{{ title }}</div>
93+
<div>{{ description }}</div>
94+
<slot name="body" />
95+
<slot name="footer" />
96+
</div>
97+
`,
98+
},
99+
},
81100
},
82101
});
83102

0 commit comments

Comments
 (0)