mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -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">
 | 
					<script lang="ts">
 | 
				
			||||||
  import { IntlMessageFormat, type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat';
 | 
					  import { IntlMessageFormat } from 'intl-messageformat';
 | 
				
			||||||
  import {
 | 
					  import {
 | 
				
			||||||
    TYPE,
 | 
					    TYPE,
 | 
				
			||||||
    type MessageFormatElement,
 | 
					    type MessageFormatElement,
 | 
				
			||||||
@ -8,8 +13,6 @@
 | 
				
			|||||||
  } from '@formatjs/icu-messageformat-parser';
 | 
					  } from '@formatjs/icu-messageformat-parser';
 | 
				
			||||||
  import { locale as i18nLocale, json } from 'svelte-i18n';
 | 
					  import { locale as i18nLocale, json } from 'svelte-i18n';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  type InterpolationValues = Record<string, PrimitiveType | FormatXMLElementFn<unknown>>;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  type MessagePart = {
 | 
					  type MessagePart = {
 | 
				
			||||||
    message: string;
 | 
					    message: string;
 | 
				
			||||||
    tag?: 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 '@testing-library/jest-dom';
 | 
				
			||||||
import { cleanup, render, type RenderResult } from '@testing-library/svelte';
 | 
					import { cleanup, render, type RenderResult } from '@testing-library/svelte';
 | 
				
			||||||
import { NotificationType } from '../notification';
 | 
					import { NotificationType } from '../notification';
 | 
				
			||||||
@ -37,4 +38,24 @@ describe('NotificationCard component', () => {
 | 
				
			|||||||
    expect(sut.getByTestId('title')).toHaveTextContent('info');
 | 
					    expect(sut.getByTestId('title')).toHaveTextContent('info');
 | 
				
			||||||
    expect(sut.getByTestId('message')).toHaveTextContent('Notification message');
 | 
					    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 { fade } from 'svelte/transition';
 | 
				
			||||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
					  import Icon from '$lib/components/elements/icon.svelte';
 | 
				
			||||||
  import {
 | 
					  import {
 | 
				
			||||||
    type Notification,
 | 
					    isComponentNotification,
 | 
				
			||||||
    notificationController,
 | 
					    notificationController,
 | 
				
			||||||
    NotificationType,
 | 
					    NotificationType,
 | 
				
			||||||
 | 
					    type ComponentNotification,
 | 
				
			||||||
 | 
					    type Notification,
 | 
				
			||||||
  } from '$lib/components/shared-components/notification/notification';
 | 
					  } from '$lib/components/shared-components/notification/notification';
 | 
				
			||||||
  import { onMount } from 'svelte';
 | 
					  import { onMount } from 'svelte';
 | 
				
			||||||
  import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js';
 | 
					  import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js';
 | 
				
			||||||
  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
					  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let notification: Notification;
 | 
					  export let notification: Notification | ComponentNotification;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  $: icon = notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline;
 | 
					  $: icon = notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline;
 | 
				
			||||||
  $: hoverStyle = notification.action.type === 'discard' ? 'hover:cursor-pointer' : '';
 | 
					  $: hoverStyle = notification.action.type === 'discard' ? 'hover:cursor-pointer' : '';
 | 
				
			||||||
@ -93,9 +95,8 @@
 | 
				
			|||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <p class="whitespace-pre-wrap pl-[28px] pr-[16px] text-sm" data-testid="message">
 | 
					  <p class="whitespace-pre-wrap pl-[28px] pr-[16px] text-sm" data-testid="message">
 | 
				
			||||||
    {#if notification.html}
 | 
					    {#if isComponentNotification(notification)}
 | 
				
			||||||
      <!-- eslint-disable-next-line svelte/no-at-html-tags -->
 | 
					      <svelte:component this={notification.component.type} {...notification.component.props} />
 | 
				
			||||||
      {@html notification.message}
 | 
					 | 
				
			||||||
    {:else}
 | 
					    {:else}
 | 
				
			||||||
      {notification.message}
 | 
					      {notification.message}
 | 
				
			||||||
    {/if}
 | 
					    {/if}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import type { ComponentProps, ComponentType, SvelteComponent } from 'svelte';
 | 
				
			||||||
import { writable } from 'svelte/store';
 | 
					import { writable } from 'svelte/store';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export enum NotificationType {
 | 
					export enum NotificationType {
 | 
				
			||||||
@ -15,11 +16,6 @@ export type Notification = {
 | 
				
			|||||||
  id: number;
 | 
					  id: number;
 | 
				
			||||||
  type: NotificationType;
 | 
					  type: NotificationType;
 | 
				
			||||||
  message: string;
 | 
					  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 */
 | 
					  /** The action to take when the notification is clicked */
 | 
				
			||||||
  action: NotificationAction;
 | 
					  action: NotificationAction;
 | 
				
			||||||
  button?: NotificationButton;
 | 
					  button?: NotificationButton;
 | 
				
			||||||
@ -32,13 +28,37 @@ type NoopAction = { type: 'noop' };
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export type NotificationAction = DiscardAction | NoopAction;
 | 
					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() {
 | 
					function createNotificationList() {
 | 
				
			||||||
  const notificationList = writable<Notification[]>([]);
 | 
					  const notificationList = writable<(Notification | ComponentNotification)[]>([]);
 | 
				
			||||||
  let count = 1;
 | 
					  let count = 1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const show = (options: NotificationOptions) => {
 | 
					  const show = <T>(options: T extends ComponentType ? ComponentNotificationOptions<T> : NotificationOptions) => {
 | 
				
			||||||
    notificationList.update((currentList) => {
 | 
					    notificationList.update((currentList) => {
 | 
				
			||||||
      currentList.push({
 | 
					      currentList.push({
 | 
				
			||||||
        id: count++,
 | 
					        id: count++,
 | 
				
			||||||
 | 
				
			|||||||
@ -378,7 +378,7 @@
 | 
				
			|||||||
  "assets": "Assets",
 | 
					  "assets": "Assets",
 | 
				
			||||||
  "assets_added_count": "Added {count, plural, one {# asset} other {# 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_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_count": "{count, plural, one {# asset} other {# assets}}",
 | 
				
			||||||
  "assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash",
 | 
					  "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}}",
 | 
					  "assets_permanently_deleted_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,5 @@
 | 
				
			|||||||
import { goto } from '$app/navigation';
 | 
					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 { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification';
 | 
				
			||||||
import { AppRoute } from '$lib/constants';
 | 
					import { AppRoute } from '$lib/constants';
 | 
				
			||||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
 | 
					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 { downloadRequest, getKey, withError } from '$lib/utils';
 | 
				
			||||||
import { createAlbum } from '$lib/utils/album-utils';
 | 
					import { createAlbum } from '$lib/utils/album-utils';
 | 
				
			||||||
import { getByteUnitString } from '$lib/utils/byte-units';
 | 
					import { getByteUnitString } from '$lib/utils/byte-units';
 | 
				
			||||||
import { encodeHTMLSpecialChars } from '$lib/utils/string-utils';
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  addAssetsToAlbum as addAssets,
 | 
					  addAssetsToAlbum as addAssets,
 | 
				
			||||||
  getAssetInfo,
 | 
					  getAssetInfo,
 | 
				
			||||||
@ -63,13 +63,17 @@ export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[])
 | 
				
			|||||||
  if (!album) {
 | 
					  if (!album) {
 | 
				
			||||||
    return;
 | 
					    return;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  const displayName = albumName ? `<b>${encodeHTMLSpecialChars(albumName)}</b>` : 'new album';
 | 
					 | 
				
			||||||
  const $t = get(t);
 | 
					  const $t = get(t);
 | 
				
			||||||
  notificationController.show({
 | 
					  notificationController.show({
 | 
				
			||||||
    type: NotificationType.Info,
 | 
					    type: NotificationType.Info,
 | 
				
			||||||
    timeout: 5000,
 | 
					    timeout: 5000,
 | 
				
			||||||
    message: $t('assets_added_to_name_count', { values: { count: assetIds.length, name: displayName } }),
 | 
					    component: {
 | 
				
			||||||
    html: true,
 | 
					      type: FormatBoldMessage,
 | 
				
			||||||
 | 
					      props: {
 | 
				
			||||||
 | 
					        key: 'assets_added_to_name_count',
 | 
				
			||||||
 | 
					        values: { count: assetIds.length, name: albumName, hasName: !!albumName },
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    button: {
 | 
					    button: {
 | 
				
			||||||
      text: $t('view_album'),
 | 
					      text: $t('view_album'),
 | 
				
			||||||
      onClick() {
 | 
					      onClick() {
 | 
				
			||||||
 | 
				
			|||||||
@ -5,12 +5,3 @@ export const removeAccents = (str: string) => {
 | 
				
			|||||||
export const normalizeSearchString = (str: string) => {
 | 
					export const normalizeSearchString = (str: string) => {
 | 
				
			||||||
  return removeAccents(str.toLocaleLowerCase());
 | 
					  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