chore(web): bump immich/ui for tooltips (#24632)

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Jason Rasmussen
2026-01-05 14:51:03 -05:00
committed by GitHub
parent 4d32968f2b
commit 57db5e64de
17 changed files with 99 additions and 193 deletions

18
pnpm-lock.yaml generated
View File

@@ -717,8 +717,8 @@ importers:
specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.50.1
version: 0.50.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)
specifier: ^0.52.0
version: 0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3)
@@ -3054,8 +3054,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.50.1':
resolution: {integrity: sha512-fNlQGh75ZFa/UZAgJaYk9/ItHOXHNNzN4CunjCmE7WocVVkUZbUxopN9Ku3F5GULSqD/zJ5gNO6PQAZ1ZoSaaQ==}
'@immich/ui@0.52.0':
resolution: {integrity: sha512-ECQIE5qYNpe7Q5+hifIGUDaRQXBkPOp9dvZaHELWWzAGIhbwG+mUYwMpUgU2TO7fV5u8XU6nHyBuC055zApiWQ==}
peerDependencies:
svelte: ^5.0.0
@@ -10677,6 +10677,10 @@ packages:
resolution: {integrity: sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==}
engines: {node: '>=0.12.18'}
simple-icons@16.4.0:
resolution: {integrity: sha512-8CKtCvx1Zq3L0CBsR4RR1MjGCXkXbzdspwl2yCxs8oWkstbzj2+DatRKDee/tuj3Ffd/2CDzwEky9RgG2yggew==}
engines: {node: '>=0.12.18'}
sirv@2.0.4:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
@@ -15026,14 +15030,14 @@ snapshots:
dependencies:
svelte: 5.46.1
'@immich/ui@0.50.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)':
'@immich/ui@0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1)
'@internationalized/date': 3.10.0
'@mdi/js': 7.4.47
bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)
luxon: 3.7.2
simple-icons: 15.22.0
simple-icons: 16.4.0
svelte: 5.46.1
svelte-highlight: 7.9.0
tailwind-merge: 3.4.0
@@ -24017,6 +24021,8 @@ snapshots:
simple-icons@15.22.0: {}
simple-icons@16.4.0: {}
sirv@2.0.4:
dependencies:
'@polka/url': 1.0.0-next.29

View File

@@ -19,7 +19,7 @@
"format": "prettier --check .",
"format:fix": "prettier --write . && pnpm run format:i18n",
"format:i18n": "pnpm dlx sort-json ../i18n/*.json",
"test": "vitest --run",
"test": "vitest",
"test:cov": "vitest --coverage",
"test:watch": "vitest dev",
"prepare": "svelte-kit sync"
@@ -28,7 +28,7 @@
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.50.1",
"@immich/ui": "^0.52.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",

View File

@@ -0,0 +1,15 @@
<script lang="ts" generics="T extends Record<string, unknown>">
import { TooltipProvider } from '@immich/ui';
import type { Component } from 'svelte';
type Props = {
component: Component<T>;
componentProps: T;
};
const { component: Test, componentProps }: Props = $props();
</script>
<TooltipProvider>
<Test {...componentProps} />
</TooltipProvider>

View File

@@ -1,4 +1,5 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { renderWithTooltips } from '$tests/helpers';
import { albumFactory } from '@test-data/factories/album-factory';
import '@testing-library/jest-dom';
import { render, waitFor, type RenderResult } from '@testing-library/svelte';
@@ -88,7 +89,7 @@ describe('AlbumCard component', () => {
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
beforeEach(async () => {
sut = render(AlbumCard, { album, onShowContextMenu });
sut = renderWithTooltips(AlbumCard, { album, onShowContextMenu });
const albumImgElement = sut.getByTestId('album-image');
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));

View File

@@ -1,7 +1,7 @@
import { renderWithTooltips } from '$tests/helpers';
import type { AssetResponseDto } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import '@testing-library/jest-dom';
import { render } from '@testing-library/svelte';
import DeleteAction from './delete-action.svelte';
let asset: AssetResponseDto;
@@ -13,8 +13,12 @@ describe('DeleteAction component', () => {
});
it('displays a button to move the asset to the trash bin', () => {
const { getByTitle, queryByTitle } = render(DeleteAction, { asset, onAction: vi.fn(), preAction: vi.fn() });
expect(getByTitle('delete')).toBeInTheDocument();
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('delete')).toBeInTheDocument();
expect(queryByTitle('deletePermanently')).toBeNull();
});
});
@@ -25,8 +29,12 @@ describe('DeleteAction component', () => {
});
it('displays a button to permanently delete the asset', () => {
const { getByTitle, queryByTitle } = render(DeleteAction, { asset, onAction: vi.fn(), preAction: vi.fn() });
expect(getByTitle('permanently_delete')).toBeInTheDocument();
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('permanently_delete')).toBeInTheDocument();
expect(queryByTitle('delete')).toBeNull();
});
});

