mirror of
https://github.com/immich-app/immich.git
synced 2025-12-19 01:11:07 +03:00
refactor: more elements (#22095)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import { AppRoute, OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType,
|
||||
@@ -10,6 +9,7 @@
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin, type SystemConfigDto } from '@immich/sdk';
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import type { SystemConfigDto } from '@immich/sdk';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
savedConfig: SystemConfigDto;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
|
||||
@@ -7,6 +6,7 @@
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import Icon from '$lib/elements/Icon.svelte';
|
||||
import {
|
||||
AudioCodec,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import type { SystemConfigDto } from '@immich/sdk';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
savedConfig: SystemConfigDto;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import type { SystemConfigDto } from '@immich/sdk';
|
||||
import { Button, IconButton } from '@immich/ui';
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { SystemConfigDto } from '@immich/sdk';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import type { SystemConfigDto } from '@immich/sdk';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||
|
||||
interface Props {
|
||||
savedConfig: SystemConfigDto;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { createBubbler, preventDefault } from 'svelte/legacy';
|
||||
|
||||
const bubble = createBubbler();
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { AppRoute, SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import {
|
||||
getStorageTemplateOptions,
|
||||
@@ -13,16 +14,13 @@
|
||||
import handlebar from 'handlebars';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import * as luxon from 'luxon';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { createBubbler, preventDefault } from 'svelte/legacy';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||
import SupportedDatetimePanel from './supported-datetime-panel.svelte';
|
||||
import SupportedVariablesPanel from './supported-variables-panel.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
savedConfig: SystemConfigDto;
|
||||
@@ -48,6 +46,7 @@
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
const bubble = createBubbler();
|
||||
let templateOptions: SystemConfigTemplateStorageOptionDto | undefined = $state();
|
||||
let selectedPreset = $state('');
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import Icon from '$lib/elements/Icon.svelte';
|
||||
import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import StarRating from '$lib/components/shared-components/star-rating.svelte';
|
||||
import StarRating from '$lib/elements/StarRating.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import FormatTagB from '$lib/components/i18n/__test__/format-tag-b.svelte';
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { init, locale, register, waitLocale, type Translations } from 'svelte-i18n';
|
||||
import { describe } from 'vitest';
|
||||
|
||||
const getSanitizedHTML = (container: HTMLElement) => container.innerHTML.replaceAll('<!---->', '');
|
||||
|
||||
describe('FormatMessage component', () => {
|
||||
beforeAll(async () => {
|
||||
register('en', () =>
|
||||
Promise.resolve({
|
||||
hello: 'Hello {name}',
|
||||
html: 'Hello <b>{name}</b>',
|
||||
plural: 'You have <b>{count, plural, one {# item} other {# items}}</b>',
|
||||
xss: '<image/src/onerror=prompt(8)>',
|
||||
plural_with_html: 'You have {count, plural, other {<b>#</b> items}}',
|
||||
select_with_html: 'Item is {status, select, other {<b>disabled</b>}}',
|
||||
ordinal_with_html: '{count, selectordinal, other {<b>#th</b>}} item',
|
||||
}),
|
||||
);
|
||||
|
||||
await init({ fallbackLocale: 'en' });
|
||||
await waitLocale('en');
|
||||
});
|
||||
|
||||
it('formats a plain text message', () => {
|
||||
render(FormatMessage, {
|
||||
key: 'hello' as Translations,
|
||||
values: { name: 'test' },
|
||||
});
|
||||
expect(screen.getByText('Hello test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('throws an error when locale is empty', async () => {
|
||||
await locale.set(undefined);
|
||||
expect(() => render(FormatMessage, { key: '' as Translations })).toThrowError();
|
||||
await locale.set('en');
|
||||
});
|
||||
|
||||
it('shows raw message when value is empty', () => {
|
||||
render(FormatMessage, {
|
||||
key: 'hello' as Translations,
|
||||
});
|
||||
expect(screen.getByText('Hello {name}')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows message when slot is empty', () => {
|
||||
render(FormatMessage, {
|
||||
key: 'html' as Translations,
|
||||
values: { name: 'test' },
|
||||
});
|
||||
expect(screen.getByText('Hello test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a message with html', () => {
|
||||
const { container } = render(FormatTagB, {
|
||||
key: 'html' as Translations,
|
||||
values: { name: 'test' },
|
||||
});
|
||||
expect(getSanitizedHTML(container)).toBe('Hello <strong>test</strong>');
|
||||
});
|
||||
|
||||
it('renders a message with html and plural', () => {
|
||||
const { container } = render(FormatTagB, {
|
||||
key: 'plural' as Translations,
|
||||
values: { count: 1 },
|
||||
});
|
||||
expect(getSanitizedHTML(container)).toBe('You have <strong>1 item</strong>');
|
||||
});
|
||||
|
||||
it('protects against XSS injection', () => {
|
||||
render(FormatMessage, {
|
||||
key: 'xss' as Translations,
|
||||
});
|
||||
expect(screen.getByText('<image/src/onerror=prompt(8)>')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the message key when not found', () => {
|
||||
render(FormatMessage, { key: 'invalid.key' as Translations });
|
||||
expect(screen.getByText('invalid.key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports html tags inside plurals', () => {
|
||||
const { container } = render(FormatTagB, {
|
||||
key: 'plural_with_html' as Translations,
|
||||
values: { count: 10 },
|
||||
});
|
||||
expect(getSanitizedHTML(container)).toBe('You have <strong>10</strong> items');
|
||||
});
|
||||
|
||||
it('supports html tags inside select', () => {
|
||||
const { container } = render(FormatTagB, {
|
||||
key: 'select_with_html' as Translations,
|
||||
values: { status: true },
|
||||
});
|
||||
expect(getSanitizedHTML(container)).toBe('Item is <strong>disabled</strong>');
|
||||
});
|
||||
|
||||
it('supports html tags inside selectordinal', () => {
|
||||
const { container } = render(FormatTagB, {
|
||||
key: 'ordinal_with_html' as Translations,
|
||||
values: { count: 4 },
|
||||
});
|
||||
expect(getSanitizedHTML(container)).toBe('<strong>4th</strong> item');
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Translations } from 'svelte-i18n';
|
||||
import FormatMessage from '../format-message.svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
key: Translations;
|
||||
values: ComponentProps<typeof FormatMessage>['values'];
|
||||
}
|
||||
|
||||
let { key, values }: Props = $props();
|
||||
</script>
|
||||
|
||||
<FormatMessage {key} {values}>
|
||||
{#snippet children({ tag, message })}
|
||||
{#if tag === 'b'}
|
||||
<strong>{message}</strong>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { InterpolationValues } from '$lib/components/i18n/format-message';
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import type { Translations } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
key: Translations;
|
||||
values?: InterpolationValues;
|
||||
}
|
||||
|
||||
let { key, values = {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<FormatMessage {key} {values}>
|
||||
{#snippet children({ message, tag })}
|
||||
{#if tag === 'b'}
|
||||
<b>{message}</b>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
@@ -1,139 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { IntlMessageFormat, type FormatXMLElementFn } from 'intl-messageformat';
|
||||
import {
|
||||
TYPE,
|
||||
type MessageFormatElement,
|
||||
type PluralElement,
|
||||
type SelectElement,
|
||||
} from '@formatjs/icu-messageformat-parser';
|
||||
import { locale as i18nLocale, json, type Translations } from 'svelte-i18n';
|
||||
import type { InterpolationValues } from '$lib/components/i18n/format-message';
|
||||
|
||||
type MessagePart = {
|
||||
message: string;
|
||||
tag?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
key: Translations;
|
||||
values?: InterpolationValues;
|
||||
children?: import('svelte').Snippet<[{ tag?: string; message?: string }]>;
|
||||
}
|
||||
|
||||
let { key, values = {}, children }: Props = $props();
|
||||
|
||||
const getLocale = (locale?: string | null) => {
|
||||
if (locale == null) {
|
||||
throw new Error('Cannot format a message without first setting the initial locale.');
|
||||
}
|
||||
|
||||
return locale;
|
||||
};
|
||||
|
||||
const getElements = (message: string | MessageFormatElement[], locale: string): MessageFormatElement[] => {
|
||||
return new IntlMessageFormat(message, locale, undefined, {
|
||||
ignoreTag: false,
|
||||
}).getAst();
|
||||
};
|
||||
|
||||
const getTagReplacements = (element: PluralElement | SelectElement) => {
|
||||
const replacements: Record<string, FormatXMLElementFn<unknown>> = {};
|
||||
|
||||
for (const option of Object.values(element.options)) {
|
||||
for (const pluralElement of option.value) {
|
||||
if (pluralElement.type === TYPE.tag) {
|
||||
const tag = pluralElement.value;
|
||||
replacements[tag] = (...parts) => `<${tag}>${parts}</${tag}>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return replacements;
|
||||
};
|
||||
|
||||
const formatElementToParts = (element: MessageFormatElement, values: InterpolationValues) => {
|
||||
const message = new IntlMessageFormat([element], locale, undefined, {
|
||||
ignoreTag: true,
|
||||
}).format(values) as string;
|
||||
|
||||
const pluralElements = new IntlMessageFormat(message, locale, undefined, {
|
||||
ignoreTag: false,
|
||||
}).getAst();
|
||||
|
||||
return pluralElements.map((element) => elementToPart(element));
|
||||
};
|
||||
|
||||
const elementToPart = (element: MessageFormatElement): MessagePart => {
|
||||
const isTag = element.type === TYPE.tag;
|
||||
|
||||
return {
|
||||
tag: isTag ? element.value : undefined,
|
||||
message: new IntlMessageFormat(isTag ? element.children : [element], locale, undefined, {
|
||||
ignoreTag: true,
|
||||
}).format(values) as string,
|
||||
};
|
||||
};
|
||||
|
||||
const getParts = (message: string, locale: string) => {
|
||||
try {
|
||||
const elements = getElements(message, locale);
|
||||
const parts: MessagePart[] = [];
|
||||
|
||||
for (const element of elements) {
|
||||
if (element.type === TYPE.plural || element.type === TYPE.select) {
|
||||
const replacements = getTagReplacements(element);
|
||||
parts.push(...formatElementToParts(element, { ...values, ...replacements }));
|
||||
} else {
|
||||
parts.push(elementToPart(element));
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.warn(`Message "${key}" has syntax error:`, error.message);
|
||||
}
|
||||
return [{ message, tag: undefined }];
|
||||
}
|
||||
};
|
||||
|
||||
let message = $derived(($json(key) as string) || key);
|
||||
let locale = $derived(getLocale($i18nLocale));
|
||||
let parts = $derived(getParts(message, locale));
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@component
|
||||
Formats an [ICU message](https://formatjs.io/docs/core-concepts/icu-syntax) that contains HTML tags
|
||||
|
||||
### Props
|
||||
- `key` - Key of a defined message
|
||||
- `values` - Object with a value for each placeholder in the message (optional)
|
||||
|
||||
### Default Slot
|
||||
Used for every occurrence of an HTML tag in a message
|
||||
- `tag` - Name of the tag
|
||||
- `message` - Formatted text inside the tag
|
||||
|
||||
@example
|
||||
```svelte
|
||||
{"message": "Visit <link>docs</link> <b>{time}</b>"}
|
||||
<FormattedMessage key="message" values={{ time: 'now' }} let:tag let:message>
|
||||
{#if tag === 'link'}
|
||||
<a href="">{message}</a>
|
||||
{:else if tag === 'b'}
|
||||
<strong>{message}</strong>
|
||||
{/if}
|
||||
</FormattedMessage>
|
||||
|
||||
Result: Visit <a href="">docs</a> <strong>now</strong>
|
||||
```
|
||||
-->
|
||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||
{#each parts as { tag, message }}
|
||||
{#if tag}
|
||||
{#if children}{@render children({ tag, message })}{:else}{message}{/if}
|
||||
{:else}
|
||||
{message}
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -1,2 +0,0 @@
|
||||
import type { FormatXMLElementFn, PrimitiveType } from 'intl-messageformat';
|
||||
export type InterpolationValues = Record<string, PrimitiveType | FormatXMLElementFn<unknown>>;
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import Icon from '$lib/elements/Icon.svelte';
|
||||
import { Stack } from '@immich/ui';
|
||||
import { mdiAlertCircleOutline } from '@mdi/js';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import AdminSettings from '$lib/components/admin-page/settings/admin-settings.svelte';
|
||||
import StorageTemplateSettings from '$lib/components/admin-page/settings/storage-template/storage-template-settings.svelte';
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getConfig, type SystemConfigDto } from '@immich/sdk';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { Checkbox, ConfirmModal, Label } from '@immich/ui';
|
||||
import { mdiDeleteForeverOutline } from '@mdi/js';
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import StarRating from '$lib/components/shared-components/star-rating.svelte';
|
||||
import { render } from '@testing-library/svelte';
|
||||
|
||||
describe('StarRating component', () => {
|
||||
it('renders correctly', () => {
|
||||
const component = render(StarRating, {
|
||||
count: 3,
|
||||
rating: 2,
|
||||
readOnly: false,
|
||||
onRating: vi.fn(),
|
||||
});
|
||||
const container = component.getByTestId('star-container') as HTMLImageElement;
|
||||
expect(container.className).toBe('flex flex-row');
|
||||
|
||||
const radioButtons = component.getAllByRole('radio') as HTMLInputElement[];
|
||||
expect(radioButtons.length).toBe(3);
|
||||
const labels = component.getAllByTestId('star') as HTMLLabelElement[];
|
||||
expect(labels.length).toBe(3);
|
||||
const labelText = component.getAllByText('rating_count') as HTMLSpanElement[];
|
||||
expect(labelText.length).toBe(3);
|
||||
const clearButton = component.getByRole('button') as HTMLButtonElement;
|
||||
expect(clearButton).toBeInTheDocument();
|
||||
|
||||
// Check the clear button content
|
||||
expect(clearButton.textContent).toBe('rating_clear');
|
||||
|
||||
// Check the initial state
|
||||
expect(radioButtons[0].checked).toBe(false);
|
||||
expect(radioButtons[1].checked).toBe(true);
|
||||
expect(radioButtons[2].checked).toBe(false);
|
||||
|
||||
// Check the radio button attributes
|
||||
for (const [index, radioButton] of radioButtons.entries()) {
|
||||
expect(radioButton.id).toBe(labels[index].htmlFor);
|
||||
expect(radioButton.name).toBe('stars');
|
||||
expect(radioButton.value).toBe((index + 1).toString());
|
||||
expect(radioButton.disabled).toBe(false);
|
||||
expect(radioButton.className).toBe('sr-only');
|
||||
}
|
||||
|
||||
// Check the label attributes
|
||||
for (const label of labels) {
|
||||
expect(label.className).toBe('cursor-pointer');
|
||||
expect(label.tabIndex).toBe(-1);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders correctly with readOnly', () => {
|
||||
const component = render(StarRating, {
|
||||
count: 3,
|
||||
rating: 2,
|
||||
readOnly: true,
|
||||
onRating: vi.fn(),
|
||||
});
|
||||
const radioButtons = component.getAllByRole('radio') as HTMLInputElement[];
|
||||
expect(radioButtons.length).toBe(3);
|
||||
const labels = component.getAllByTestId('star') as HTMLLabelElement[];
|
||||
expect(labels.length).toBe(3);
|
||||
const clearButton = component.queryByRole('button');
|
||||
expect(clearButton).toBeNull();
|
||||
|
||||
// Check the initial state
|
||||
expect(radioButtons[0].checked).toBe(false);
|
||||
expect(radioButtons[1].checked).toBe(true);
|
||||
expect(radioButtons[2].checked).toBe(false);
|
||||
|
||||
// Check the radio button attributes
|
||||
for (const [index, radioButton] of radioButtons.entries()) {
|
||||
expect(radioButton.id).toBe(labels[index].htmlFor);
|
||||
expect(radioButton.disabled).toBe(true);
|
||||
}
|
||||
|
||||
// Check the label attributes
|
||||
for (const label of labels) {
|
||||
expect(label.className).toBe('');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { focusOutside } from '$lib/actions/focus-outside';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import Icon from '$lib/elements/Icon.svelte';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
count?: number;
|
||||
rating: number;
|
||||
readOnly?: boolean;
|
||||
onRating: (rating: number) => void | undefined;
|
||||
}
|
||||
|
||||
let { count = 5, rating, readOnly = false, onRating }: Props = $props();
|
||||
|
||||
let ratingSelection = $derived(rating);
|
||||
let hoverRating = $state(0);
|
||||
let focusRating = $state(0);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const starIcon =
|
||||
'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z';
|
||||
const id = generateId();
|
||||
|
||||
const handleSelect = (newRating: number) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newRating === rating) {
|
||||
return;
|
||||
}
|
||||
|
||||
onRating(newRating);
|
||||
};
|
||||
|
||||
const setHoverRating = (value: number) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
hoverRating = value;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setHoverRating(0);
|
||||
focusRating = 0;
|
||||
};
|
||||
|
||||
const handleSelectDebounced = (value: number) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
handleSelect(value);
|
||||
}, 300);
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||
<fieldset
|
||||
class="text-immich-primary dark:text-immich-dark-primary w-fit cursor-default"
|
||||
onmouseleave={() => setHoverRating(0)}
|
||||
use:focusOutside={{ onFocusOut: reset }}
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
|
||||
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
|
||||
]}
|
||||
>
|
||||
<legend class="sr-only">{$t('rating')}</legend>
|
||||
<div class="flex flex-row" data-testid="star-container">
|
||||
{#each { length: count } as _, index (index)}
|
||||
{@const value = index + 1}
|
||||
{@const filled = hoverRating >= value || (hoverRating === 0 && ratingSelection >= value)}
|
||||
{@const starId = `${id}-${value}`}
|
||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<label
|
||||
for={starId}
|
||||
class:cursor-pointer={!readOnly}
|
||||
class:ring-2={focusRating === value}
|
||||
onmouseover={() => setHoverRating(value)}
|
||||
tabindex={-1}
|
||||
data-testid="star"
|
||||
>
|
||||
<span class="sr-only">{$t('rating_count', { values: { count: value } })}</span>
|
||||
<Icon
|
||||
path={starIcon}
|
||||
size="1.5em"
|
||||
strokeWidth={1}
|
||||
color={filled ? 'currentcolor' : 'transparent'}
|
||||
strokeColor={filled ? 'currentcolor' : '#c1cce8'}
|
||||
ariaHidden
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
type="radio"
|
||||
name="stars"
|
||||
{value}
|
||||
id={starId}
|
||||
bind:group={ratingSelection}
|
||||
disabled={readOnly}
|
||||
onfocus={() => {
|
||||
focusRating = value;
|
||||
}}
|
||||
onchange={() => handleSelectDebounced(value)}
|
||||
class="sr-only"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{#if ratingSelection > 0 && !readOnly}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
ratingSelection = 0;
|
||||
handleSelect(ratingSelection);
|
||||
}}
|
||||
class="cursor-pointer text-xs text-immich-primary dark:text-immich-dark-primary"
|
||||
>
|
||||
{$t('rating_clear')}
|
||||
</button>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user