Merge branch 'main' into feat/album-asset-workflow-trigger

This commit is contained in:
Ben Beckford 2026-08-03 08:21:36 -07:00 committed by GitHub
commit 61620e6c27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 307 additions and 67 deletions

View file

@ -1,7 +0,0 @@
merge_queue:
status_comments: outcomes
queue_rules:
- name: default
batch_size: 3
batch_max_wait_time: 2 min

View file

@ -12,6 +12,8 @@
"editorconfig.editorconfig",
"foxundermoon.shell-format",
"timonwong.shellcheck",
"bluebrown.yamlfmt"
"bluebrown.yamlfmt",
// TODO TS 7 while it's still in preview
"typescriptteam.native-preview"
]
}

View file

@ -60,6 +60,8 @@
"**/dist/**": true,
"**/node_modules/**": true
},
// TODO remove once the ts7 extension is stable
"js/ts.experimental.useTsgo": true,
"js/ts.preferences.importModuleSpecifier": "non-relative",
"search.exclude": {
"**/.svelte-kit": true,

View file

@ -30,6 +30,7 @@
"add_to_album_bottom_sheet_added": "Added to {album}",
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
"add_to_album_bottom_sheet_some_local_assets": "Some local assets could not be added to album",
"add_to_album_item_count": "Add {count, plural, one {# item} other {# items}} to album",
"add_to_albums": "Add to albums",
"add_to_albums_count": "Add to albums ({count})",
"add_to_bottom_bar": "Add to",

View file

@ -7,7 +7,7 @@ const Map<String, Locale> locales = {
'Arabic (ar)': Locale('ar'),
'Basque (eu)': Locale('eu'),
'Belarusian (be)': Locale('be'),
'Bosnian (bl)': Locale('bn'),
'Bengali (bn)': Locale('bn'),
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
'Bulgarian (bg)': Locale('bg'),
'Catalan (ca)': Locale('ca'),

View file

@ -33,6 +33,7 @@ final assetsActionProvider = Provider.family.autoDispose<AssetFilter<BaseAsset>,
null => const <BaseAsset>{},
},
}),
dependencies: [multiSelectProvider],
);
final clearSelectionProvider = Provider.family.autoDispose<VoidCallback, ActionSource>((ref, source) {
@ -41,10 +42,11 @@ final clearSelectionProvider = Provider.family.autoDispose<VoidCallback, ActionS
}
return () {};
});
}, dependencies: [multiSelectProvider]);
final ownedAssetsActionProvider = Provider.family.autoDispose<AssetFilter<RemoteAsset>, ActionSource>(
(ref, source) => ref.watch(assetsActionProvider(source)).owned(ref.watch(authUserProvider).id),
dependencies: [assetsActionProvider],
);
abstract class AssetActionBuilder extends ActionBuilder {

View file

@ -22,7 +22,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref,
.map((asset) => asset.id)
.toList(growable: false);
return assetIds.isEmpty ? null : (shouldArchive: shouldArchive, assetIds: assetIds);
});
}, dependencies: [ownedAssetsActionProvider]);
class ArchiveAction extends AssetActionBuilder {
const ArchiveAction({required super.source});

View file

@ -40,7 +40,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref,
final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked);
return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash);
});
}, dependencies: [assetsActionProvider]);
class DeleteAction extends AssetActionBuilder {
const DeleteAction({required super.source});
@ -149,7 +149,7 @@ final _cleanupStateProvider = Provider.family.autoDispose<List<String>?, ActionS
final assets = ref.watch(assetsActionProvider(source));
final assetIds = assets.backedUp().map((asset) => asset.localId).nonNulls.toList(growable: false);
return assetIds.isEmpty ? null : assetIds;
});
}, dependencies: [assetsActionProvider]);
class CleanupLocalAction extends AssetActionBuilder {
const CleanupLocalAction({required super.source});

View file

@ -14,7 +14,7 @@ final _stateProvider = Provider.family.autoDispose<List<RemoteAsset>?, ActionSou
final assets = ref.watch(assetsActionProvider(source));
final remote = assets.remote().toList(growable: false);
return remote.isEmpty ? null : remote;
});
}, dependencies: [assetsActionProvider]);
class DownloadAction extends AssetActionBuilder {
const DownloadAction({required super.source});

View file

@ -28,7 +28,7 @@ final _stateProvider = Provider.family.autoDispose<RemoteAsset?, ActionSource>((
final assets = ref.watch(ownedAssetsActionProvider(source));
return assets.where((asset) => asset.isEditable).singleOrNull;
});
}, dependencies: [ownedAssetsActionProvider]);
class EditAssetAction extends AssetActionBuilder {
const EditAssetAction({required super.source});

View file

@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref,
}
return (assetIds: assets.map((asset) => asset.id).toList(growable: false), origin: assets.singleOrNull);
});
}, dependencies: [ownedAssetsActionProvider]);
class EditDateTimeAction extends AssetActionBuilder {
const EditDateTimeAction({required super.source});

View file

@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref,
}
return (assetIds: assets.map((asset) => asset.id).toList(growable: false), origin: assets.singleOrNull);
});
}, dependencies: [ownedAssetsActionProvider]);
class EditLocationAction extends AssetActionBuilder {
const EditLocationAction({required super.source});

View file

@ -18,7 +18,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref,
final shouldFavorite = assets.favorite(isFavorite: false).isNotEmpty;
final assetIds = assets.favorite(isFavorite: !shouldFavorite).map((asset) => asset.id).toList(growable: false);
return (shouldFavorite: shouldFavorite, assetIds: assetIds);
});
}, dependencies: [ownedAssetsActionProvider]);
class FavoriteAction extends AssetActionBuilder {
const FavoriteAction({required super.source});

View file

@ -23,7 +23,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref,
// Only locking has an on-device copy to clean up; unlocking leaves the device alone.
localIds: shouldLock ? targets.map((asset) => asset.localId).nonNulls.toList(growable: false) : const [],
);
});
}, dependencies: [ownedAssetsActionProvider]);
class LockAction extends AssetActionBuilder {
const LockAction({required super.source});

View file

@ -11,7 +11,7 @@ final _stateProvider = Provider.family.autoDispose<List<String>?, ActionSource>(
final assets = ref.watch(assetsActionProvider(source));
final assetIds = assets.remote().map((asset) => asset.id).toList(growable: false);
return assetIds.isEmpty ? null : assetIds;
});
}, dependencies: [assetsActionProvider]);
class RemoveFromAlbumAction extends AssetActionBuilder {
final String albumId;

View file

@ -11,7 +11,7 @@ final _stateProvider = Provider.family.autoDispose<List<String>?, ActionSource>(
final assets = ref.watch(ownedAssetsActionProvider(source));
final assetIds = assets.trashed().map((asset) => asset.id).toList(growable: false);
return assetIds.isEmpty ? null : assetIds;
});
}, dependencies: [ownedAssetsActionProvider]);
class RestoreAction extends AssetActionBuilder {
const RestoreAction({required super.source});

View file

@ -11,7 +11,7 @@ import 'package:immich_mobile/utils/error_handler.dart';
final _stateProvider = Provider.family.autoDispose<String?, ActionSource>((ref, source) {
final assets = ref.watch(assetsActionProvider(source));
return assets.remote().map((asset) => asset.id).singleOrNull;
});
}, dependencies: [assetsActionProvider]);
class SetAlbumCoverAction extends AssetActionBuilder {
final String albumId;

View file

@ -16,7 +16,7 @@ final _stateProvider = Provider.family.autoDispose<List<BaseAsset>?, ActionSourc
final assets = ref.watch(assetsActionProvider(source));
final shareable = assets.toList(growable: false);
return shareable.isEmpty ? null : shareable;
});
}, dependencies: [assetsActionProvider]);
class ShareAction extends AssetActionBuilder {
const ShareAction({required super.source});

View file

@ -12,7 +12,7 @@ final _stateProvider = Provider.family.autoDispose<List<String>?, ActionSource>(
final assets = ref.watch(assetsActionProvider(source));
final remoteIds = assets.remote().map((asset) => asset.id).toList(growable: false);
return remoteIds.isEmpty ? null : remoteIds;
});
}, dependencies: [assetsActionProvider]);
class ShareLinkAction extends AssetActionBuilder {
const ShareLinkAction({required super.source});

View file

@ -23,7 +23,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref,
assetIds: assets.map((asset) => asset.id).toList(growable: false),
stackIds: assets.map((asset) => asset.stackId).nonNulls.toList(growable: false),
);
});
}, dependencies: [ownedAssetsActionProvider]);
class StackAction extends AssetActionBuilder {
const StackAction({required super.source});

View file

@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose<List<String>?, ActionSource>(
final assets = ref.watch(ownedAssetsActionProvider(source));
final assetIds = assets.map((asset) => asset.id).toList(growable: false);
return assetIds.isEmpty ? null : assetIds;
});
}, dependencies: [ownedAssetsActionProvider]);
class TagAction extends AssetActionBuilder {
const TagAction({required super.source});

View file

@ -16,7 +16,7 @@ final _stateProvider = Provider.family.autoDispose<List<LocalAsset>?, ActionSour
final assets = ref.watch(assetsActionProvider(source));
final local = assets.backedUp(isBackedUp: false).local().toList(growable: false);
return local.isEmpty ? null : local;
});
}, dependencies: [assetsActionProvider]);
class UploadAction extends AssetActionBuilder {
final bool showProgress;

View file

@ -103,7 +103,8 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('AssetUploadReadyV2', _handleSyncAssetUploadReadyV2);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReadyV1);
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
socket.on('on_album_update', _handleAlbumUpdate);
socket.on('on_album_update', _handleRemoteChange);
socket.on('on_asset_stack_update', _handleRemoteChange);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates);
} catch (e) {
@ -185,7 +186,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data));
}
void _handleAlbumUpdate(dynamic _) {
void _handleRemoteChange(dynamic _) {
unawaited(_ref.read(backgroundSyncProvider).syncRemote());
}

View file

@ -101,15 +101,18 @@ extension PumpPresentationWidget on WidgetTester {
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: [...context.overrides, ...overrides],
overrides: context.overrides,
child: Builder(
builder: (context) => MaterialApp(
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: scaffoldMessengerKey,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
home: Scaffold(body: widget),
builder: (context) => ProviderScope(
overrides: overrides,
child: MaterialApp(
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: scaffoldMessengerKey,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
home: Scaffold(body: widget),
),
),
),
),

16
pnpm-lock.yaml generated
View file

@ -952,8 +952,8 @@ importers:
specifier: ^4.2.4
version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))
'@testing-library/jest-dom':
specifier: ^6.4.2
version: 6.9.1
specifier: ^7.0.0
version: 7.0.0(@testing-library/dom@10.4.1)
'@testing-library/svelte':
specifier: ^5.2.8
version: 5.4.2(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))(vitest@4.1.10)
@ -4995,9 +4995,11 @@ packages:
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
'@testing-library/jest-dom@6.9.1':
resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
'@testing-library/jest-dom@7.0.0':
resolution: {integrity: sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==}
engines: {node: '>=22', npm: '>=6', yarn: '>=1'}
peerDependencies:
'@testing-library/dom': '>=10 <11'
'@testing-library/svelte-core@1.1.3':
resolution: {integrity: sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==}
@ -6805,6 +6807,7 @@ packages:
cron-parser@4.9.0:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'}
deprecated: v4 is no longer maintained, upgrade to v5
cron@4.4.0:
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
@ -17751,9 +17754,10 @@ snapshots:
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/jest-dom@6.9.1':
'@testing-library/jest-dom@7.0.0(@testing-library/dom@10.4.1)':
dependencies:
'@adobe/css-tools': 4.5.0
'@testing-library/dom': 10.4.1
aria-query: 5.3.1
css.escape: 1.5.1
dom-accessibility-api: 0.6.3

