Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
658c29d
refactor(onboarding): standardize onboarding controls on Nuxt UI
Ajit-Mehrotra Mar 23, 2026
9c1be62
feat(unraid-ui): expose open state in Accordion slot props
Ajit-Mehrotra Mar 24, 2026
b8c983d
refactor(onboarding): replace HeadlessUI Disclosure with Accordion
Ajit-Mehrotra Mar 24, 2026
e4bff27
test(onboarding): update mocks for Accordion migration
Ajit-Mehrotra Mar 24, 2026
0752200
fix(onboarding): raise USelectMenu dropdown z-index inside Dialog
Ajit-Mehrotra Mar 24, 2026
4e7997b
fix(onboarding): disable search input on USelectMenu dropdowns
Ajit-Mehrotra Mar 24, 2026
f65c495
fix(onboarding): allow Back navigation during boot context loading
Ajit-Mehrotra Mar 24, 2026
d07a888
fix(onboarding): show not-allowed cursor on disabled nav buttons
Ajit-Mehrotra Mar 24, 2026
0f014d9
fix(unraid-ui): show disabled state on BrandButton via aria-disabled
Ajit-Mehrotra Mar 24, 2026
750981e
fix(onboarding): restore radio buttons for boot mode selection
Ajit-Mehrotra Mar 24, 2026
525de04
chore(onboarding): remove unused setBootMode function
Ajit-Mehrotra Mar 24, 2026
8ddb49b
fix(onboarding): address boot step UI regressions
Ajit-Mehrotra Mar 24, 2026
ec8d83b
fix(onboarding): fix device select trigger height when empty
Ajit-Mehrotra Mar 24, 2026
92dc2ef
fix(onboarding): remove magic min-height from device select
Ajit-Mehrotra Mar 24, 2026
3551477
fix(onboarding): show placeholder text in device select menus
Ajit-Mehrotra Mar 25, 2026
37c23d4
fix(onboarding): allow Skip button during initial data loading
Ajit-Mehrotra Mar 25, 2026
73ce3d3
fix(onboarding): remove duplicate chevron and hover underline in plug…
Ajit-Mehrotra Mar 25, 2026
543fbdb
fix(onboarding): fix plugin accordion border, chevron color, and spacing
Ajit-Mehrotra Mar 25, 2026
2c8d718
refactor(onboarding): convert initialization blockquote to UAlert
Ajit-Mehrotra Mar 25, 2026
4446429
fix(unraid-ui): add right padding to accordion trigger
Ajit-Mehrotra Mar 25, 2026
b1b5b2c
fix(onboarding): polish skip dialog and skip button in license step
Ajit-Mehrotra Mar 25, 2026
bbfea54
fix(onboarding): use solid variant for internal boot info alert
Ajit-Mehrotra Mar 25, 2026
311bd76
fix(onboarding): use neutral color for internal boot alerts
Ajit-Mehrotra Mar 25, 2026
6fcd609
refactor(accordion): revert global style changes, use optional props
Ajit-Mehrotra Mar 25, 2026
c640073
fix(onboarding): convert drive warnings blockquote to UAlert
Ajit-Mehrotra Mar 25, 2026
bc98afb
fix(test): fix drive warning test for auto-imported USelectMenu
Ajit-Mehrotra Mar 25, 2026
1da72fb
fix(onboarding): use subtle variant for summary initialization alert
Ajit-Mehrotra Mar 25, 2026
5643f5e
fix(onboarding): remove empty body from apply result dialog
Ajit-Mehrotra Mar 25, 2026
3c638d6
fix(onboarding): reorder finalization log after server identity update
Ajit-Mehrotra Mar 25, 2026
11c98c9
fix(onboarding): prevent reboot double-click and slotCount NaN
Ajit-Mehrotra Mar 25, 2026
e38cdb5
feat: onboarding use history
elibosley Mar 25, 2026
fa2e385
merge: integrate PR 1964 (onboarding history + no-changes messaging)
Ajit-Mehrotra Mar 25, 2026
a0cd428
fix(onboarding): resolve type errors from PR 1964 merge
Ajit-Mehrotra Mar 25, 2026
55280d3
fix(onboarding): fix eligibility accordion border, chevron, and hover
Ajit-Mehrotra Mar 25, 2026
0d06d8b
fix(onboarding): use default accordion chevron for eligibility panel
Ajit-Mehrotra Mar 25, 2026
fddfd10
fix(onboarding): align eligibility accordion chevron with View details
Ajit-Mehrotra Mar 25, 2026
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
6 changes: 6 additions & 0 deletions plugin/source/dynamix.unraid.net/install/doinst.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ cp usr/local/unraid-api/.env.production usr/local/unraid-api/.env
( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm )
( cd usr/local/bin ; rm -rf npx )
( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx )
( cd usr/local/bin ; rm -rf corepack )
( cd usr/local/bin ; ln -sf ../lib/node_modules/corepack/dist/corepack.js corepack )
( cd usr/local/bin ; rm -rf npm )
( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm )
( cd usr/local/bin ; rm -rf npx )
( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx )
2 changes: 1 addition & 1 deletion unraid-ui/src/components/brand/brand-button.variants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cva, type VariantProps } from 'class-variance-authority';

