feat: improved update ui (#1691)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Global awaitable confirmation modal for notification actions.
  * “Ignore this release” toggle that persists to the server when used.
* New test pages and standalone test controls for the update modal and
theme switching.

* **Refactor**
* Update modal rebuilt with a responsive layout, unified “Update
Available” title, revised action logic, and centralized modal plumbing.

* **Style**
* OS Update highlight block, improved spacing, refreshed iconography,
and tooltips clarifying actions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-09-11 21:35:13 -04:00
committed by GitHub
parent 2fef10c94a
commit a59b363ebc
13 changed files with 966 additions and 84 deletions
+1 -4
View File
@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
This is the Unraid API monorepo containing multiple packages that provide API functionality for Unraid servers. It uses pnpm workspaces with the following structure:
- `/api` - Core NestJS API server with GraphQL
- `/web` - Nuxt.js frontend application
- `/web` - Vue 3 frontend application
- `/unraid-ui` - Vue 3 component library
- `/plugin` - Unraid plugin package (.plg)
- `/packages` - Shared packages and API plugins
@@ -128,9 +128,6 @@ Enables GraphQL playground at `http://tower.local/graphql`
- **Use Mocks Correctly**: Mocks should be used as nouns, not verbs.
#### Vue Component Testing
- This is a Nuxt.js app but we are testing with vitest outside of the Nuxt environment
- Nuxt is currently set to auto import so some vue files may need compute or ref imported
- Use pnpm when running terminal commands and stay within the web directory
- Tests are located under `web/__test__`, run with `pnpm test`
- Use `mount` from Vue Test Utils for component testing
@@ -51,7 +51,7 @@ const classes = computed(() => {
});
const needsBrandGradientBackground = computed(() => {
return ['outline-solid', 'outline-primary'].includes(props.variant ?? '');
return ['outline', 'outline-solid', 'outline-primary'].includes(props.variant ?? '');
});
const isLink = computed(() => Boolean(props.href));
+3
View File
@@ -31,6 +31,7 @@ declare module 'vue' {
ChangelogModal: typeof import('./src/components/UpdateOs/ChangelogModal.vue')['default']
CheckUpdateResponseModal: typeof import('./src/components/UpdateOs/CheckUpdateResponseModal.vue')['default']
'ColorSwitcher.standalone': typeof import('./src/components/ColorSwitcher.standalone.vue')['default']
ConfirmDialog: typeof import('./src/components/ConfirmDialog.vue')['default']
'ConnectSettings.standalone': typeof import('./src/components/ConnectSettings/ConnectSettings.standalone.vue')['default']
Console: typeof import('./src/components/Docker/Console.vue')['default']
Detail: typeof import('./src/components/LayoutViews/Detail/Detail.vue')['default']
@@ -97,6 +98,8 @@ declare module 'vue' {
SsoButtons: typeof import('./src/components/sso/SsoButtons.vue')['default']
SsoProviderButton: typeof import('./src/components/sso/SsoProviderButton.vue')['default']
Status: typeof import('./src/components/UpdateOs/Status.vue')['default']
'TestThemeSwitcher.standalone': typeof import('./src/components/TestThemeSwitcher.standalone.vue')['default']
'TestUpdateModal.standalone': typeof import('./src/components/UpdateOs/TestUpdateModal.standalone.vue')['default']
'ThemeSwitcher.standalone': typeof import('./src/components/ThemeSwitcher.standalone.vue')['default']
ThirdPartyDrivers: typeof import('./src/components/UpdateOs/ThirdPartyDrivers.vue')['default']
Trial: typeof import('./src/components/UserProfile/Trial.vue')['default']
+8
View File
@@ -179,6 +179,14 @@
</div>
<a href="/test-pages/os-management.html">Open →</a>
</div>
<div class="page-item">
<div>
<h3>Update Modal Testing <span class="badge new">NEW</span></h3>
<p>Test various update scenarios including expired licenses, renewals, and auth requirements</p>
</div>
<a href="/test-update-modal.html">Open →</a>
</div>
</div>
</div>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import {
Button,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
} from '@unraid/ui';
import { useConfirm } from '~/composables/useConfirm';
const { isOpen, state, handleConfirm, handleCancel } = useConfirm();
</script>
<template>
<DialogRoot :open="isOpen" @update:open="!$event && handleCancel()">
<DialogContent>
<DialogHeader v-if="state">
<DialogTitle>{{ state.title }}</DialogTitle>
<DialogDescription v-if="state.description">
{{ state.description }}
</DialogDescription>
</DialogHeader>
<DialogFooter v-if="state">
<div class="flex w-full justify-between gap-3">
<Button variant="outline" @click="handleCancel">
{{ state.cancelText }}
</Button>
<Button :variant="state.confirmVariant" @click="handleConfirm">
{{ state.confirmText }}
</Button>
</div>
</DialogFooter>
</DialogContent>
</DialogRoot>
</template>
+21 -6
View File
@@ -21,6 +21,7 @@ import {
} from '@unraid/ui';
import { Settings } from 'lucide-vue-next';
import ConfirmDialog from '~/components/ConfirmDialog.vue';
import {
archiveAllNotifications,
deleteArchivedNotifications,
@@ -37,10 +38,12 @@ import NotificationsList from '~/components/Notifications/List.vue';
import { useTrackLatestSeenNotification } from '~/composables/api/use-notifications';
import { useFragment } from '~/composables/gql';
import { NotificationImportance as Importance, NotificationType } from '~/composables/gql/graphql';
import { useConfirm } from '~/composables/useConfirm';
const { mutate: archiveAll, loading: loadingArchiveAll } = useMutation(archiveAllNotifications);
const { mutate: deleteArchives, loading: loadingDeleteAll } = useMutation(deleteArchivedNotifications);
const { mutate: recalculateOverview } = useMutation(resetOverview);
const { confirm } = useConfirm();
const importance = ref<Importance | undefined>(undefined);
const filterItems = [
@@ -52,17 +55,26 @@ const filterItems = [
];
const confirmAndArchiveAll = async () => {
if (confirm('This will archive all notifications on your Unraid server. Continue?')) {
const confirmed = await confirm({
title: 'Archive All Notifications',
description: 'This will archive all notifications on your Unraid server. Continue?',
confirmText: 'Archive All',
confirmVariant: 'primary',
});
if (confirmed) {
await archiveAll();
}
};
const confirmAndDeleteArchives = async () => {
if (
confirm(
'This will permanently delete all archived notifications currently on your Unraid server. Continue?'
)
) {
const confirmed = await confirm({
title: 'Delete All Archived Notifications',
description:
'This will permanently delete all archived notifications currently on your Unraid server. This action cannot be undone.',
confirmText: 'Delete All',
confirmVariant: 'destructive',
});
if (confirmed) {
await deleteArchives();
}
};
@@ -230,4 +242,7 @@ const prepareToViewNotifications = () => {
</div>
</SheetContent>
</Sheet>
<!-- Global Confirm Dialog -->
<ConfirmDialog />
</template>
@@ -0,0 +1,40 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { Select } from '@unraid/ui';
import type { Theme } from '~/themes/types';
import { useThemeStore } from '~/store/theme';
const themeStore = useThemeStore();
const { theme } = storeToRefs(themeStore);
// Available theme options
const items = [
{ value: 'white', label: 'Light' },
{ value: 'black', label: 'Dark' },
{ value: 'azure', label: 'Azure' },
{ value: 'gray', label: 'Gray' },
];
// Current theme value
const currentTheme = computed({
get: () => theme.value.name,
set: (value: string) => {
const newTheme: Theme = {
...theme.value,
name: value,
};
themeStore.setTheme(newTheme);
},
});
</script>
<template>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-white">Theme:</span>
<Select v-model="currentTheme" :items="items" placeholder="Select theme" class="w-32" />
</div>
</template>
@@ -2,27 +2,30 @@
import { computed, onBeforeMount, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { ArrowDownTrayIcon } from '@heroicons/vue/24/outline';
import {
ArrowTopRightOnSquareIcon,
CogIcon,
EyeIcon,
IdentificationIcon,
KeyIcon,
XMarkIcon,
} from '@heroicons/vue/24/solid';
import {
BrandButton,
BrandLoading,
Button,
cn,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
Label,
ResponsiveModal,
ResponsiveModalFooter,
ResponsiveModalHeader,
ResponsiveModalTitle,
Switch,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@unraid/ui';
import type { BrandButtonProps } from '@unraid/ui';
@@ -96,6 +99,9 @@ watch(updateOsIgnoredReleases, (newVal, oldVal) => {
}
});
// Get the localized 'Close' text for comparison
const localizedCloseText = computed(() => props.t('Close'));
const notificationsSettings = computed(() => {
return !updateOsNotificationsEnabled.value
? props.t(
@@ -115,30 +121,18 @@ const modalCopy = computed((): ModalCopy | null => {
};
}
// Use the release date
let formattedReleaseDate = '';
if (availableReleaseDate.value) {
// build string with prefix
formattedReleaseDate = props.t('Release date {0}', [userFormattedReleaseDate.value]);
}
if (availableWithRenewal.value) {
const description = regUpdatesExpired.value
? `${props.t('Eligible for updates released on or before {0}.', [formattedRegExp.value])} ${props.t('Extend your license to access the latest updates.')}`
: props.t('Eligible for free feature updates until {0}', [formattedRegExp.value]);
return {
title: props.t('Unraid OS {0} Released', [availableWithRenewal.value]),
description: `<p>${formattedReleaseDate}</p><p>${description}</p>`,
title: props.t('Update Available'),
description: description,
};
} else if (available.value) {
const description = availableRequiresAuth.value
? props.t('Release requires verification to update')
: undefined;
return {
title: props.t('Unraid OS {0} Update Available', [available.value]),
description: description
? `<p>${formattedReleaseDate}</p><p>${description}</p>`
: formattedReleaseDate,
title: props.t('Update Available'),
description: undefined,
};
} else if (!available.value && !availableWithRenewal.value) {
return {
@@ -169,8 +163,18 @@ const extraLinks = computed((): BrandButtonProps[] => {
});
const actionButtons = computed((): BrandButtonProps[] | null => {
// If ignoring release, show close button as primary action
if (ignoreThisRelease.value && (available.value || availableWithRenewal.value)) {
return [
{
click: () => close(),
text: props.t('Close'),
},
];
}
// update not available or no action buttons default closing
if (!available.value || ignoreThisRelease.value) {
if (!available.value && !availableWithRenewal.value) {
return null;
}
@@ -274,20 +278,58 @@ const modalWidth = computed(() => {
</script>
<template>
<DialogRoot :open="open" @update:open="(value) => !value && close()">
<DialogContent :class="modalWidth" :show-close-button="!checkForUpdatesLoading">
<DialogHeader v-if="modalCopy?.title">
<DialogTitle>
<ResponsiveModal
:open="open"
:dialog-class="modalWidth"
:sheet-class="'h-full'"
:show-close-button="!checkForUpdatesLoading"
@update:open="(value) => !value && close()"
>
<div class="flex h-full flex-col">
<ResponsiveModalHeader v-if="modalCopy?.title">
<ResponsiveModalTitle>
{{ modalCopy.title }}
</DialogTitle>
</ResponsiveModalTitle>
<DialogDescription v-if="modalCopy?.description">
<span v-html="modalCopy.description" />
</DialogDescription>
</DialogHeader>
</ResponsiveModalHeader>
<div v-if="renderMainSlot" class="flex flex-col gap-4">
<div v-if="renderMainSlot" class="flex flex-1 flex-col gap-6 overflow-y-auto px-6">
<BrandLoading v-if="checkForUpdatesLoading" class="mx-auto w-[150px]" />
<div v-else class="flex flex-col gap-y-4">
<div v-else class="flex flex-col gap-y-6">
<!-- OS Update highlight section -->
<div v-if="available || availableWithRenewal" class="flex flex-col items-center gap-4 py-4">
<div class="bg-primary/10 flex items-center justify-center rounded-full p-4">
<ArrowDownTrayIcon class="text-primary h-8 w-8" />
</div>
<div class="text-center">
<h2 class="text-foreground text-3xl font-bold">
{{ availableWithRenewal || available }}
</h2>
<p v-if="userFormattedReleaseDate" class="text-muted-foreground mt-2 text-center text-sm">
Released on {{ userFormattedReleaseDate }}
</p>
<p
v-if="availableRequiresAuth && !availableWithRenewal"
class="mt-2 text-center text-sm text-amber-500"
>
{{ t('Requires verification to update') }}
</p>
</div>
<div class="mt-4">
<div
class="hover:bg-muted/50 flex cursor-pointer items-center gap-3 rounded-lg p-2 transition-colors"
@click="ignoreThisRelease = !ignoreThisRelease"
>
<Switch v-model="ignoreThisRelease" @click.stop />
<Label class="text-muted-foreground cursor-pointer text-sm">
{{ t('Ignore this release until next reboot') }}
</Label>
</div>
</div>
</div>
<div
v-if="extraLinks.length > 0"
:class="cn('xs:!flex-row flex flex-col justify-center gap-2')"
@@ -295,7 +337,7 @@ const modalWidth = computed(() => {
<BrandButton
v-for="item in extraLinks"
:key="item.text"
:btn-style="item.variant ?? undefined"
:variant="item.variant ?? undefined"
:href="item.href ?? undefined"
:icon="item.icon"
:icon-right="item.iconRight"
@@ -306,23 +348,8 @@ const modalWidth = computed(() => {
/>
</div>
<div v-if="available || availableWithRenewal" class="mx-auto">
<div class="flex items-center justify-center gap-2 rounded p-2">
<Switch
v-model="ignoreThisRelease"
:class="
ignoreThisRelease
? 'from-unraid-red to-orange bg-linear-to-r'
: 'data-[state=unchecked]:bg-opacity-10 data-[state=unchecked]:bg-foreground data-[state=unchecked]:bg-transparent'
"
/>
<Label class="text-base">
{{ t('Ignore this release until next reboot') }}
</Label>
</div>
</div>
<div
v-else-if="updateOsIgnoredReleases.length > 0"
v-if="updateOsIgnoredReleases.length > 0 && !(available || availableWithRenewal)"
class="mx-auto flex w-full max-w-[640px] flex-col gap-2"
>
<h3 class="text-left text-base font-semibold italic">
@@ -338,7 +365,7 @@ const modalWidth = computed(() => {
</div>
</div>
<DialogFooter>
<ResponsiveModalFooter>
<div
:class="
cn(
@@ -347,31 +374,75 @@ const modalWidth = computed(() => {
)
"
>
<div :class="cn('xs:!flex-row flex flex-col-reverse justify-start gap-2')">
<Button variant="ghost" @click="close">
<XMarkIcon class="mr-2 h-4 w-4" />
{{ t('Close') }}
</Button>
<Button variant="ghost" @click="accountStore.updateOs()">
<ArrowTopRightOnSquareIcon class="mr-2 h-4 w-4" />
{{ t('More options') }}
</Button>
<div
v-if="actionButtons"
:class="cn('xs:!flex-row flex flex-col-reverse justify-start gap-3')"
>
<TooltipProvider>
<Tooltip :delay-duration="0">
<TooltipTrigger as-child>
<Button variant="ghost" @click="accountStore.updateOs()">
<ArrowTopRightOnSquareIcon class="mr-2 h-4 w-4" />
{{ t('More Options') }}
</Button>
</TooltipTrigger>
<TooltipContent class="max-w-xs">
<div class="flex items-start gap-2">
<ArrowTopRightOnSquareIcon
class="text-muted-foreground mt-0.5 h-4 w-4 flex-shrink-0"
/>
<p class="text-left text-sm">
{{
t(
'Manage update preferences including beta access and version selection at account.unraid.net'
)
}}
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div v-if="actionButtons" :class="cn('xs:!flex-row flex flex-col justify-end gap-2')">
<BrandButton
v-for="item in actionButtons"
:key="item.text"
:btn-style="item.variant ?? undefined"
:icon="item.icon"
:icon-right="item.iconRight"
:icon-right-hover-display="item.iconRightHoverDisplay"
:text="t(item.text ?? '')"
:title="item.title ? t(item.title) : undefined"
@click="item.click?.()"
/>
<div v-if="actionButtons" :class="cn('xs:!flex-row flex flex-col justify-end gap-3')">
<template v-for="item in actionButtons" :key="item.text">
<TooltipProvider v-if="ignoreThisRelease && item.text === localizedCloseText">
<Tooltip :delay-duration="300">
<TooltipTrigger as-child>
<BrandButton
:variant="item.variant ?? undefined"
:icon="item.icon"
:icon-right="item.iconRight"
:icon-right-hover-display="item.iconRightHoverDisplay"
:text="item.text ?? ''"
:title="item.title ? item.title : undefined"
@click="item.click?.()"
/>
</TooltipTrigger>
<TooltipContent>
<p>
{{
t(
'You can opt back in to an ignored release by clicking on the Check for Updates button in the header anytime'
)
}}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<BrandButton
v-else
:variant="item.variant ?? undefined"
:icon="item.icon"
:icon-right="item.iconRight"
:icon-right-hover-display="item.iconRightHoverDisplay"
:text="item.text ?? ''"
:title="item.title ? item.title : undefined"
@click="item.click?.()"
/>
</template>
</div>
</div>
</DialogFooter>
</DialogContent>
</DialogRoot>
</ResponsiveModalFooter>
</div>
</ResponsiveModal>
</template>
@@ -0,0 +1,292 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Button, Label, Switch } from '@unraid/ui';
import type { ServerState, ServerUpdateOsResponse } from '~/types/server';
import CheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue';
import { useServerStore } from '~/store/server';
import { useUpdateOsStore } from '~/store/updateOs';
const { t } = useI18n();
const updateOsStore = useUpdateOsStore();
const serverStore = useServerStore();
// Test scenarios
const testScenarios = [
{
id: 'expired-ineligible',
name: 'Expired key with ineligible update',
description: 'License expired, update available but not eligible',
serverState: 'EEXPIRED' as ServerState,
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: false,
changelog:
'https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/7.1.0.md',
changelogPretty: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
sha256: undefined, // requires auth
},
},
{
id: 'normal-update',
name: 'Normal update available',
description: 'Active license with eligible update',
serverState: 'BASIC' as ServerState,
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: true,
changelog:
'https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/7.1.0.md',
changelogPretty: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
sha256: 'abc123def456789',
},
},
{
id: 'renewal-required',
name: 'Update requires renewal',
description: 'License expired > 1 year, update requires renewal',
serverState: 'STARTER' as ServerState,
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: false,
changelog:
'https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/7.1.0.md',
changelogPretty: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
sha256: undefined,
},
},
{
id: 'no-update',
name: 'No update available',
description: 'Already on latest version',
serverState: 'BASIC' as ServerState,
updateResponse: {
version: '7.0.0',
name: 'Unraid 7.0.0',
date: '2024-01-15',
isNewer: false,
isEligible: true,
changelog:
'https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/7.0.0.md',
sha256: 'xyz789abc123',
},
},
{
id: 'trial-update',
name: 'Trial with update',
description: 'Trial license with update available',
serverState: 'TRIAL' as ServerState,
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: true,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
sha256: 'def456ghi789',
},
},
{
id: 'pro-auth-required',
name: 'Pro license - auth required',
description: 'Pro license but authentication required for download',
serverState: 'PRO' as ServerState,
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: true,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
sha256: undefined, // requires auth
},
},
];
// Component state
const selectedScenario = ref('normal-update');
const ignoreRelease = ref(false);
const checkingForUpdates = ref(false);
const ignoredReleases = ref<string[]>([]);
// Use the store's modal state directly
const modalOpen = computed({
get: () => updateOsStore.updateOsModalVisible,
set: (value) => updateOsStore.setModalOpen(value),
});
// Apply scenario
const applyScenario = () => {
const scenario = testScenarios.find((s) => s.id === selectedScenario.value);
if (!scenario) return;
// Set server state
const currentTime = Date.now();
const expiredTime = scenario.serverState === 'EEXPIRED' ? currentTime - 24 * 60 * 60 * 1000 : 0;
const regExp =
scenario.serverState === 'STARTER' ? currentTime - 400 * 24 * 60 * 60 * 1000 : undefined;
// Apply update response
if (scenario.serverState === 'EEXPIRED') {
serverStore.$patch({
expireTime: expiredTime,
state: scenario.serverState,
regExp: undefined,
});
} else if (scenario.serverState === 'STARTER') {
serverStore.$patch({
state: scenario.serverState,
regExp: regExp,
regTy: 'Starter',
});
} else {
serverStore.$patch({
state: scenario.serverState,
regExp: undefined,
expireTime: scenario.serverState === 'TRIAL' ? currentTime + 7 * 24 * 60 * 60 * 1000 : 0,
});
}
serverStore.setUpdateOsResponse(scenario.updateResponse as ServerUpdateOsResponse);
// Apply ignored releases
if (ignoreRelease.value && scenario.updateResponse.isNewer) {
if (!ignoredReleases.value.includes(scenario.updateResponse.version)) {
ignoredReleases.value.push(scenario.updateResponse.version);
}
} else {
ignoredReleases.value = ignoredReleases.value.filter((v) => v !== scenario.updateResponse.version);
}
serverStore.$patch({ updateOsIgnoredReleases: ignoredReleases.value });
};
// Watch for scenario changes
watch([selectedScenario, ignoreRelease], () => {
applyScenario();
});
// Watch for loading state changes
watch(checkingForUpdates, (value) => {
updateOsStore.checkForUpdatesLoading = value;
});
// Open modal with scenario
const openModal = () => {
applyScenario();
updateOsStore.checkForUpdatesLoading = checkingForUpdates.value;
updateOsStore.setModalOpen(true);
};
// Initialize
applyScenario();
const currentScenario = computed(() => testScenarios.find((s) => s.id === selectedScenario.value));
</script>
<template>
<div class="container mx-auto max-w-4xl p-6">
<div class="rounded-lg bg-white p-6 shadow-lg dark:bg-zinc-900">
<div class="mb-6">
<h2 class="mb-2 text-2xl font-bold">Update Modal Test Page</h2>
<p class="text-muted-foreground">
Test various update scenarios for the CheckUpdateResponseModal component
</p>
</div>
<div class="space-y-6">
<!-- Scenario Selection -->
<div class="space-y-4">
<Label class="text-lg font-semibold">Select Test Scenario</Label>
<div class="space-y-3">
<div v-for="scenario in testScenarios" :key="scenario.id" class="flex items-start space-x-3">
<input
type="radio"
:id="scenario.id"
:value="scenario.id"
v-model="selectedScenario"
class="mt-1 rounded-full"
/>
<div class="flex-1">
<Label :for="scenario.id" class="block cursor-pointer font-medium">
{{ scenario.name }}
</Label>
<p class="text-muted-foreground mt-1 text-sm">{{ scenario.description }}</p>
</div>
</div>
</div>
</div>
<!-- Options -->
<div class="space-y-4 border-t pt-4">
<h3 class="font-semibold">Options</h3>
<div class="flex items-center space-x-3">
<Switch id="ignore-release" v-model:checked="ignoreRelease" />
<Label for="ignore-release" class="cursor-pointer">Ignore this release</Label>
</div>
<div class="flex items-center space-x-3">
<Switch id="checking-updates" v-model:checked="checkingForUpdates" />
<Label for="checking-updates" class="cursor-pointer"
>Show checking for updates loading state</Label
>
</div>
</div>
<!-- Current State Display -->
<div class="space-y-2 border-t pt-4">
<h3 class="font-semibold">Current Scenario Details</h3>
<div class="space-y-1 font-mono text-sm">
<p><span class="font-semibold">Server State:</span> {{ currentScenario?.serverState }}</p>
<p>
<span class="font-semibold">Version:</span> {{ currentScenario?.updateResponse.version }}
</p>
<p>
<span class="font-semibold">Is Newer:</span> {{ currentScenario?.updateResponse.isNewer }}
</p>
<p>
<span class="font-semibold">Is Eligible:</span>
{{ currentScenario?.updateResponse.isEligible }}
</p>
<p>
<span class="font-semibold">Has SHA256:</span>
{{ !!currentScenario?.updateResponse.sha256 }}
</p>
<p>
<span class="font-semibold">Ignored Releases:</span>
{{ ignoredReleases.join(', ') || 'None' }}
</p>
</div>
</div>
<!-- Open Modal Button -->
<div class="border-t pt-4">
<Button @click="openModal" variant="primary" class="w-full"> Open Update Modal </Button>
</div>
</div>
</div>
<!-- The Modal Component -->
<CheckUpdateResponseModal
:open="modalOpen"
@update:open="
(val: boolean) => {
modalOpen = val;
}
"
:t="t"
/>
</div>
</template>
@@ -140,4 +140,14 @@ export const componentMappings: ComponentMapping[] = [
selector: ['unraid-toaster', 'uui-toaster'],
appId: 'toaster',
},
{
loader: () => import('../UpdateOs/TestUpdateModal.standalone.vue'),
selector: 'unraid-test-update-modal',
appId: 'test-update-modal',
},
{
loader: () => import('../TestThemeSwitcher.standalone.vue'),
selector: 'unraid-test-theme-switcher',
appId: 'test-theme-switcher',
},
];
+58
View File
@@ -0,0 +1,58 @@
import { ref, shallowRef } from 'vue';
export interface ConfirmOptions {
title: string;
description?: string;
confirmText?: string;
cancelText?: string;
confirmVariant?: 'primary' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
}
interface ConfirmState extends ConfirmOptions {
resolve: (value: boolean) => void;
}
const isOpen = ref(false);
const state = shallowRef<ConfirmState | null>(null);
export function useConfirm() {
const confirm = (options: ConfirmOptions): Promise<boolean> => {
// Resolve any existing dialog promise with false before opening a new one
if (state.value?.resolve) {
const previousResolve = state.value.resolve;
previousResolve(false);
state.value = null;
}
return new Promise((resolve) => {
state.value = {
...options,
confirmText: options.confirmText ?? 'Confirm',
cancelText: options.cancelText ?? 'Cancel',
confirmVariant: options.confirmVariant ?? 'primary',
resolve,
};
isOpen.value = true;
});
};
const handleConfirm = () => {
state.value?.resolve(true);
isOpen.value = false;
state.value = null;
};
const handleCancel = () => {
state.value?.resolve(false);
isOpen.value = false;
state.value = null;
};
return {
confirm,
isOpen,
state,
handleConfirm,
handleCancel,
};
}
+288
View File
@@ -0,0 +1,288 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Button, Label, Switch } from '@unraid/ui';
import { useDummyServerStore } from '~/_data/serverState';
import type { ServerState, ServerUpdateOsResponse } from '~/types/server';
import CheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue';
import { useServerStore } from '~/store/server';
import { useUpdateOsStore } from '~/store/updateOs';
const { t } = useI18n();
const updateOsStore = useUpdateOsStore();
const serverStore = useServerStore();
const dummyServerStore = useDummyServerStore();
// Test scenarios
const testScenarios = [
{
id: 'expired-ineligible',
name: 'Expired key with ineligible update',
description: 'License expired, update available but not eligible',
serverState: 'EEXPIRED',
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: false,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
changelogPretty:
'## Unraid 7.1.0\n\n### New Features\n- Feature 1\n- Feature 2\n\n### Bug Fixes\n- Fix 1\n- Fix 2',
sha256: undefined, // requires auth
},
},
{
id: 'normal-update',
name: 'Normal update available',
description: 'Active license with eligible update',
serverState: 'BASIC',
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: true,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
changelogPretty:
'## Unraid 7.1.0\n\n### New Features\n- Feature 1\n- Feature 2\n\n### Bug Fixes\n- Fix 1\n- Fix 2',
sha256: 'abc123def456789',
},
},
{
id: 'renewal-required',
name: 'Update requires renewal',
description: 'License expired > 1 year, update requires renewal',
serverState: 'STARTER',
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: false,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
changelogPretty:
'## Unraid 7.1.0\n\n### New Features\n- Feature 1\n- Feature 2\n\n### Bug Fixes\n- Fix 1\n- Fix 2',
sha256: undefined,
},
},
{
id: 'no-update',
name: 'No update available',
description: 'Already on latest version',
serverState: 'BASIC',
updateResponse: {
version: '7.0.0',
name: 'Unraid 7.0.0',
date: '2024-01-15',
isNewer: false,
isEligible: true,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.0.0/',
sha256: 'xyz789abc123',
},
},
{
id: 'trial-update',
name: 'Trial with update',
description: 'Trial license with update available',
serverState: 'TRIAL',
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: true,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
sha256: 'def456ghi789',
},
},
{
id: 'pro-auth-required',
name: 'Pro license - auth required',
description: 'Pro license but authentication required for download',
serverState: 'PRO',
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: true,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
sha256: undefined, // requires auth
},
},
];
// Component state
const selectedScenario = ref('normal-update');
const modalOpen = ref(false);
const ignoreRelease = ref(false);
const checkingForUpdates = ref(false);
const ignoredReleases = ref<string[]>([]);
// Apply scenario
const applyScenario = () => {
const scenario = testScenarios.find((s) => s.id === selectedScenario.value);
if (!scenario) return;
// Apply server state
dummyServerStore.selector =
scenario.serverState === 'EEXPIRED' || scenario.serverState === 'STARTER' ? 'default' : 'default';
// Set server state
const currentTime = Date.now();
const expiredTime = scenario.serverState === 'EEXPIRED' ? currentTime - 24 * 60 * 60 * 1000 : 0;
const regExp =
scenario.serverState === 'STARTER' ? currentTime - 400 * 24 * 60 * 60 * 1000 : undefined;
// Apply update response
if (scenario.serverState === 'EEXPIRED') {
serverStore.$patch({
expireTime: expiredTime,
state: 'EEXPIRED' as ServerState,
regExp: undefined,
});
} else if (scenario.serverState === 'STARTER') {
serverStore.$patch({
state: 'STARTER' as ServerState,
regExp: regExp,
regTy: 'Starter',
});
} else {
serverStore.$patch({
state: scenario.serverState as ServerState,
regExp: undefined,
expireTime: scenario.serverState === 'TRIAL' ? currentTime + 7 * 24 * 60 * 60 * 1000 : 0,
});
}
serverStore.setUpdateOsResponse(scenario.updateResponse as ServerUpdateOsResponse);
// Apply ignored releases
if (ignoreRelease.value && scenario.updateResponse.isNewer) {
if (!ignoredReleases.value.includes(scenario.updateResponse.version)) {
ignoredReleases.value.push(scenario.updateResponse.version);
}
} else {
ignoredReleases.value = ignoredReleases.value.filter((v) => v !== scenario.updateResponse.version);
}
serverStore.$patch({ updateOsIgnoredReleases: ignoredReleases.value });
};
// Watch for scenario changes
watch([selectedScenario, ignoreRelease], () => {
applyScenario();
});
// Open modal with scenario
const openModal = () => {
applyScenario();
updateOsStore.checkForUpdatesLoading = checkingForUpdates.value;
modalOpen.value = true;
updateOsStore.setModalOpen(true);
};
// Initialize
applyScenario();
const currentScenario = computed(() => testScenarios.find((s) => s.id === selectedScenario.value));
</script>
<template>
<div class="container mx-auto max-w-4xl p-6">
<div class="rounded-lg bg-white p-6 shadow-lg dark:bg-zinc-900">
<div class="mb-6">
<h2 class="mb-2 text-2xl font-bold">Update Modal Test Page</h2>
<p class="text-muted-foreground">
Test various update scenarios for the CheckUpdateResponseModal component
</p>
</div>
<div class="space-y-6">
<!-- Scenario Selection -->
<div class="space-y-4">
<Label class="text-lg font-semibold">Select Test Scenario</Label>
<div class="space-y-3">
<div v-for="scenario in testScenarios" :key="scenario.id" class="flex items-start space-x-3">
<input
type="radio"
:id="scenario.id"
:value="scenario.id"
v-model="selectedScenario"
class="mt-1 rounded-full"
/>
<div class="flex-1">
<Label :for="scenario.id" class="block cursor-pointer font-medium">
{{ scenario.name }}
</Label>
<p class="text-muted-foreground mt-1 text-sm">{{ scenario.description }}</p>
</div>
</div>
</div>
</div>
<!-- Options -->
<div class="space-y-4 border-t pt-4">
<h3 class="font-semibold">Options</h3>
<div class="flex items-center space-x-3">
<Switch id="ignore-release" v-model:checked="ignoreRelease" />
<Label for="ignore-release" class="cursor-pointer">Ignore this release</Label>
</div>
<div class="flex items-center space-x-3">
<Switch id="checking-updates" v-model:checked="checkingForUpdates" />
<Label for="checking-updates" class="cursor-pointer"
>Show checking for updates loading state</Label
>
</div>
</div>
<!-- Current State Display -->
<div class="space-y-2 border-t pt-4">
<h3 class="font-semibold">Current Scenario Details</h3>
<div class="space-y-1 font-mono text-sm">
<p><span class="font-semibold">Server State:</span> {{ currentScenario?.serverState }}</p>
<p>
<span class="font-semibold">Version:</span> {{ currentScenario?.updateResponse.version }}
</p>
<p>
<span class="font-semibold">Is Newer:</span> {{ currentScenario?.updateResponse.isNewer }}
</p>
<p>
<span class="font-semibold">Is Eligible:</span>
{{ currentScenario?.updateResponse.isEligible }}
</p>
<p>
<span class="font-semibold">Has SHA256:</span>
{{ !!currentScenario?.updateResponse.sha256 }}
</p>
<p>
<span class="font-semibold">Ignored Releases:</span>
{{ ignoredReleases.join(', ') || 'None' }}
</p>
</div>
</div>
<!-- Open Modal Button -->
<div class="border-t pt-4">
<Button @click="openModal" variant="primary" class="w-full"> Open Update Modal </Button>
</div>
</div>
</div>
<!-- The Modal Component -->
<CheckUpdateResponseModal
:open="modalOpen"
@update:open="
(val: boolean) => {
modalOpen = val;
updateOsStore.setModalOpen(val);
}
"
:t="t"
/>
</div>
</template>
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Update Modal Test - Unraid Component Test</title>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<style>
html {
font-size: 10px;
}
body {
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
padding: 20px;
background: #f3f4f6;
}
.header {
background: #1f2937;
color: white;
padding: 20px;
margin-bottom: 20px;
border-radius: 8px;
}
.header h1 {
margin: 0;
}
.back-link {
color: white;
text-decoration: none;
margin-bottom: 10px;
display: inline-block;
opacity: 0.8;
}
.back-link:hover {
opacity: 1;
}
</style>
</head>
<body>
<div class="header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<a href="/test-pages/" class="back-link">← Back to Test Pages</a>
<h1>🧪 Update Modal Test Scenarios</h1>
</div>
<div>
<unraid-test-theme-switcher></unraid-test-theme-switcher>
</div>
</div>
</div>
<!-- Mount the test component -->
<unraid-test-update-modal></unraid-test-update-modal>
<!-- Mount the modals component which includes the changelog modal -->
<unraid-modals></unraid-modals>
<!-- Load the manifest and inject resources -->
<script src="/test-pages/load-manifest.js"></script>
</body>
</html>