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
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,28 @@ vi.mock('@unraid/ui', () => ({
template:
'<button data-testid="brand-button" :disabled="disabled" @click="$emit(\'click\')">{{ text }}</button>',
},
Select: {
props: ['modelValue', 'items', 'disabled', 'placeholder'],
emits: ['update:modelValue'],
template: `
<select
data-testid="select"
:disabled="disabled"
:value="modelValue ?? ''"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-if="placeholder" value="">{{ placeholder }}</option>
<option
v-for="item in items"
:key="item.value"
:value="item.value"
:disabled="item.disabled"
>
{{ item.label }}
</option>
</select>
`,
},
}));

vi.mock('@vue/apollo-composable', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ const languageItems = computed(() => {
});

const isLanguageDisabled = computed(() => isLanguagesLoading.value || !!languagesQueryError.value);
const onboardingSelectClasses =
'w-full border-muted bg-bg text-highlighted data-[placeholder]:text-muted focus:ring-primary focus:ring-offset-0';

const handleSubmit = async () => {
if (serverNameValidation.value || serverDescriptionValidation.value) {
Expand Down Expand Up @@ -506,7 +508,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
v-model="selectedTimeZone"
:items="timeZoneItems"
:placeholder="t('onboarding.coreSettings.selectTimezonePlaceholder')"
class="w-full"
:class="onboardingSelectClasses"
:disabled="isBusy"
size="lg"
/>
Expand All @@ -523,7 +525,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
:placeholder="
isLanguagesLoading ? t('common.loading') : t('onboarding.coreSettings.selectLanguage')
"
class="w-full"
:class="onboardingSelectClasses"
:disabled="isBusy || isLanguageDisabled"
size="lg"
/>
Expand Down Expand Up @@ -582,7 +584,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
<Select
v-model="selectedTheme"
:items="themeItems"
class="w-full"
:class="onboardingSelectClasses"
:disabled="isBusy"
size="lg"
/>
Expand Down
111 changes: 81 additions & 30 deletions web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
ChevronRightIcon,
ExclamationTriangleIcon,
} from '@heroicons/vue/24/solid';
import { BrandButton } from '@unraid/ui';
import { BrandButton, Select } from '@unraid/ui';
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 All @@ -20,6 +20,7 @@ import type {
OnboardingBootMode,
OnboardingInternalBootSelection,
} from '@/components/Onboarding/store/onboardingDraft';
import type { SelectItemType } from '@unraid/ui';
import type { GetInternalBootContextQuery } from '~/composables/gql/graphql';

import { GetInternalBootContextDocument } from '~/composables/gql/graphql';
Expand Down Expand Up @@ -397,6 +398,24 @@ const visiblePresetOptions = computed(() => {
}));
});

const slotCountItems = computed<SelectItemType[]>(() =>
slotOptions.value.map((option) => ({
value: option,
label: String(option),
}))
);

const bootSizePresetItems = computed<SelectItemType[]>(() => [
...visiblePresetOptions.value.map((option) => ({
value: option.value,
label: option.label,
})),
{
value: 'custom',
label: t('onboarding.internalBootStep.bootSize.custom'),
},
]);

const bootSizeMiB = computed(() => {
if (bootSizePreset.value === 'custom') {
const sizeGb = Number.parseInt(customBootSizeGb.value, 10);
Expand Down Expand Up @@ -497,6 +516,48 @@ const isDeviceDisabled = (deviceId: string, index: number) => {
);
};

const getDeviceSelectItems = (index: number): SelectItemType[] =>
deviceOptions.value.map((option) => ({
value: option.value,
label: option.label,
disabled: isDeviceDisabled(option.value, index),
Comment on lines +519 to +523
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore an explicit empty device option

Mapping deviceOptions directly into items removes the previously selectable empty value, and placeholder only affects display state. Because duplicate device choices are disabled via isDeviceDisabled, users with two selected slots can no longer swap assignments (for example A/B to B/A) since there is no way to temporarily clear one slot. Please add an explicit empty item (equivalent to the old <option value="">) so a slot can be unselected and reassigned.

Useful? React with 👍 / 👎.

}));

const toSelectString = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}

if (typeof value === 'number' || typeof value === 'bigint') {
return String(value);
}

return '';
};

const handleSlotCountChange = (value: unknown) => {
const parsedValue =
typeof value === 'number'
? value
: typeof value === 'bigint'
? Number(value)
: Number.parseInt(toSelectString(value), 10);
if (Number.isFinite(parsedValue) && parsedValue >= 1 && parsedValue <= 2) {
slotCount.value = parsedValue;
}
};

const handleDeviceSelection = (index: number, value: unknown) => {
selectedDevices.value[index] = toSelectString(value);
};

const handleBootSizePresetChange = (value: unknown) => {
bootSizePreset.value = toSelectString(value);
};

const onboardingSelectClasses =
'w-full border-muted bg-bg text-highlighted data-[placeholder]:text-muted focus:ring-primary focus:ring-offset-0';

const buildValidatedSelection = (): OnboardingInternalBootSelection | null => {
const normalizedPoolName = poolName.value.trim();
if (!normalizedPoolName) {
Expand Down Expand Up @@ -793,13 +854,13 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
<span class="text-muted text-sm font-medium">
{{ t('onboarding.internalBootStep.fields.slots') }}
</span>
<select
v-model.number="slotCount"
class="border-muted bg-bg focus:ring-primary w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
<Select
:model-value="slotCount"
:items="slotCountItems"
:class="onboardingSelectClasses"
:disabled="isBusy"
>
<option v-for="option in slotOptions" :key="option" :value="option">{{ option }}</option>
</select>
@update:model-value="handleSlotCountChange"
/>
</label>
</div>

Expand All @@ -811,21 +872,14 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
<label class="text-muted text-sm font-medium">{{
t('onboarding.internalBootStep.fields.deviceSlot', { index })
}}</label>
<select
v-model="selectedDevices[index - 1]"
class="border-muted bg-bg focus:ring-primary w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
<Select
:model-value="selectedDevices[index - 1] || undefined"
:items="getDeviceSelectItems(index - 1)"
:placeholder="t('onboarding.internalBootStep.fields.selectDevice')"
:class="onboardingSelectClasses"
:disabled="isBusy"
>
<option value="">{{ t('onboarding.internalBootStep.fields.selectDevice') }}</option>
<option
v-for="option in deviceOptions"
:key="option.value"
:value="option.value"
:disabled="isDeviceDisabled(option.value, index - 1)"
>
{{ option.label }}
</option>
</select>
@update:model-value="handleDeviceSelection(index - 1, $event)"
/>
</div>
</div>

Expand All @@ -834,16 +888,13 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
<span class="text-muted text-sm font-medium">
{{ t('onboarding.internalBootStep.fields.bootReservedSize') }}
</span>
<select
v-model="bootSizePreset"
class="border-muted bg-bg focus:ring-primary w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
<Select
:model-value="bootSizePreset"
:items="bootSizePresetItems"
:class="onboardingSelectClasses"
:disabled="isBusy"
>
<option v-for="option in visiblePresetOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
<option value="custom">{{ t('onboarding.internalBootStep.bootSize.custom') }}</option>
</select>
@update:model-value="handleBootSizePresetChange"
/>
</label>

<label class="space-y-2">
Expand Down
Loading