View File

@@ -1,9 +1,9 @@
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import { preferencesFactory } from '@test-data/factories/preferences-factory';
import { userAdminFactory } from '@test-data/factories/user-factory';
import '@testing-library/jest-dom';
import { render } from '@testing-library/svelte';
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
describe('AssetViewerNavBar component', () => {
@@ -16,12 +16,13 @@ describe('AssetViewerNavBar component', () => {
showShareButton: false,
preAction: () => {},
onZoomImage: () => {},
onCopyImage: () => {},
onAction: () => {},
onRunJob: () => {},
onPlaySlideshow: () => {},
onShowDetail: () => {},
onClose: () => {},
playOriginalVideo: false,
setPlayOriginalVideo: () => Promise.resolve(),
};
beforeAll(() => {
@@ -51,8 +52,8 @@ describe('AssetViewerNavBar component', () => {
preferencesStore.set(prefs);
const asset = assetFactory.build({ isTrashed: false });
const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
expect(getByTitle('go_back')).toBeInTheDocument();
const { getByLabelText } = renderWithTooltips(AssetViewerNavBar, { asset, ...additionalProps });
expect(getByLabelText('go_back')).toBeInTheDocument();
});
describe('if the current user owns the asset', () => {
@@ -65,8 +66,8 @@ describe('AssetViewerNavBar component', () => {
const prefs = preferencesFactory.build({ cast: { gCastEnabled: false } });
preferencesStore.set(prefs);
const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
expect(getByTitle('delete')).toBeInTheDocument();
const { getByLabelText } = renderWithTooltips(AssetViewerNavBar, { asset, ...additionalProps });
expect(getByLabelText('delete')).toBeInTheDocument();
});
});
});

View File

@@ -1,119 +0,0 @@
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte';
import type { PersonResponseDto } from '@immich/sdk';
import { personFactory } from '@test-data/factories/person-factory';
import { render } from '@testing-library/svelte';
import { tick } from 'svelte';
describe('ManagePeopleVisibility Component', () => {
let personVisible: PersonResponseDto;
let personHidden: PersonResponseDto;
let personWithoutName: PersonResponseDto;
beforeAll(() => {
// Prevents errors from `img.decode()` in ImageThumbnail
Object.defineProperty(HTMLImageElement.prototype, 'decode', {
value: vi.fn(),
});
});
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
personVisible = personFactory.build({ isHidden: false });
personHidden = personFactory.build({ isHidden: true });
personWithoutName = personFactory.build({ isHidden: false, name: undefined });
sdkMock.updatePeople.mockResolvedValue([]);
});
afterEach(() => {
vi.resetAllMocks();
});
it('does not update people when no changes are made', () => {
const { getByText } = render(ManagePeopleVisibility, {
props: {
people: [personVisible, personHidden, personWithoutName],
totalPeopleCount: 3,
onClose: vi.fn(),
loadNextPage: vi.fn(),
},
});
const saveButton = getByText('done');
saveButton.click();
expect(sdkMock.updatePeople).not.toHaveBeenCalled();
});
// svelte animations require a real browser
it.skip('hides unnamed people on first button press', () => {
const { getByText, getByTitle } = render(ManagePeopleVisibility, {
props: {
people: [personVisible, personHidden, personWithoutName],
totalPeopleCount: 3,
onClose: vi.fn(),
loadNextPage: vi.fn(),
},
});
getByTitle('hide_unnamed_people').click();
getByText('done').click();
expect(sdkMock.updatePeople).toHaveBeenCalledWith({
peopleUpdateDto: {
people: [{ id: personWithoutName.id, isHidden: true }],
},
});
});
// svelte animations require a real browser
it.skip('hides all people on second button press', async () => {
const { getByText, getByTitle } = render(ManagePeopleVisibility, {
props: {
people: [personVisible, personHidden, personWithoutName],
totalPeopleCount: 3,
onClose: vi.fn(),
loadNextPage: vi.fn(),
},
});
getByTitle('hide_unnamed_people').click();
await tick();
getByTitle('hide_all_people').click();
getByText('done').click();
expect(sdkMock.updatePeople).toHaveBeenCalledWith({
peopleUpdateDto: {
people: expect.arrayContaining([
{ id: personVisible.id, isHidden: true },
{ id: personWithoutName.id, isHidden: true },
]),
},
});
});
// svelte animations require a real browser
it.skip('shows all people on third button press', async () => {
const { getByText, getByTitle } = render(ManagePeopleVisibility, {
props: {
people: [personVisible, personHidden, personWithoutName],
totalPeopleCount: 3,
onClose: vi.fn(),
loadNextPage: vi.fn(),
},
});
getByTitle('hide_unnamed_people').click();
await tick();
getByTitle('hide_all_people').click();
await tick();
getByTitle('show_all_people').click();
getByText('done').click();
expect(sdkMock.updatePeople).toHaveBeenCalledWith({
peopleUpdateDto: {
people: [{ id: personHidden.id, isHidden: false }],
},
});
});
});

View File

@@ -1,30 +0,0 @@
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
import Combobox from '$lib/components/shared-components/combobox.svelte';
import { render, screen } from '@testing-library/svelte';
describe('Combobox component', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.stubGlobal('visualViewport', getVisualViewportMock());
});
it('shows selected option', () => {
render(Combobox, {
label: 'test',
selectedOption: { label: 'option-1', value: 'option-1' },
});
expect(screen.getByRole('combobox')).toHaveValue('option-1');
});
it('clears the selected option when set to undefined', async () => {
const { rerender } = render(Combobox, {
label: 'test',
selectedOption: { label: 'option-1', value: 'option-1' },
});
await rerender({ selectedOption: undefined });
expect(screen.getByRole('combobox')).toHaveValue('');
});
});

