refactor(ui): replace vue-sonner toasts with nuxtui toasts

> [!Note] This stubs the unraid-ui/src/components/common/toast. Initially created a shim to convert vue-sonnner toasts to nuxtui. However, since there weren't that many, I just did a clean replacement.

- replace router link with window.location.assign

The `UButton` component attempts to inject the Vue Router instance when the `:to` prop is used. In the standalone component environment (where the router is not installed), this caused a "TypeError: inject(...) is undefined" crash when rendering notifications with links.

This change replaces the `:to` prop with a standard `@click` handler that uses `window.location.assign`, ensuring navigation works correctly without requiring the router context.
This commit is contained in:
Ajit Mehrotra
2025-12-02 16:39:40 -05:00
parent e80ea795fe
commit 88766adeea
10 changed files with 72 additions and 93 deletions

View File

@@ -1,8 +0,0 @@
/* eslint-disable no-var */
declare global {
/** loaded by Toaster.vue */
var toast: (typeof import('vue-sonner'))['toast'];
}
// an export or import statement is required to make this file a module
export {};

View File

@@ -32,10 +32,18 @@ export default {
right: {},
},
},
},
toaster: {
position: 'bottom-right' as const,
expand: true,
duration: 5000,
//css theming/style-overrides for the toast component
// https://ui.nuxt.com/docs/components/toast#theme
toast: {},
// Also, for toasts, BUT this is imported in the Root UApp in mount-engine.ts
// https://ui.nuxt.com/docs/components/toast#examples
toaster: {
position: 'top-center' as const,
// expand: false, --> buggy
duration: 5000,
max: 3,
},
},
};

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
import { AlertTriangle, Octagon } from 'lucide-vue-next';
import { useToast } from '@nuxt/ui/composables/useToast.js';
import type { FragmentType } from '~/composables/gql';
import type {
@@ -23,6 +22,8 @@ import {
import { useFragment } from '~/composables/gql';
import { NotificationImportance } from '~/composables/gql/graphql';
const toast = useToast();
const { result, loading, error, refetch } = useQuery<
WarningAndAlertNotificationsQuery,
WarningAndAlertNotificationsQueryVariables
@@ -88,24 +89,24 @@ const formatTimestamp = (notification: NotificationFragmentFragment) => {
const importanceMeta: Record<
NotificationImportance,
{ label: string; badge: string; icon: typeof AlertTriangle; accent: string }
{ label: string; badge: string; icon: string; accent: string }
> = {
[NotificationImportance.ALERT]: {
label: 'Alert',
badge: 'bg-red-100 text-red-700 border border-red-300',
icon: Octagon,
icon: 'i-lucide-octagon',
accent: 'text-red-600',
},
[NotificationImportance.WARNING]: {
label: 'Warning',
badge: 'bg-amber-100 text-amber-700 border border-amber-300',
icon: AlertTriangle,
icon: 'i-lucide-alert-triangle',
accent: 'text-amber-600',
},
[NotificationImportance.INFO]: {
label: 'Info',
badge: 'bg-blue-100 text-blue-700 border border-blue-300',
icon: AlertTriangle,
icon: 'i-lucide-alert-triangle',
accent: 'text-blue-600',
},
};
@@ -156,30 +157,32 @@ onNotificationAdded(({ data }) => {
void refetch();
if (!globalThis.toast) {
return;
}
if (notification.timestamp) {
// Trigger the global toast in tandem with the subscription update.
const funcMapping: Record<
NotificationImportance,
(typeof globalThis)['toast']['info' | 'error' | 'warning']
'error' | 'warning' | 'info' | 'primary' | 'secondary' | 'success' | 'neutral'
> = {
[NotificationImportance.ALERT]: globalThis.toast.error,
[NotificationImportance.WARNING]: globalThis.toast.warning,
[NotificationImportance.INFO]: globalThis.toast.info,
[NotificationImportance.ALERT]: 'error',
[NotificationImportance.WARNING]: 'warning',
[NotificationImportance.INFO]: 'success',
};
const toast = funcMapping[notification.importance];
const color = funcMapping[notification.importance];
const createOpener = () => ({
label: 'Open',
onClick: () => notification.link && window.open(notification.link, '_blank', 'noopener'),
onClick: () => {
if (notification.link) {
window.location.assign(notification.link);
}
},
});
requestAnimationFrame(() =>
toast(notification.title, {
toast.add({
title: notification.title,
description: notification.subject,
action: notification.link ? createOpener() : undefined,
color,
actions: notification.link ? [createOpener()] : undefined,
})
);
}
@@ -190,7 +193,7 @@ onNotificationAdded(({ data }) => {
<section class="flex flex-col gap-4 rounded-lg border border-amber-200 bg-white p-4 shadow-sm">
<header class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<AlertTriangle class="h-5 w-5 text-amber-600" aria-hidden="true" />
<UIcon name="i-lucide-alert-triangle" class="h-5 w-5 text-amber-600" aria-hidden="true" />
<h2 class="text-base font-semibold text-gray-900">Warnings & Alerts</h2>
</div>
<span v-if="!loading" class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
@@ -211,7 +214,7 @@ onNotificationAdded(({ data }) => {
<li v-for="{ notification, displayTimestamp, meta } in enrichedNotifications" :key="notification.id"
class="grid gap-2 rounded-md border border-gray-200 p-3 transition hover:border-amber-300">
<div class="flex items-start gap-3">
<component :is="meta.icon" class="mt-0.5 h-5 w-5 flex-none" :class="meta.accent" aria-hidden="true" />
<UIcon :name="meta.icon" class="mt-0.5 h-5 w-5 flex-none" :class="meta.accent" aria-hidden="true" />
<div class="flex flex-1 flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="meta.badge">

View File

@@ -64,6 +64,12 @@ const mutationError = computed(() => {
return archive.error?.message ?? deleteNotification.error?.message;
});
const openLink = () => {
if (props.link) {
window.location.assign(props.link);
}
};
const reformattedTimestamp = computed<string>(() => {
if (!props.timestamp) return '';
const userLocale = navigator.language ?? 'en-US'; // Get the user's browser language (e.g., 'en-US', 'fr-FR')
@@ -110,7 +116,13 @@ const reformattedTimestamp = computed<string>(() => {
<p v-if="mutationError" class="text-destructive">{{ t('common.error') }}: {{ mutationError }}</p>
<div class="flex items-baseline justify-end gap-4">
<UButton v-if="link" :to="link" variant="link" icon="i-heroicons-link-20-solid">
<UButton
v-if="link"
variant="link"
icon="i-heroicons-link-20-solid"
color="neutral"
@click="openLink"
>
{{ t('notifications.item.viewLink') }}
</UButton>
<UButton

View File

@@ -5,8 +5,6 @@ import { storeToRefs } from 'pinia';
import { useQuery } from '@vue/apollo-composable';
import { vInfiniteScroll } from '@vueuse/components';
import { CheckIcon } from '@heroicons/vue/24/solid';
import type { ApolloError } from '@apollo/client/errors';
import type { NotificationImportance as Importance, NotificationType } from '~/composables/gql/graphql';
import type { GraphQLError } from 'graphql';
@@ -250,7 +248,7 @@ const displayErrorMessage = computed(() => {
<!-- Default (empty state) -->
<div v-else class="contents">
<CheckIcon class="text-unraid-green h-10 translate-y-3" />
<UIcon name="i-heroicons-check-20-solid" class="text-unraid-green h-10 w-10 translate-y-3" />
{{ noNotificationsMessage }}
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
import { useToast } from '@nuxt/ui/composables/useToast.js';
import ConfirmDialog from '~/components/ConfirmDialog.vue';
import {
@@ -22,6 +23,8 @@ import { useFragment } from '~/composables/gql';
import { NotificationImportance as Importance, NotificationType } from '~/composables/gql/graphql';
import { useConfirm } from '~/composables/useConfirm';
const toast = useToast();
const { mutate: archiveAll, loading: loadingArchiveAll } = useMutation(archiveAllNotifications);
const { mutate: deleteArchives, loading: loadingDeleteAll } = useMutation(deleteArchivedNotifications);
const { mutate: recalculateOverview } = useMutation(resetOverview);
@@ -88,25 +91,27 @@ onNotificationAdded(({ data }) => {
if (notif.timestamp) {
latestNotificationTimestamp.value = notif.timestamp;
}
if (!globalThis.toast) {
return;
}
const funcMapping: Record<Importance, (typeof globalThis)['toast']['info' | 'error' | 'warning']> = {
[Importance.ALERT]: globalThis.toast.error,
[Importance.WARNING]: globalThis.toast.warning,
[Importance.INFO]: globalThis.toast.info,
const funcMapping: Record<
Importance,
'error' | 'warning' | 'info' | 'primary' | 'secondary' | 'success' | 'neutral'
> = {
[Importance.ALERT]: 'error',
[Importance.WARNING]: 'warning',
[Importance.INFO]: 'success',
};
const toast = funcMapping[notif.importance];
const color = funcMapping[notif.importance];
const createOpener = () => ({
label: t('notifications.sidebar.toastOpen'),
onClick: () => window.location.assign(notif.link as string),
});
requestAnimationFrame(() =>
toast(notif.title, {
toast.add({
title: notif.title,
description: notif.subject,
action: notif.link ? createOpener() : undefined,
color,
actions: notif.link ? [createOpener()] : undefined,
})
);
});

View File

@@ -1,31 +0,0 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Toaster } from '@unraid/ui';
import { useThemeStore } from '~/store/theme';
const themeStore = useThemeStore();
// Get dark mode from theme store
const theme = computed(() => (themeStore.darkMode ? 'dark' : 'light'));
withDefaults(
defineProps<{
position?:
| 'top-center'
| 'top-right'
| 'top-left'
| 'bottom-center'
| 'bottom-right'
| 'bottom-left';
}>(),
{
position: 'top-right',
}
);
</script>
<template>
<Toaster rich-colors close-button :position="position" :theme="theme" />
</template>

View File

@@ -134,11 +134,6 @@ export const componentMappings: ComponentMapping[] = [
selector: 'unraid-color-switcher',
appId: 'color-switcher',
},
{
component: defineAsyncComponent(() => import('@/components/UnraidToaster.vue')),
selector: ['unraid-toaster', 'uui-toaster'],
appId: 'toaster',
},
{
component: defineAsyncComponent(() => import('../UpdateOs/TestUpdateModal.standalone.vue')),
selector: 'unraid-test-update-modal',

View File

@@ -12,6 +12,8 @@ import { createI18nInstance, ensureLocale, getWindowLocale } from '~/helpers/i18
// Import Pinia for use in Vue apps
import { globalPinia } from '~/store/globalPinia';
import { ensureUnapiScope, ensureUnapiScopeForSelectors, observeUnapiScope } from '~/utils/unapiScope';
// Import the app config to pass runtime settings (like toaster position)
import appConfig from '../../../app.config';
// Ensure Apollo client is singleton
const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || client;
@@ -244,6 +246,7 @@ export async function mountUnifiedApp() {
UApp,
{
portal: portalTarget,
toaster: appConfig.ui.toaster,
},
{
default: () => h(component, props),

View File

@@ -5,6 +5,7 @@ import { useClipboard } from '@vueuse/core';
*/
export function useClipboardWithToast() {
const { copy, copied, isSupported } = useClipboard();
const toast = useToast();
/**
* Copy text and show toast
@@ -19,10 +20,7 @@ export function useClipboardWithToast() {
if (isSupported.value) {
try {
await copy(text);
// Use global toast if available
if (globalThis.toast) {
globalThis.toast.success(successMessage);
}
toast.add({ title: successMessage, color: 'success' });
return true;
} catch (error) {
console.error('Failed to copy to clipboard:', error);
@@ -43,9 +41,7 @@ export function useClipboardWithToast() {
document.body.removeChild(textarea);
if (success) {
if (globalThis.toast) {
globalThis.toast.success(successMessage);
}
toast.add({ title: successMessage, color: 'success' });
return true;
}
} catch (error) {
@@ -53,9 +49,7 @@ export function useClipboardWithToast() {
}
// Both methods failed
if (globalThis.toast) {
globalThis.toast.error('Failed to copy to clipboard');
}
toast.add({ title: 'Failed to copy to clipboard', color: 'error' });
return false;
};