mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 22:51:59 +00:00
feat(web): render component in notifications (#10990)
This commit is contained in:
parent
1dd1d36120
commit
59aa347912
9 changed files with 88 additions and 30 deletions
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…
Reference in a new issue