1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

feat(web): render component in notifications (#10990)

This commit is contained in:
Michel Heusschen 2024-07-10 16:05:04 +02:00 committed by GitHub
parent 1dd1d36120
commit 59aa347912
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 88 additions and 30 deletions

View 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>

View file

@ -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;

View file

@ -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>');
});
});

View file

@ -0,0 +1,5 @@
<script lang="ts">
export let href: string;
</script>
Notification <b>message</b> with <a {href}>link</a>

View file

@ -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}

View file

@ -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++,

View file

@ -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}}",

View file

@ -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() {

View file

@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
};