feat: toasts (#23298)

This commit is contained in:
Jason Rasmussen
2025-10-28 15:09:11 -04:00
committed by GitHub
parent 106effca2e
commit 52596255c8
80 changed files with 341 additions and 1069 deletions
@@ -2,15 +2,10 @@
import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap';
import NotificationItem from '$lib/components/shared-components/navigation-bar/notification-item.svelte';
import {
notificationController,
NotificationType as WebNotificationType,
} from '$lib/components/shared-components/notification/notification';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { NotificationType, type NotificationDto } from '@immich/sdk';
import { Button, Icon, Scrollable, Stack, Text } from '@immich/ui';
import { Button, Icon, Scrollable, Stack, Text, toastManager } from '@immich/ui';
import { mdiBellOutline, mdiCheckAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import { flip } from 'svelte/animate';
@@ -29,7 +24,7 @@
const markAllAsRead = async () => {
try {
await notificationManager.markAllAsRead();
notificationController.show({ message: $t('marked_all_as_read'), type: WebNotificationType.Info });
toastManager.info($t('marked_all_as_read'));
} catch (error) {
handleError(error, $t('errors.failed_to_update_notification_status'));
}
@@ -1,86 +0,0 @@
import NotificationComponentTest from '$lib/components/shared-components/notification/__tests__/notification-component-test.svelte';
import '@testing-library/jest-dom';
import { cleanup, render, type RenderResult } from '@testing-library/svelte';
import { NotificationType } from '../notification';
import NotificationCard from '../notification-card.svelte';
describe('NotificationCard component', () => {
let sut: RenderResult<typeof NotificationCard>;
it('disposes timeout if already removed from the DOM', () => {
vi.spyOn(globalThis, 'clearTimeout');
sut = render(NotificationCard, {
notification: {
id: 1234,
message: 'Notification message',
timeout: 1000,
type: NotificationType.Info,
action: { type: 'discard' },
},
});
cleanup();
expect(globalThis.clearTimeout).toHaveBeenCalledTimes(1);
});
it('shows message and title', () => {
sut = render(NotificationCard, {
notification: {
id: 1234,
message: 'Notification message',
timeout: 1000,
type: NotificationType.Info,
action: { type: 'discard' },
},
});
expect(sut.getByTestId('title')).toHaveTextContent('info');
expect(sut.getByTestId('message')).toHaveTextContent('Notification message');
});
it('makes all buttons non-focusable and hidden from screen readers', () => {
sut = render(NotificationCard, {
notification: {
id: 1234,
message: 'Notification message',
timeout: 1000,
type: NotificationType.Info,
action: { type: 'discard' },
button: {
text: 'button',
onClick: vi.fn(),
},
},
});
const buttons = sut.container.querySelectorAll('button');
expect(buttons).toHaveLength(2);
for (const button of buttons) {
expect(button.getAttribute('tabindex')).toBe('-1');
expect(button.getAttribute('aria-hidden')).toBe('true');
}
});
it('shows title and renders component', () => {
sut = render(NotificationCard, {
notification: {
id: 1234,
type: NotificationType.Info,
timeout: 1,
action: { type: 'discard' },
component: {
type: NotificationComponentTest,
props: {
href: 'link',
},
},
},
});
expect(sut.getByTestId('title')).toHaveTextContent('info');
expect(sut.getByTestId('message').innerHTML.replaceAll('<!---->', '')).toEqual(
'Notification <b>message</b> with <a href="link">link</a>',
);
});
});
@@ -1,9 +0,0 @@
<script lang="ts">
interface Props {
href: string;
}
let { href }: Props = $props();
</script>
Notification <b>message</b> with <a {href}>link</a>
@@ -1,41 +0,0 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import '@testing-library/jest-dom';
import { render, waitFor, type RenderResult } from '@testing-library/svelte';
import { get } from 'svelte/store';
import { NotificationType, notificationController } from '../notification';
import NotificationList from '../notification-list.svelte';
function _getNotificationListElement(): HTMLAnchorElement | null {
return document.body.querySelector('#notification-list');
}
describe('NotificationList component', () => {
beforeAll(() => {
Element.prototype.animate = getAnimateMock();
});
afterAll(() => {
vi.unstubAllGlobals();
});
it('shows a notification when added and closes it automatically after the delay timeout', async () => {
const sut: RenderResult<NotificationList> = render(NotificationList, { intro: false });
const status = await sut.findAllByRole('status');
expect(status).toHaveLength(1);
expect(_getNotificationListElement()).not.toBeInTheDocument();
notificationController.show({
message: 'Notification',
type: NotificationType.Info,
timeout: 1,
});
await waitFor(() => expect(_getNotificationListElement()).toBeInTheDocument());
await waitFor(() => expect(_getNotificationListElement()?.children).toHaveLength(1));
expect(get(notificationController.notificationList)).toHaveLength(1);
await waitFor(() => expect(_getNotificationListElement()).not.toBeInTheDocument());
expect(get(notificationController.notificationList)).toHaveLength(0);
});
});
@@ -1,125 +0,0 @@
<script lang="ts">
import {
isComponentNotification,
notificationController,
NotificationType,
type ComponentNotification,
type Notification,
} from '$lib/components/shared-components/notification/notification';
import { Button, Icon, IconButton, type Color } from '@immich/ui';
import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
notification: Notification | ComponentNotification;
}
let { notification }: Props = $props();
let icon = $derived(notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline);
let hoverStyle = $derived(notification.action.type === 'discard' ? 'hover:cursor-pointer' : '');
const backgroundColor: Record<NotificationType, string> = {
[NotificationType.Info]: '#E0E2F0',
[NotificationType.Error]: '#FBE8E6',
[NotificationType.Warning]: '#FFF6DC',
};
const borderColor: Record<NotificationType, string> = {
[NotificationType.Info]: '#D8DDFF',
[NotificationType.Error]: '#F0E8E7',
[NotificationType.Warning]: '#FFE6A5',
};
const primaryColor: Record<NotificationType, string> = {
[NotificationType.Info]: '#4250AF',
[NotificationType.Error]: '#E64132',
[NotificationType.Warning]: '#D08613',
};
const colors: Record<NotificationType, Color> = {
[NotificationType.Info]: 'primary',
[NotificationType.Error]: 'danger',
[NotificationType.Warning]: 'warning',
};
onMount(() => {
const timeoutId = setTimeout(discard, notification.timeout);
return () => clearTimeout(timeoutId);
});
const discard = () => {
notificationController.removeNotificationById(notification.id);
};
const handleClick = () => {
if (notification.action.type === 'discard') {
discard();
}
};
const handleButtonClick = () => {
const button = notification.button;
if (button) {
discard();
return notification.button?.onClick();
}
};
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
transition:fade={{ duration: 250 }}
style:background-color={backgroundColor[notification.type]}
style:border-color={borderColor[notification.type]}
class="border mb-4 min-h-[80px] w-[300px] rounded-2xl p-4 shadow-md {hoverStyle}"
onclick={handleClick}
onkeydown={handleClick}
>
<div class="flex justify-between">
<div class="flex place-items-center gap-2">
<Icon {icon} color={primaryColor[notification.type]} size="20" />
<h2 style:color={primaryColor[notification.type]} class="font-medium" data-testid="title">
{#if notification.type == NotificationType.Error}{$t('error')}
{:else if notification.type == NotificationType.Warning}{$t('warning')}
{:else if notification.type == NotificationType.Info}{$t('info')}{/if}
</h2>
</div>
<IconButton
variant="ghost"
shape="round"
color="secondary"
icon={mdiWindowClose}
aria-label={$t('close')}
class="dark:text-immich-dark-gray"
size="medium"
onclick={discard}
aria-hidden={true}
tabindex={-1}
/>
</div>
<p class="whitespace-pre-wrap ps-[28px] pe-[16px] text-sm text-black/80" data-testid="message">
{#if isComponentNotification(notification)}
<notification.component.type {...notification.component.props} />
{:else}
{notification.message}
{/if}
</p>
{#if notification.button}
<p class="ps-[28px] mt-2.5 light text-light">
<Button
size="small"
color={colors[notification.type]}
onclick={handleButtonClick}
aria-hidden="true"
tabindex={-1}
>
{notification.button.text}
</Button>
</p>
{/if}
</div>
@@ -1,25 +0,0 @@
<script lang="ts">
import Portal from '$lib/elements/Portal.svelte';
import { t } from 'svelte-i18n';
import { flip } from 'svelte/animate';
import { quintOut } from 'svelte/easing';
import { fade } from 'svelte/transition';
import { notificationController } from './notification';
import NotificationCard from './notification-card.svelte';
const { notificationList } = notificationController;
</script>
<Portal>
<div role="status" aria-relevant="additions" aria-label={$t('notifications')}>
{#if $notificationList.length > 0}
<section transition:fade={{ duration: 250 }} id="notification-list" class="fixed end-5 top-[80px]">
{#each $notificationList as notification (notification.id)}
<div animate:flip={{ duration: 250, easing: quintOut }}>
<NotificationCard {notification} />
</div>
{/each}
</section>
{/if}
</div>
</Portal>
@@ -1,87 +0,0 @@
import type { Component as ComponentType } from 'svelte';
import { writable } from 'svelte/store';
export enum NotificationType {
Info = 'Info',
Error = 'Error',
Warning = 'Warning',
}
export type NotificationButton = {
text: string;
onClick: () => unknown;
};
export type Notification = {
id: number;
type: NotificationType;
message: string;
/** The action to take when the notification is clicked */
action: NotificationAction;
button?: NotificationButton;
/** Timeout in milliseconds */
timeout: number;
};
type DiscardAction = { type: 'discard' };
type NoopAction = { type: 'noop' };
export type NotificationAction = DiscardAction | NoopAction;
type Props = Record<string, unknown>;
type Component<T extends Props> = {
type: ComponentType<T>;
props: T;
};
type BaseNotificationOptions<T, R extends keyof T> = Partial<Omit<T, 'id'>> & Pick<T, R>;
export type NotificationOptions = BaseNotificationOptions<Notification, 'message'>;
export type ComponentNotificationOptions<T extends Props> = BaseNotificationOptions<
ComponentNotification<T>,
'component'
>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ComponentNotification<T extends Props = any> = Omit<Notification, 'message'> & {
component: Component<T>;
};
export const isComponentNotification = <T extends Props>(
notification: Notification | ComponentNotification<T>,
): notification is ComponentNotification<T> => {
return 'component' in notification;
};
function createNotificationList() {
const notificationList = writable<(Notification | ComponentNotification)[]>([]);
let count = 1;
const show = <T>(options: T extends Props ? ComponentNotificationOptions<T> : NotificationOptions) => {
notificationList.update((currentList) => {
currentList.push({
id: count++,
type: NotificationType.Info,
action: {
type: options.button ? 'noop' : 'discard',
},
timeout: 3000,
...options,
});
return currentList;
});
};
const removeNotificationById = (id: number) => {
notificationList.update((currentList) => currentList.filter((n) => n.id !== id));
};
return {
show,
removeNotificationById,
notificationList,
};
}
export const notificationController = createNotificationList();
@@ -2,12 +2,11 @@
import { locale } from '$lib/stores/preferences.store';
import { uploadAssetsStore } from '$lib/stores/upload';
import { uploadExecutionQueue } from '$lib/utils/file-uploader';
import { Icon, IconButton } from '@immich/ui';
import { Icon, IconButton, toastManager } from '@immich/ui';
import { mdiCancel, mdiCloudUploadOutline, mdiCog, mdiWindowMinimize } from '@mdi/js';
import { t } from 'svelte-i18n';
import { quartInOut } from 'svelte/easing';
import { fade, scale } from 'svelte/transition';
import { notificationController, NotificationType } from './notification/notification';
import UploadAssetPreview from './upload-asset-preview.svelte';
let showDetail = $state(false);
@@ -29,21 +28,12 @@
out:fade={{ duration: 250 }}
onoutroend={() => {
if ($stats.errors > 0) {
notificationController.show({
message: $t('upload_errors', { values: { count: $stats.errors } }),
type: NotificationType.Warning,
});
toastManager.danger($t('upload_errors', { values: { count: $stats.errors } }));
} else if ($stats.success > 0) {
notificationController.show({
message: $t('upload_success'),
type: NotificationType.Info,
});
toastManager.success($t('upload_success'));
}
if ($stats.duplicates > 0) {
notificationController.show({
message: $t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }),
type: NotificationType.Warning,
});
toastManager.warning($t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }));
}
uploadAssetsStore.reset();
}}