mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:39:37 -05:00 
			
		
		
		
	feat(web): render component in notifications (#10990)
This commit is contained in:
		
							parent
							
								
									1dd1d36120
								
							
						
					
					
						commit
						59aa347912
					
				
							
								
								
									
										13
									
								
								web/src/lib/components/i18n/format-bold-message.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								web/src/lib/components/i18n/format-bold-message.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,13 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import FormatMessage from '$lib/components/i18n/format-message.svelte';
 | 
			
		||||
  import type { InterpolationValues } from '$lib/components/i18n/format-message.svelte';
 | 
			
		||||
 | 
			
		||||
  export let key: string;
 | 
			
		||||
  export let values: InterpolationValues = {};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<FormatMessage {key} {values} let:message let:tag>
 | 
			
		||||
  {#if tag === 'b'}
 | 
			
		||||
    <b>{message}</b>
 | 
			
		||||
  {/if}
 | 
			
		||||
</FormatMessage>
 | 
			
		||||
@ -1,5 +1,10 @@
 | 
			
		||||
<script lang="ts" context="module">
 | 
			
		||||
  import type { FormatXMLElementFn, PrimitiveType } from 'intl-messageformat';
 | 
			
		||||
  export type InterpolationValues = Record<string, PrimitiveType | FormatXMLElementFn<unknown>>;
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { IntlMessageFormat, type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat';
 | 
			
		||||
  import { IntlMessageFormat } from 'intl-messageformat';
 | 
			
		||||
  import {
 | 
			
		||||
    TYPE,
 | 
			
		||||
    type MessageFormatElement,
 | 
			
		||||
@ -8,8 +13,6 @@
 | 
			
		||||
  } from '@formatjs/icu-messageformat-parser';
 | 
			
		||||
  import { locale as i18nLocale, json } from 'svelte-i18n';
 | 
			
		||||
 | 
			
		||||
  type InterpolationValues = Record<string, PrimitiveType | FormatXMLElementFn<unknown>>;
 | 
			
		||||
 | 
			
		||||
  type MessagePart = {
 | 
			
		||||
    message: string;
 | 
			
		||||
    tag?: string;
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,4 @@
 | 
			
		||||
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';
 | 
			
		||||
@ -37,4 +38,24 @@ describe('NotificationCard component', () => {
 | 
			
		||||
    expect(sut.getByTestId('title')).toHaveTextContent('info');
 | 
			
		||||
    expect(sut.getByTestId('message')).toHaveTextContent('Notification message');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  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).toEqual('Notification <b>message</b> with <a href="link">link</a>');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,5 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  export let href: string;
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
Notification <b>message</b> with <a {href}>link</a>
 | 
			
		||||
@ -2,16 +2,18 @@
 | 
			
		||||
  import { fade } from 'svelte/transition';
 | 
			
		||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
			
		||||
  import {
 | 
			
		||||
    type Notification,
 | 
			
		||||
    isComponentNotification,
 | 
			
		||||
    notificationController,
 | 
			
		||||
    NotificationType,
 | 
			
		||||
    type ComponentNotification,
 | 
			
		||||
    type Notification,
 | 
			
		||||
  } from '$lib/components/shared-components/notification/notification';
 | 
			
		||||
  import { onMount } from 'svelte';
 | 
			
		||||
  import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js';
 | 
			
		||||
  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
 | 
			
		||||
  export let notification: Notification;
 | 
			
		||||
  export let notification: Notification | ComponentNotification;
 | 
			
		||||
 | 
			
		||||
  $: icon = notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline;
 | 
			
		||||
  $: hoverStyle = notification.action.type === 'discard' ? 'hover:cursor-pointer' : '';
 | 
			
		||||
@ -93,9 +95,8 @@
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <p class="whitespace-pre-wrap pl-[28px] pr-[16px] text-sm" data-testid="message">
 | 
			
		||||
    {#if notification.html}
 | 
			
		||||
      <!-- eslint-disable-next-line svelte/no-at-html-tags -->
 | 
			
		||||
      {@html notification.message}
 | 
			
		||||
    {#if isComponentNotification(notification)}
 | 
			
		||||
      <svelte:component this={notification.component.type} {...notification.component.props} />
 | 
			
		||||
    {:else}
 | 
			
		||||
      {notification.message}
 | 
			
		||||
    {/if}
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,4 @@
 | 
			
		||||
import type { ComponentProps, ComponentType, SvelteComponent } from 'svelte';
 | 
			
		||||
import { writable } from 'svelte/store';
 | 
			
		||||
 | 
			
		||||
export enum NotificationType {
 | 
			
		||||
@ -15,11 +16,6 @@ export type Notification = {
 | 
			
		||||
  id: number;
 | 
			
		||||
  type: NotificationType;
 | 
			
		||||
  message: string;
 | 
			
		||||
  /**
 | 
			
		||||
   * Allow HTML to be inserted within the message. Make sure to verify/encode
 | 
			
		||||
   * variables that may be interpoalted into 'message'
 | 
			
		||||
   */
 | 
			
		||||
  html?: boolean;
 | 
			
		||||
  /** The action to take when the notification is clicked */
 | 
			
		||||
  action: NotificationAction;
 | 
			
		||||
  button?: NotificationButton;
 | 
			
		||||
@ -32,13 +28,37 @@ type NoopAction = { type: 'noop' };
 | 
			
		||||
 | 
			
		||||
export type NotificationAction = DiscardAction | NoopAction;
 | 
			
		||||
 | 
			
		||||
export type NotificationOptions = Partial<Exclude<Notification, 'id'>> & { message: string };
 | 
			
		||||
type Component<T extends ComponentType> = {
 | 
			
		||||
  type: T;
 | 
			
		||||
  props: ComponentProps<InstanceType<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 ComponentType> = BaseNotificationOptions<
 | 
			
		||||
  ComponentNotification<T>,
 | 
			
		||||
  'component'
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
export type ComponentNotification<T extends ComponentType = ComponentType<SvelteComponent>> = Omit<
 | 
			
		||||
  Notification,
 | 
			
		||||
  'message'
 | 
			
		||||
> & {
 | 
			
		||||
  component: Component<T>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const isComponentNotification = <T extends ComponentType>(
 | 
			
		||||
  notification: Notification | ComponentNotification<T>,
 | 
			
		||||
): notification is ComponentNotification<T> => {
 | 
			
		||||
  return 'component' in notification;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function createNotificationList() {
 | 
			
		||||
  const notificationList = writable<Notification[]>([]);
 | 
			
		||||
  const notificationList = writable<(Notification | ComponentNotification)[]>([]);
 | 
			
		||||
  let count = 1;
 | 
			
		||||
 | 
			
		||||
  const show = (options: NotificationOptions) => {
 | 
			
		||||
  const show = <T>(options: T extends ComponentType ? ComponentNotificationOptions<T> : NotificationOptions) => {
 | 
			
		||||
    notificationList.update((currentList) => {
 | 
			
		||||
      currentList.push({
 | 
			
		||||
        id: count++,
 | 
			
		||||
 | 
			
		||||
@ -378,7 +378,7 @@
 | 
			
		||||
  "assets": "Assets",
 | 
			
		||||
  "assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
 | 
			
		||||
  "assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
 | 
			
		||||
  "assets_added_to_name_count": "Added {count, plural, one {# asset} other {# assets}} to {name}",
 | 
			
		||||
  "assets_added_to_name_count": "Added {count, plural, one {# asset} other {# assets}} to {hasName, select, true {<b>{name}</b>} other {new album}}",
 | 
			
		||||
  "assets_count": "{count, plural, one {# asset} other {# assets}}",
 | 
			
		||||
  "assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash",
 | 
			
		||||
  "assets_permanently_deleted_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}",
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
import { goto } from '$app/navigation';
 | 
			
		||||
import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte';
 | 
			
		||||
import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification';
 | 
			
		||||
import { AppRoute } from '$lib/constants';
 | 
			
		||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
 | 
			
		||||
@ -9,7 +10,6 @@ import { preferences } from '$lib/stores/user.store';
 | 
			
		||||
import { downloadRequest, getKey, withError } from '$lib/utils';
 | 
			
		||||
import { createAlbum } from '$lib/utils/album-utils';
 | 
			
		||||
import { getByteUnitString } from '$lib/utils/byte-units';
 | 
			
		||||
import { encodeHTMLSpecialChars } from '$lib/utils/string-utils';
 | 
			
		||||
import {
 | 
			
		||||
  addAssetsToAlbum as addAssets,
 | 
			
		||||
  getAssetInfo,
 | 
			
		||||
@ -63,13 +63,17 @@ export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[])
 | 
			
		||||
  if (!album) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  const displayName = albumName ? `<b>${encodeHTMLSpecialChars(albumName)}</b>` : 'new album';
 | 
			
		||||
  const $t = get(t);
 | 
			
		||||
  notificationController.show({
 | 
			
		||||
    type: NotificationType.Info,
 | 
			
		||||
    timeout: 5000,
 | 
			
		||||
    message: $t('assets_added_to_name_count', { values: { count: assetIds.length, name: displayName } }),
 | 
			
		||||
    html: true,
 | 
			
		||||
    component: {
 | 
			
		||||
      type: FormatBoldMessage,
 | 
			
		||||
      props: {
 | 
			
		||||
        key: 'assets_added_to_name_count',
 | 
			
		||||
        values: { count: assetIds.length, name: albumName, hasName: !!albumName },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    button: {
 | 
			
		||||
      text: $t('view_album'),
 | 
			
		||||
      onClick() {
 | 
			
		||||
 | 
			
		||||
@ -5,12 +5,3 @@ export const removeAccents = (str: string) => {
 | 
			
		||||
export const normalizeSearchString = (str: string) => {
 | 
			
		||||
  return removeAccents(str.toLocaleLowerCase());
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const encodeHTMLSpecialChars = (str: string) => {
 | 
			
		||||
  return str
 | 
			
		||||
    .replaceAll('&', '&')
 | 
			
		||||
    .replaceAll('<', '<')
 | 
			
		||||
    .replaceAll('>', '>')
 | 
			
		||||
    .replaceAll('"', '"')
 | 
			
		||||
    .replaceAll("'", ''');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user