View File

@@ -21,7 +21,14 @@
import { copyToClipboard, getReleaseType, semverToName } from '$lib/utils';
import { maintenanceShouldRedirect } from '$lib/utils/maintenance';
import { isAssetViewerRoute } from '$lib/utils/navigation';
import { CommandPaletteContext, modalManager, setTranslations, toastManager, type ActionItem } from '@immich/ui';
import {
CommandPaletteDefaultProvider,
TooltipProvider,
modalManager,
setTranslations,
toastManager,
type ActionItem,
} from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -180,7 +187,7 @@
</script>
<OnEvents {onReleaseEvent} />
<CommandPaletteContext {commands} />
<CommandPaletteDefaultProvider name="Global" actions={commands} />
<svelte:head>
<title>{page.data.meta?.title || 'Web'} - Immich</title>
@@ -228,15 +235,17 @@
}}
/>
{#if page.data.error}
<ErrorLayout error={page.data.error}></ErrorLayout>
{:else}
{@render children?.()}
{/if}
<TooltipProvider>
{#if page.data.error}
<ErrorLayout error={page.data.error}></ErrorLayout>
{:else}
{@render children?.()}
{/if}
{#if showNavigationLoadingBar}
<NavigationLoadingBar />
{/if}
{#if showNavigationLoadingBar}
<NavigationLoadingBar />
{/if}
<DownloadPanel />
<UploadPanel />
<DownloadPanel />
<UploadPanel />
</TooltipProvider>

View File

@@ -8,7 +8,7 @@
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import { Button, CommandPaletteContext } from '@immich/ui';
import { Button, CommandPaletteDefaultProvider } from '@immich/ui';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -51,7 +51,7 @@
<OnEvents {onLibraryCreate} {onLibraryUpdate} {onLibraryDelete} />
<CommandPaletteContext commands={[Create, ScanAll]} />
<CommandPaletteDefaultProvider name={$t('library')} actions={[Create, ScanAll]} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[ScanAll, Create]}>
<section class="my-4">

View File

@@ -16,7 +16,7 @@
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import type { LibraryResponseDto } from '@immich/sdk';
import { Code, CommandPaletteContext, Container, Heading, modalManager } from '@immich/ui';
import { Code, CommandPaletteDefaultProvider, Container, Heading, modalManager } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -51,7 +51,7 @@
<OnEvents {onLibraryUpdate} {onLibraryDelete} />
<CommandPaletteContext commands={[Edit, Delete, AddFolder, AddExclusionPattern, Scan]} />
<CommandPaletteDefaultProvider name={$t('library')} actions={[Edit, Delete, AddFolder, AddExclusionPattern, Scan]} />
<AdminPageLayout
breadcrumbs={[{ title: $t('external_libraries'), href: AppRoute.ADMIN_LIBRARIES }, { title: library.name }]}

View File

@@ -5,7 +5,7 @@
import { queueManager } from '$lib/managers/queue-manager.svelte';
import { getQueuesActions } from '$lib/services/queue.service';
import { type QueueResponseDto } from '@immich/sdk';
import { CommandPaletteContext, Container, type ActionItem } from '@immich/ui';
import { CommandPaletteDefaultProvider, Container, type ActionItem } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -33,7 +33,7 @@
};
</script>
<CommandPaletteContext {commands} />
<CommandPaletteDefaultProvider name={$t('admin.queues')} actions={commands} />
<OnEvents {onQueueUpdate} />

View File

@@ -26,7 +26,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { getSystemConfigActions } from '$lib/services/system-config.service';
import { Alert, CommandPaletteContext, Container } from '@immich/ui';
import { Alert, CommandPaletteDefaultProvider, Container } from '@immich/ui';
import {
mdiAccountOutline,
mdiBackupRestore,
@@ -214,7 +214,7 @@
);
</script>
<CommandPaletteContext commands={[CopyToClipboard, Upload, Download]} />
<CommandPaletteDefaultProvider name={$t('admin.system_settings')} actions={[CopyToClipboard, Upload, Download]} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[CopyToClipboard, Download, Upload]}>
<Container size="large" center>

View File

@@ -5,7 +5,7 @@
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, CommandPaletteContext, Container, Icon } from '@immich/ui';
import { Button, CommandPaletteDefaultProvider, Container, Icon } from '@immich/ui';
import { mdiInfinity } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -44,7 +44,7 @@
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[Create]} />
<CommandPaletteDefaultProvider name={$t('users')} actions={[Create]} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
<Container center size="large">

