refactor: web mutation confirm (#33055)

(cherry picked from commit 511c3a9883458ae2e675ec82c3153d1e0620c45a)
This commit is contained in:
Jason Rasmussen authored and Codex committed 2026-11-07 21:31:45 +03:00
1 parent 55df2bb2bf
commit 499433a2c6
16 files changed
+131 -189

No files matched your search

@@ -312,20 +312,17 @@
return;
}
const isConfirmed = await modalManager.showDialog({
prompt: person.name ? $t('confirm_tag_face', { values: { name: person.name } }) : $t('confirm_tag_face_unnamed'),
});
if (!isConfirmed) {
return;
}
const onSuccess = async () => {
await assetViewerManager.setAssetId(assetId);
faceManager.clear();
};
await executeMutation(createFaceMutation, [{ assetFaceCreateDto: { assetId, personId: person.id, ...data } }], {
confirm: {
prompt: person.name
? $t('confirm_tag_face', { values: { name: person.name } })
: $t('confirm_tag_face_unnamed'),
},
onSuccess: () => void onSuccess(),
onSettled: onClose,
});
@@ -12,7 +12,7 @@
import { zoomImageToBase64 } from '#lib/utils/people-utils.ts';
import { getPersonNameWithHiddenValue } from '#lib/utils/person.ts';
import { AssetTypeEnum, createPerson, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager, toastManager } from '@immich/ui';
import { Icon, IconButton, LoadingSpinner, toastManager } from '@immich/ui';
import { mdiAccountOff, mdiArrowLeftThin, mdiArrowRightThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js';
import { createQuery } from '@tanstack/svelte-query';
import { t } from 'svelte-i18n';
@@ -151,14 +151,8 @@
return;
}
const isConfirmed = await modalManager.showDialog({
prompt: $t('confirm_delete_face', { values: { name: person.name } }),
});
if (!isConfirmed) {
return;
}
await executeMutation(deleteFaceMutation, [{ id: face.id, assetFaceDeleteDto: { force: false } }], {
confirm: { prompt: $t('confirm_delete_face', { values: { name: person.name } }) },
onSuccess: () => {
eventManager.emit('PersonAssetDelete', { id: person.id, assetId });
void assetViewerManager.setAssetId(assetId);
@@ -4,7 +4,7 @@
import { executeMutation } from '#lib/queries/client.ts';
import { removeSharedLinkAssetsMutation } from '#lib/queries/shared-link.query.ts';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { IconButton, modalManager, toastManager } from '@immich/ui';
import { IconButton, toastManager } from '@immich/ui';
import { mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -16,16 +16,12 @@
const onAction = async () => {
const assetIds = assetMultiSelectManager.assets.map(({ id }) => id);
const success = await modalManager.showDialog({
title: $t('remove_assets_title'),
prompt: $t('remove_assets_shared_link_confirmation', { values: { count: assetIds.length } }),
confirmText: $t('remove'),
});
if (!success) {
return;
}
await executeMutation(removeSharedLinkAssetsMutation, [{ id: sharedLink.id, assetIdsDto: { assetIds } }], {
confirm: {
title: $t('remove_assets_title'),
prompt: $t('remove_assets_shared_link_confirmation', { values: { count: assetIds.length } }),
confirmText: $t('remove'),
},
onSuccess: (results) => {
const removed = new Set(results.filter(({ success }) => success).map(({ assetId }) => assetId));
sharedLink.assets = sharedLink.assets.filter(({ id }) => !removed.has(id));
+34 -1
View File
@@ -1,8 +1,10 @@
import { queryClient } from '#lib/queries/client.ts';
import { executeMutation, queryClient } from '#lib/queries/client.ts';
import { getAdminConfigQuery } from '#lib/queries/config-admin.query.ts';
import { getPublicConfigQuery } from '#lib/queries/config-public.query.ts';
import { getUserConfigQuery } from '#lib/queries/config-user.query.ts';
import { getServerConfigQuery } from '#lib/queries/server-info.query.ts';
import { deleteTagMutation } from '#lib/queries/tag.query.ts';
import { factory } from '#test/unit/factories.ts';
import { test } from '#test/unit/fixtures.ts';
import { QueryObserver, type QueryKey, type QueryObserverOptions } from '@tanstack/svelte-query';
import { noop } from 'lodash-es';
@@ -50,3 +52,34 @@ describe('queryClient', () => {
});
});
});
describe('executeMutation', () => {
const confirm = { prompt: 'delete_tag_confirmation_prompt' };
test('runs the mutation once the confirm is accepted', async ({ data, api, app, settle }) => {
const tag = factory.tag();
data.addTags(tag);
const onSuccess = vi.fn<() => void>();
const result = executeMutation(deleteTagMutation, [{ id: tag.id }], { confirm, onSuccess });
await app.confirmButton.click();
await result;
await settle();
expect(api.deleteTag).toHaveBeenCalledWith({ params: { id: tag.id } });
expect(onSuccess).toHaveBeenCalled();
});
test('skips the mutation when the confirm is declined', async ({ data, api, app }) => {
const tag = factory.tag();
data.addTags(tag);
const onSuccess = vi.fn<() => void>();
const result = executeMutation(deleteTagMutation, [{ id: tag.id }], { confirm, onSuccess });
await app.cancelButton.click();
await result;
expect(api.deleteTag).not.toHaveBeenCalled();
expect(onSuccess).not.toHaveBeenCalled();
});
});
+19 -6
View File
@@ -3,7 +3,7 @@ import { getPublicConfigQuery } from '#lib/queries/config-public.query.ts';
import { getServerConfigQuery } from '#lib/queries/server-info.query.ts';
import { handleError } from '#lib/utils/handle-error.ts';
import { getFormatter } from '#lib/utils/i18n.ts';
import { toastManager } from '@immich/ui';
import { modalManager, toastManager } from '@immich/ui';
import {
MutationCache,
MutationObserver,
@@ -130,14 +130,27 @@ export const createPreloadedQuery = <TQueryFnData, TError, TData, TQueryKey exte
() => queryClient,
);
type MutationArgs<TData, TError, TVariables, TOnMutateResult> = TVariables extends void
? [variables?: TVariables, mutateOptions?: MutateOptions<TData, TError, TVariables, TOnMutateResult>]
: [variables: TVariables, mutateOptions?: MutateOptions<TData, TError, TVariables, TOnMutateResult>];
export type ConfirmOptions = Parameters<typeof modalManager.showDialog>[0];
export const executeMutation = <TData, TError, TVariables, TOnMutateResult>(
type ExecuteMutationOptions<TData, TError, TVariables, TOnMutateResult> = MutateOptions<
TData,
TError,
TVariables,
TOnMutateResult
> & { confirm?: ConfirmOptions | false };
type MutationArgs<TData, TError, TVariables, TOnMutateResult> = TVariables extends void
? [variables?: TVariables, mutateOptions?: ExecuteMutationOptions<TData, TError, TVariables, TOnMutateResult>]
: [variables: TVariables, mutateOptions?: ExecuteMutationOptions<TData, TError, TVariables, TOnMutateResult>];
export const executeMutation = async <TData, TError, TVariables, TOnMutateResult>(
options: () => CreateMutationOptions<TData, TError, TVariables, TOnMutateResult>,
...[variables, mutateOptions]: MutationArgs<TData, TError, TVariables, TOnMutateResult>
...[variables, { confirm, ...mutateOptions } = {}]: MutationArgs<TData, TError, TVariables, TOnMutateResult>
) => {
if (confirm && !(await modalManager.showDialog(confirm))) {
return;
}
const observer = new MutationObserver(queryClient, options());
const unsubscribe = observer.subscribe(noop);
return observer
+16 -32
View File
@@ -176,33 +176,20 @@ export const handleAddUsersToAlbum = (album: AlbumResponseDto, users: UserRespon
export const handleRemoveUserFromAlbum = async (album: AlbumResponseDto, albumUser: UserResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
title: $t('album_remove_user'),
prompt: $t('album_remove_user_confirmation', { values: { user: albumUser.name } }),
confirmText: $t('remove_user'),
await executeMutation(removeUserFromAlbumMutation, [{ id: album.id, userId: albumUser.id }], {
confirm: {
title: $t('album_remove_user'),
prompt: $t('album_remove_user_confirmation', { values: { user: albumUser.name } }),
confirmText: $t('remove_user'),
},
});
if (!confirmed) {
return;
}
await executeMutation(removeUserFromAlbumMutation, [{ id: album.id, userId: albumUser.id }]);
};
export const handleLeaveAlbum = async (album: AlbumResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
title: $t('leave_album'),
prompt: $t('are_you_sure_to_do_this'),
confirmText: $t('leave'),
});
if (!confirmed) {
return;
}
await executeMutation(removeUserFromAlbumMutation, [{ id: album.id, userId: 'me' }], {
confirm: { title: $t('leave_album'), prompt: $t('are_you_sure_to_do_this'), confirmText: $t('leave') },
onSuccess: () => leaveAlbumPage(album),
});
};
@@ -221,17 +208,11 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
const $t = await getFormatter();
const { prompt = true, notify = true } = options ?? {};
if (prompt) {
const confirmation =
album.albumName.length > 0
? $t('album_delete_confirmation', { values: { album: album.albumName } })
: $t('unnamed_album_delete_confirmation');
const description = $t('album_delete_confirmation_description');
const success = await modalManager.showDialog({ prompt: `${confirmation} ${description}` });
if (!success) {
return;
}
}
const confirmation =
album.albumName.length > 0
? $t('album_delete_confirmation', { values: { album: album.albumName } })
: $t('unnamed_album_delete_confirmation');
const description = $t('album_delete_confirmation_description');
await executeMutation(
() =>
@@ -239,7 +220,10 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
notify ? { success: { toast: true }, error: { toast: 'errors.unable_to_delete_album' } } : {},
),
[{ id: album.id }],
{ onSuccess: () => leaveAlbumPage(album) },
{
confirm: prompt && { prompt: `${confirmation} ${description}` },
onSuccess: () => leaveAlbumPage(album),
},
);
};
+2 -10
View File
@@ -44,12 +44,8 @@ export const getApiKeyActions = ($t: MessageFormatter, apiKey: ApiKeyResponseDto
const handleRotateApiKey = async (apiKey: ApiKeyResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('rotate_api_key_prompt') });
if (!confirmed) {
return;
}
await executeMutation(rotateApiKeyMutation, [{ id: apiKey.id }], {
confirm: { prompt: $t('rotate_api_key_prompt') },
onSuccess: ({ secret }) => void modalManager.show(ApiKeySecretModal, { secret }),
});
};
@@ -57,12 +53,8 @@ const handleRotateApiKey = async (apiKey: ApiKeyResponseDto) => {
const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') });
if (!confirmed) {
return;
}
await executeMutation(deleteApiKeyMutation, [{ id: apiKey.id }], {
confirm: { prompt: $t('delete_api_key_prompt') },
onSuccess: () => toastManager.primary($t('removed_api_key', { values: { name: apiKey.name } })),
});
};
+10 -11
View File
@@ -9,7 +9,7 @@ import AssetTagModal from '#lib/modals/AssetTagModal.svelte';
import ProfileImageCropperModal from '#lib/modals/ProfileImageCropperModal.svelte';
import SharedLinkCreateModal from '#lib/modals/SharedLinkCreateModal.svelte';
import { removeAssetFromAlbumMutation } from '#lib/queries/album.query.ts';
import { executeMutation, readPreloadedQuery } from '#lib/queries/client.ts';
import { executeMutation, readPreloadedQuery, type ConfirmOptions } from '#lib/queries/client.ts';
import { getUserConfigQuery } from '#lib/queries/config-user.query.ts';
import { Route } from '#lib/route.ts';
import { SlideshowState, slideshowStore } from '#lib/stores/slideshow.store.ts';
@@ -603,22 +603,21 @@ const undoArchiveAssets = async (assets: TimelineAsset[]) => {
const handleBulkRemoveAssetsFromAlbum = async (assetIds: string[], album: AlbumResponseDto) => {
const $t = await getFormatter();
const isConfirmed = await modalManager.showDialog({
prompt: $t('remove_assets_album_confirmation', { values: { count: assetIds.length } }),
await handleRemoveAssetsFromAlbum(assetIds, album, {
confirm: { prompt: $t('remove_assets_album_confirmation', { values: { count: assetIds.length } }) },
onSettled: () => assetMultiSelectManager.clear(),
});
if (!isConfirmed) {
return;
}
await handleRemoveAssetsFromAlbum(assetIds, album);
assetMultiSelectManager.clear();
};
const handleRemoveAssetsFromAlbum = async (assetIds: string[], album: AlbumResponseDto) => {
const handleRemoveAssetsFromAlbum = async (
assetIds: string[],
album: AlbumResponseDto,
options: { confirm?: ConfirmOptions | false; onSettled?: () => void } = {},
) => {
const $t = await getFormatter();
await executeMutation(removeAssetFromAlbumMutation, [{ id: album.id, bulkIdsDto: { ids: assetIds } }], {
...options,
onSuccess: (results) => {
const count = results.filter(({ success }) => success).length;
toastManager.primary($t('assets_removed_count', { values: { count } }));
+9 -25
View File
@@ -123,16 +123,10 @@ const handleDeleteLibrary = async (library: LibraryResponseDto) => {
return;
}
if (library.assetCount > 0) {
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_delete_library_assets', { values: { count: library.assetCount } }),
});
if (!isConfirmed) {
return;
}
}
await executeMutation(deleteLibraryMutation, [{ id: library.id }], {
confirm: library.assetCount > 0 && {
prompt: $t('admin.confirm_delete_library_assets', { values: { count: library.assetCount } }),
},
onSuccess: () => void goto(Route.libraries()),
});
};
@@ -140,27 +134,17 @@ const handleDeleteLibrary = async (library: LibraryResponseDto) => {
const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: string) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
prompt: $t('admin.library_remove_folder_prompt'),
confirmText: $t('remove'),
});
if (!confirmed) {
return;
}
const importPaths = library.importPaths.filter((path) => path !== folder);
await executeMutation(updateLibraryMutation, [{ id: library.id, updateLibraryDto: { importPaths } }]);
await executeMutation(updateLibraryMutation, [{ id: library.id, updateLibraryDto: { importPaths } }], {
confirm: { prompt: $t('admin.library_remove_folder_prompt'), confirmText: $t('remove') },
});
};
const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('admin.library_remove_exclusion_pattern_prompt') });
if (!confirmed) {
return;
}
const exclusionPatterns = library.exclusionPatterns.filter((pattern) => pattern !== exclusionPattern);
await executeMutation(updateLibraryMutation, [{ id: library.id, updateLibraryDto: { exclusionPatterns } }]);
await executeMutation(updateLibraryMutation, [{ id: library.id, updateLibraryDto: { exclusionPatterns } }], {
confirm: { prompt: $t('admin.library_remove_exclusion_pattern_prompt') },
});
};
+1 -5
View File
@@ -41,12 +41,8 @@ export const getPasskeyActions = ($t: MessageFormatter, passkey: PasskeyResponse
const handleDeletePasskey = async (passkey: PasskeyResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('delete_passkey_prompt') });
if (!confirmed) {
return;
}
await executeMutation(deletePasskeyMutation, [{ id: passkey.id }], {
confirm: { prompt: $t('delete_passkey_prompt') },
onSuccess: () =>
toastManager.primary(
$t('removed_passkey', { values: { name: passkey.name ?? $t('passkeys', { values: { count: 1 } }) } }),
+2 -9
View File
@@ -62,14 +62,7 @@ export const showSharedLinkQrCode = async (sharedLink: SharedLinkResponseDto) =>
const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto) => {
const $t = await getFormatter();
const success = await modalManager.showDialog({
title: $t('delete_shared_link'),
prompt: $t('confirm_delete_shared_link'),
confirmText: $t('delete'),
await executeMutation(removeSharedLinkMutation, [{ id: sharedLink.id }], {
confirm: { title: $t('delete_shared_link'), prompt: $t('confirm_delete_shared_link'), confirmText: $t('delete') },
});
if (!success) {
return;
}
await executeMutation(removeSharedLinkMutation, [{ id: sharedLink.id }]);
};
+5 -10
View File
@@ -33,17 +33,12 @@ export const getTagActions = ($t: MessageFormatter, tag: TreeNode) => {
return;
}
const confirmed = await modalManager.showDialog({
title: $t('delete_tag'),
prompt: $t('delete_tag_confirmation_prompt', { values: { tagName: tag.value } }),
confirmText: $t('delete'),
});
if (!confirmed) {
return;
}
await executeMutation(deleteTagMutation, [{ id: tagId }], {
confirm: {
title: $t('delete_tag'),
prompt: $t('delete_tag_confirmation_prompt', { values: { tagName: tag.value } }),
confirmText: $t('delete'),
},
onSuccess: () => void goto(Route.tags({ path: tag.parent?.path ?? '' })),
});
},
+7 -14
View File
@@ -112,27 +112,20 @@ const generatePassword = (length: number = 16) => {
const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => {
const $t = await getFormatter();
const prompt = $t('admin.confirm_user_password_reset', { values: { user: user.name } });
const success = await modalManager.showDialog({ prompt });
if (!success) {
return;
}
const password = generatePassword();
await executeMutation(
updateUserAdminMutation,
[{ id: user.id, userAdminUpdateDto: { password, shouldChangePassword: true } }],
{ onSuccess: () => void modalManager.show(PasswordResetSuccessModal, { newPassword: password }) },
{
confirm: { prompt: $t('admin.confirm_user_password_reset', { values: { user: user.name } }) },
onSuccess: () => void modalManager.show(PasswordResetSuccessModal, { newPassword: password }),
},
);
};
const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => {
const $t = await getFormatter();
const prompt = $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } });
const success = await modalManager.showDialog({ prompt });
if (!success) {
return;
}
await executeMutation(updateUserAdminMutation, [{ id: user.id, userAdminUpdateDto: { pinCode: null } }]);
await executeMutation(updateUserAdminMutation, [{ id: user.id, userAdminUpdateDto: { pinCode: null } }], {
confirm: { prompt: $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }) },
});
};
+1 -9
View File
@@ -130,16 +130,8 @@ export const getWorkflowShowSchemaAction = (
const handleDeleteWorkflow = async (workflow: WorkflowResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
prompt: $t('workflow_delete_prompt'),
confirmColor: 'danger',
});
if (!confirmed) {
return;
}
await executeMutation(deleteWorkflowMutation, [{ id: workflow.id }], {
confirm: { prompt: $t('workflow_delete_prompt'), confirmColor: 'danger' },
onSuccess: () => {
if (page.params.workflowId === workflow.id) {
void goto(Route.workflows());
@@ -3,7 +3,7 @@
import { createPreloadedQuery, executeMutation } from '#lib/queries/client.ts';
import { deleteAllSessionsMutation, deleteSessionMutation, getSessionsQuery } from '#lib/queries/session.query.ts';
import { type SessionResponseDto } from '@immich/sdk';
import { Button, Text, modalManager } from '@immich/ui';
import { Button, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -13,23 +13,15 @@
let currentSession = $derived(devices.find((device) => device.current));
let otherSessions = $derived(devices.filter((device) => !device.current));
const handleDelete = async (device: SessionResponseDto) => {
const isConfirmed = await modalManager.showDialog({ prompt: $t('logout_this_device_confirmation') });
if (!isConfirmed) {
return;
}
const handleDelete = (device: SessionResponseDto) =>
executeMutation(deleteSessionMutation, [{ id: device.id }], {
confirm: { prompt: $t('logout_this_device_confirmation') },
});
await executeMutation(deleteSessionMutation, [{ id: device.id }]);
};
const handleDeleteAll = async () => {
const isConfirmed = await modalManager.showDialog({ prompt: $t('logout_all_device_confirmation') });
if (!isConfirmed) {
return;
}
await executeMutation(deleteAllSessionsMutation);
};
const handleDeleteAll = () =>
executeMutation(deleteAllSessionsMutation, undefined, {
confirm: { prompt: $t('logout_all_device_confirmation') },
});
</script>
<section class="my-4">
+3 -14
View File
@@ -4,7 +4,7 @@
import { runQueueCommandLegacyMutation } from '#lib/queries/queue.query.ts';
import { asQueueItem } from '#lib/services/queue.service.ts';
import { QueueCommand, QueueName, type QueueCommandDto, type QueueResponseDto } from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import { toastManager } from '@immich/ui';
import type { Component } from 'svelte';
import { t } from 'svelte-i18n';
import QueueCard from './QueueCard.svelte';
@@ -87,21 +87,10 @@
const handleCommand = async (name: QueueName, dto: QueueCommandDto) => {
const item = asQueueItem($t, { name });
switch (name) {
case QueueName.FaceDetection:
case QueueName.FacialRecognition: {
if (dto.force) {
const confirmed = await modalManager.showDialog({ prompt: $t('admin.confirm_reprocess_all_faces') });
if (!confirmed) {
return;
}
break;
}
}
// no default
}
const reprocessesAllFaces = dto.force && (name === QueueName.FaceDetection || name === QueueName.FacialRecognition);
await executeMutation(runQueueCommandLegacyMutation, [{ name, queueCommandDto: dto }], {
confirm: reprocessesAllFaces && { prompt: $t('admin.confirm_reprocess_all_faces') },
onSuccess: () => {
if (dto.command === QueueCommand.Empty) {
toastManager.primary($t('admin.cleared_jobs', { values: { job: item.title } }));