View file

@ -9,6 +9,7 @@ import semver from 'semver';
import {
EXTENSION_NAMES,
POSTGRES_VERSION_RANGE,
serverVersion,
VECTOR_EXTENSIONS,
VECTOR_INDEX_TABLES,
VECTOR_VERSION_RANGE,
@ -382,6 +383,17 @@ export class DatabaseRepository {
if (error) {
this.logger.error(`Migrations failed: ${error}`);
const missing =
error instanceof Error ? error.message.match(/previously executed migration (.+) is missing/u) : null;
if (missing) {
throw new Error(
`Migration "${missing[1]}" was already applied to this database but is not in this version of Immich (${serverVersion}). ` +
`This usually means the database was migrated by a newer version. Downgrades are not supported.`,
{ cause: error },
);
}
throw error;
}

View file

@ -317,6 +317,12 @@ describe(AssetMediaService.name, () => {
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.create).not.toHaveBeenCalled();
expect(mocks.asset.remove).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: [file.originalPath, undefined] },
});
expect(mocks.event.emit).not.toHaveBeenCalled();
expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size);
expect(mocks.storage.utimes).not.toHaveBeenCalledWith(
file.originalPath,

View file

@ -1,7 +1,7 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { AuthSharedLink } from 'src/database';
import { Asset, AuthSharedLink } from 'src/database';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
@ -128,6 +128,7 @@ export class AssetMediaService extends BaseService {
file: UploadFile,
sidecarFile?: UploadFile,
): Promise<AssetMediaResponseDto> {
let asset: Asset | undefined;
try {
await this.requireAccess({
auth,
@ -145,7 +146,7 @@ export class AssetMediaService extends BaseService {
);
}
const asset = await this.assetRepository.create({
asset = await this.assetRepository.create({
ownerId: auth.user.id,
libraryId: null,
@ -215,6 +216,11 @@ export class AssetMediaService extends BaseService {
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
}
// clean up the asset row if one was created
if (asset) {
await this.assetRepository.remove({ id: asset.id });
}
this.logger.error(`Error uploading file ${error}`, error?.stack);
throw error;
}

View file

@ -54,6 +54,7 @@ describe(PartnerService.name, () => {
const auth = AuthFactory.create({ id: user1.id });
mocks.partner.get.mockResolvedValue(void 0);
mocks.user.get.mockResolvedValue(user2);
mocks.partner.create.mockResolvedValue(getForPartner(partner));
await expect(sut.create(auth, { sharedWithId: user2.id })).resolves.toBeDefined();
@ -76,6 +77,19 @@ describe(PartnerService.name, () => {
expect(mocks.partner.create).not.toHaveBeenCalled();
});
it('should throw an error when sharedWithId does not resolve to an existing (non-deleted) user', async () => {
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const auth = AuthFactory.create({ id: user1.id });
mocks.partner.get.mockResolvedValue(void 0);
mocks.user.get.mockResolvedValue(void 0);
await expect(sut.create(auth, { sharedWithId: user2.id })).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.partner.create).not.toHaveBeenCalled();
});
});
describe('remove', () => {

View file

@ -16,6 +16,12 @@ export class PartnerService extends BaseService {
throw new BadRequestException(`Partner already exists`);
}
const user = await this.userRepository.get(sharedWithId, {});
if (!user) {
this.logger.debug('Partner creation failed: user not found');
throw new BadRequestException('Invalid user');
}
const partner = await this.partnerRepository.create(partnerId);
return this.mapPartner(partner, PartnerDirection.SharedBy);
}

View file

@ -406,6 +406,101 @@ describe('core plugin', () => {
});
});
describe('assetFileFilter', () => {
it('should match assets case-insensitively', async () => {
const { user } = await ctx.newUser();
const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
ctx.newAsset({ ownerId: user.id, originalFileName: 'exampleFile.png' }),
ctx.newAsset({ ownerId: user.id, originalFileName: 'anotherfile.jpg' }),
]);
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [
{
method: 'immich-plugin-core#assetFileFilter',
config: { matchType: 'contains', pattern: 'File' },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id }),
).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id }),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
});
it('should match assets by regex', async () => {
const { user } = await ctx.newUser();
const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
ctx.newAsset({ ownerId: user.id, originalFileName: 'exampleFile.png' }),
ctx.newAsset({ ownerId: user.id, originalFileName: 'anotherfile.jpg' }),
]);
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [
{
method: 'immich-plugin-core#assetFileFilter',
config: { matchType: 'regex', pattern: '.+png' },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id }),
).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id }),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: false });
});
it('should filter assets by path if specified', async () => {
const { user } = await ctx.newUser();
const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
ctx.newAsset({ ownerId: user.id, originalPath: '/library/folder/file1.png' }),
ctx.newAsset({ ownerId: user.id, originalPath: '/library/file2.png' }),
]);
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [
{
method: 'immich-plugin-core#assetFileFilter',
config: { matchType: 'contains', pattern: 'folder', usePath: true },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id }),
).resolves.toBeUndefined();
await expect(
ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id }),
).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: false });
});
});
describe('assetTypeFilter', () => {
it('should favorite asset if it is a video', async () => {
const { user } = await ctx.newUser();

View file

@ -80,7 +80,7 @@
"@sveltejs/kit": "^2.56.1",
"@sveltejs/vite-plugin-svelte": "7.2.0",
"@tailwindcss/vite": "^4.2.4",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/svelte": "^5.2.8",
"@testing-library/user-event": "^14.5.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",

View file

@ -14,7 +14,7 @@
let { array, label, description, albumIds = $bindable([]) }: Props = $props();
const onAlbums = async () => {
const albums = await modalManager.show(AlbumPickerModal);
const albums = await modalManager.show(AlbumPickerModal, {});
if (!albums || albums.length === 0) {
return;
}

View file

@ -40,6 +40,9 @@
const albumNameArray: string[] = $derived.by(() => {
let { albumName } = album;
let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery));
if (findIndex === -1) {
return [albumName, '', ''];
}
let findLength = searchQuery.length;
return [
albumName.slice(0, findIndex),

View file

@ -108,6 +108,19 @@ describe('Album Modal', () => {
]);
});
it('search matches on description as well as name', () => {
const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Vacances 2019', description: 'Crete' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('Crete', [], [holidayAlbum, constructionAlbum], -1, []);
expect(modalRows).toStrictEqual([
createNewAlbumRow(false),
createSectionRow('ALBUMS'),
createAlbumRow(holidayAlbum, false),
]);
});
it('selection can select new album row', () => {
const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });

View file

@ -46,10 +46,14 @@ export class AlbumModalRowConverter {
const recentAlbumsToShow = search.length === 0 ? recentAlbums : [];
const rows: AlbumModalRow[] = [{ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 }];
const normalizedSearch = normalizeSearchString(search);
const filteredAlbums = sortAlbums(
search.length > 0 && albums.length > 0
? albums.filter((album) => {
return normalizeSearchString(album.albumName).includes(normalizeSearchString(search));
return (
normalizeSearchString(album.albumName).includes(normalizedSearch) ||
normalizeSearchString(album.description).includes(normalizedSearch)
);
})
: albums,
{ sortBy: this.sortBy, orderBy: this.orderBy },

View file

@ -0,0 +1,58 @@
import { render, screen, waitFor } from '@testing-library/svelte';
import { init, register, waitLocale } from 'svelte-i18n';
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
import AlbumPickerModal from './AlbumPickerModal.svelte';
describe('AlbumPickerModal component', () => {
const onClose = vi.fn();
beforeAll(async () => {
await init({ fallbackLocale: 'en-US' });
register('en-US', () => import('$i18n/en.json'));
await waitLocale('en-US');
});
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.stubGlobal('visualViewport', getVisualViewportMock());
vi.resetAllMocks();
Element.prototype.animate = getAnimateMock();
});
afterAll(async () => {
await waitFor(() => {
expect(document.body.style.pointerEvents).not.toBe('none');
});
});
it('shows the singular selection count title when selectedItemsCount is 1', async () => {
// Called by onMount()
sdkMock.getAllAlbums.mockResolvedValueOnce([]);
render(AlbumPickerModal, { props: { onClose, selectedItemsCount: 1 } });
expect(await screen.findByText('Add 1 item to album')).toBeInTheDocument();
expect(screen.queryByText('Select albums')).not.toBeInTheDocument();
});
it('shows the plural selection count title when selectedItemsCount is greater than 1', async () => {
sdkMock.getAllAlbums.mockResolvedValueOnce([]);
render(AlbumPickerModal, { props: { onClose, selectedItemsCount: 3 } });
expect(await screen.findByText('Add 3 items to album')).toBeInTheDocument();
expect(screen.queryByText('Select albums')).not.toBeInTheDocument();
});
it('shows the generic title when selectedItemsCount is not provided', async () => {
sdkMock.getAllAlbums.mockResolvedValueOnce([]);
render(AlbumPickerModal, { props: { onClose } });
expect(await screen.findByText('Select albums')).toBeInTheDocument();
expect(screen.queryByText('Add 1 item to album')).not.toBeInTheDocument();
});
});

View file

@ -23,9 +23,10 @@
type Props = {
onClose: (albums?: AlbumResponseDto[]) => void;
selectedItemsCount?: number;
};
let { onClose }: Props = $props();
let { onClose, selectedItemsCount }: Props = $props();
onMount(async () => {
albums = await getAllAlbums({});
@ -147,9 +148,15 @@
}
}
};
const title = $derived(
selectedItemsCount === undefined
? $t('select_albums')
: $t('add_to_album_item_count', { values: { count: selectedItemsCount } }),
);
</script>
<Modal title={$t('add_to_album')} {onClose} size="small">
<Modal {title} {onClose} size="small">
<ModalBody>
<div class="mb-2 flex max-h-100 flex-col">
{#if loading}

View file

@ -24,4 +24,4 @@
};
</script>
<AlbumPickerModal onClose={handleClose} />
<AlbumPickerModal selectedItemsCount={assetIds.length} onClose={handleClose} />

View file

@ -63,13 +63,16 @@
<div class="flex items-center justify-center gap-2 py-4 md:h-36">
{#if !choosePersonToMerge}
<div class="flex size-20 items-center px-1 md:size-24 md:px-2">
<ImageThumbnail
circle
shadow
url={getPeopleThumbnailUrl(personToMerge)}
altText={personToMerge.name}
widthStyle="100%"
/>
<!-- Trigger a re-render on person change as <Image> captures only the first src -->
{#key personToMerge.id}
<ImageThumbnail
circle
shadow
url={getPeopleThumbnailUrl(personToMerge)}
altText={personToMerge.name}
widthStyle="100%"
/>
{/key}
</div>
<div class="grid grid-rows-3">
@ -101,14 +104,16 @@
}
}}
>
<ImageThumbnail
border={potentialMergePeople.length > 0}
circle
shadow
url={getPeopleThumbnailUrl(personToBeMergedInto)}
altText={personToBeMergedInto.name}
widthStyle="100%"
/>
{#key personToBeMergedInto.id}
<ImageThumbnail
border={potentialMergePeople.length > 0}
circle
shadow
url={getPeopleThumbnailUrl(personToBeMergedInto)}
altText={personToBeMergedInto.name}
widthStyle="100%"
/>
{/key}
</button>
{:else}
<div class="grid w-full grid-cols-1 gap-2">

View file

@ -40,7 +40,10 @@
class:dark:border-immich-dark-primary={border}
class:border-immich-primary={border}
>
<ImageThumbnail {circle} url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" shadow />
<!-- Trigger a re-render on person change as <Image> captures only the first src -->
{#key person.id}
<ImageThumbnail {circle} url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" shadow />
{/key}
</div>
<div