export const brandButtonVariants = cva(
'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',
'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',
{
variants: {
variant: {
Expand Down
37 changes: 34 additions & 3 deletions unraid-ui/src/components/common/accordion/Accordion.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AccordionRoot,
AccordionTrigger,
} from '@/components/ui/accordion';
import { computed, ref, watch } from 'vue';

export interface AccordionItemData {
value: string;
Expand All @@ -18,21 +19,50 @@ export interface AccordionProps {
type?: 'single' | 'multiple';
collapsible?: boolean;
defaultValue?: string | string[];
modelValue?: string | string[];
class?: string;
itemClass?: string;
triggerClass?: string;
}

const props = withDefaults(defineProps<AccordionProps>(), {
type: 'single',
collapsible: true,
});

const emit = defineEmits<{
'update:modelValue': [value: string | string[]];
}>();

const openValue = ref<string | string[] | undefined>(props.modelValue ?? props.defaultValue);

watch(
() => props.modelValue,
(val) => {
if (val !== undefined) openValue.value = val;
}
);

function isItemOpen(itemValue: string): boolean {
if (!openValue.value) return false;
if (Array.isArray(openValue.value)) return openValue.value.includes(itemValue);
return openValue.value === itemValue;
}

function handleUpdate(value: string | string[]) {
openValue.value = value;
emit('update:modelValue', value);
}
</script>

<template>
<AccordionRoot
:type="type"
:collapsible="collapsible"
:default-value="defaultValue"
:model-value="openValue"
:class="props.class"
@update:model-value="handleUpdate"
>
<!-- Default slot for direct composition -->
<slot />
Expand All @@ -44,14 +74,15 @@ const props = withDefaults(defineProps<AccordionProps>(), {
:key="item.value"
:value="item.value"
:disabled="item.disabled"
:class="props.itemClass"
>
<AccordionTrigger>
<slot name="trigger" :item="item">
<AccordionTrigger :class="props.triggerClass">
<slot name="trigger" :item="item" :open="isItemOpen(item.value)">
{{ item.title }}
</slot>
</AccordionTrigger>
<AccordionContent>
<slot name="content" :item="item">
<slot name="content" :item="item" :open="isItemOpen(item.value)">
{{ item.content }}
</slot>
</AccordionContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,36 +69,6 @@ vi.mock('@unraid/ui', () => ({
template:
'<button data-testid="brand-button" :disabled="disabled" @click="$emit(\'click\')"><slot />{{ text }}</button>',
},
Select: {
props: ['modelValue', 'items', 'disabled'],
emits: ['update:modelValue'],
template: `
<select
data-testid="select"
:disabled="disabled"
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="item in items" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
`,
},
}));

vi.mock('@headlessui/vue', () => ({
Switch: {
props: ['modelValue', 'disabled'],
emits: ['update:modelValue'],
template: `
<input
data-testid="switch"
type="checkbox"
:checked="modelValue"
:disabled="disabled"
@change="$emit('update:modelValue', $event.target.checked)"
/>
`,
},
}));

vi.mock('@vvo/tzdb', () => ({
Expand Down Expand Up @@ -173,6 +143,35 @@ const mountComponent = (props: Record<string, unknown> = {}) => {
},
global: {
plugins: [createTestI18n()],
stubs: {
USelectMenu: {
props: ['modelValue', 'items', 'disabled'],
emits: ['update:modelValue'],
template: `
<select
data-testid="select-menu"
:disabled="disabled"
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="item in items" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
`,
},
USwitch: {
props: ['modelValue', 'disabled'],
emits: ['update:modelValue'],
template: `
<input
data-testid="switch"
type="checkbox"
:checked="modelValue"
:disabled="disabled"
@change="$emit('update:modelValue', $event.target.checked)"
/>
`,
},
},
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils';
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils';

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type {
InternalBootApplyMessages,
Expand All @@ -11,6 +11,14 @@ import type {
import OnboardingInternalBootStandalone from '~/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue';
import { createTestI18n } from '../../utils/i18n';

const INTERNAL_BOOT_HISTORY_STATE_KEY = '__unraidOnboardingInternalBoot';

type InternalBootHistoryState = {
sessionId: string;
stepId: 'CONFIGURE_BOOT' | 'SUMMARY';
position: number;
};

const {
draftStore,
applyInternalBootSelectionMock,
Expand Down Expand Up @@ -129,10 +137,50 @@ const mountComponent = () =>
},
});

enableAutoUnmount(afterEach);

const getInternalBootHistoryState = (): InternalBootHistoryState | null => {
const state =
typeof window.history.state === 'object' && window.history.state !== null
? (window.history.state as Record<string, unknown>)
: null;
const candidate = state?.[INTERNAL_BOOT_HISTORY_STATE_KEY];
if (!candidate || typeof candidate !== 'object') {
return null;
}

const sessionId =
typeof (candidate as Record<string, unknown>).sessionId === 'string'
? ((candidate as Record<string, unknown>).sessionId as string)
: null;
const stepId =
(candidate as Record<string, unknown>).stepId === 'CONFIGURE_BOOT' ||
(candidate as Record<string, unknown>).stepId === 'SUMMARY'
? ((candidate as Record<string, unknown>).stepId as InternalBootHistoryState['stepId'])
: null;
const position = Number((candidate as Record<string, unknown>).position);

if (!sessionId || !stepId || !Number.isInteger(position)) {
return null;
}

return {
sessionId,
stepId,
position,
};
};

const dispatchPopstate = (state: Record<string, unknown> | null) => {
window.history.replaceState(state, '', window.location.href);
window.dispatchEvent(new PopStateEvent('popstate', { state }));
};

describe('OnboardingInternalBoot.standalone.vue', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
window.history.replaceState(null, '', window.location.href);

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

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

it('restores the configure step when browser back leaves a reversible result', async () => {
const wrapper = mountComponent();

await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
await flushPromises();

const currentHistoryState = getInternalBootHistoryState();
expect(currentHistoryState).toMatchObject({
stepId: 'SUMMARY',
position: 1,
});

dispatchPopstate({
[INTERNAL_BOOT_HISTORY_STATE_KEY]: {
sessionId: currentHistoryState?.sessionId,
stepId: 'CONFIGURE_BOOT',
position: 0,
},
});
await flushPromises();
await wrapper.vm.$nextTick();

expect(stepperPropsRef.value).toMatchObject({
activeStepIndex: 0,
});
expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true);
});

it('closes when browser back leaves a fully applied result', async () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: true,
};

const wrapper = mountComponent();

await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
await flushPromises();

const currentHistoryState = getInternalBootHistoryState();
expect(currentHistoryState).toMatchObject({
stepId: 'SUMMARY',
position: 1,
});

dispatchPopstate({
[INTERNAL_BOOT_HISTORY_STATE_KEY]: {
sessionId: currentHistoryState?.sessionId,
stepId: 'CONFIGURE_BOOT',
position: 0,
},
});
await flushPromises();

expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false);
});

it('closes locally after showing a result', async () => {
const historyGoSpy = vi.spyOn(window.history, 'go').mockImplementation(() => {});
const wrapper = mountComponent();

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

expect(historyGoSpy).toHaveBeenCalledWith(-2);
dispatchPopstate(null);
await flushPromises();

expect(wrapper.find('[data-testid="internal-boot-standalone-result"]').exists()).toBe(false);
});

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

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

expect(historyGoSpy).toHaveBeenCalledWith(-1);
dispatchPopstate(null);
await flushPromises();

expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false);
});

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

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

expect(historyGoSpy).toHaveBeenCalledWith(-1);
dispatchPopstate(null);
await flushPromises();

expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false);
});
Expand Down Expand Up @@ -337,6 +462,7 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
});

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

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

dispatchPopstate(null);
await flushPromises();

expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
});
});
Loading
Loading