View File

@@ -17,7 +17,7 @@
Alert,
Badge,
Code,
CommandPaletteContext,
CommandPaletteDefaultProvider,
Container,
getByteUnitString,
Heading,
@@ -99,7 +99,7 @@
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[ResetPassword, ResetPinCode, Update, Delete, Restore]} />
<CommandPaletteDefaultProvider name={$t('user')} actions={[ResetPassword, ResetPinCode, Update, Delete, Restore]} />
<AdminPageLayout
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}

View File

@@ -25,6 +25,8 @@ const config = {
alias: {
$lib: 'src/lib',
'$lib/*': 'src/lib/*',
$tests: 'src/../tests',
'$tests/*': 'src/../tests/*',
'@test-data': 'src/test-data',
$i18n: '../i18n',
'chromecast-caf-sender': './node_modules/@types/chromecast-caf-sender/index.d.ts',

13
web/tests/helpers.ts Normal file
View File

@@ -0,0 +1,13 @@
import TestWrapper from '$lib/components/TestWrapper.svelte';
import { render, type RenderResult } from '@testing-library/svelte';
import { type Component } from 'svelte';
export const renderWithTooltips = <T extends Record<string, unknown>, K extends Component<T>>(
component: K,
props: T,
) => {
return render(TestWrapper as Component<{ component: K; componentProps: T }>, {
component,
componentProps: props,
}) as unknown as RenderResult<K>;
};