refactor: delete confirm modal (#25135)

This commit is contained in:
Jason Rasmussen
2026-01-08 15:59:26 -05:00
committed by GitHub
parent 6997ed83c4
commit 471fab0591
8 changed files with 106 additions and 159 deletions

View File

@@ -7,6 +7,13 @@ import DeleteAction from './delete-action.svelte';
let asset: AssetResponseDto;
describe('DeleteAction component', () => {
beforeEach(() => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
});
describe('given an asset which is not trashed yet', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: false });

View File

@@ -1,15 +1,14 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { deleteAssets as deleteAssetsUtil, type OnUndoDelete } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
import { IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action';
@@ -23,24 +22,32 @@
let { asset, onAction, preAction, onUndoDelete = undefined }: Props = $props();
let showConfirmModal = $state(false);
const forceDefault = $derived(asset.isTrashed || !featureFlagsManager.value.trash);
const trashOrDelete = async (force = false) => {
if (force || !featureFlagsManager.value.trash) {
const trashOrDelete = async (forceRequest?: boolean) => {
const timelineAsset = toTimelineAsset(asset);
const force = forceDefault || forceRequest;
if (force) {
if ($showDeleteModal) {
showConfirmModal = true;
return;
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
if (!confirmed) {
return;
}
}
await deleteAsset();
try {
preAction({ type: AssetAction.DELETE, asset: timelineAsset });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset: timelineAsset });
toastManager.success($t('permanently_deleted_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}
return;
}
await trashAsset();
return;
};
const trashAsset = async () => {
const timelineAsset = toTimelineAsset(asset);
preAction({ type: AssetAction.TRASH, asset: timelineAsset });
await deleteAssetsUtil(
false,
@@ -49,24 +56,11 @@
onUndoDelete,
);
};
const deleteAsset = async () => {
try {
preAction({ type: AssetAction.DELETE, asset: toTimelineAsset(asset) });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset: toTimelineAsset(asset) });
toastManager.success($t('permanently_deleted_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
} finally {
showConfirmModal = false;
}
};
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete(asset.isTrashed) },
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete() },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
]}
/>
@@ -75,13 +69,7 @@
color="secondary"
shape="round"
variant="ghost"
icon={asset.isTrashed ? mdiDeleteForeverOutline : mdiDeleteOutline}
aria-label={asset.isTrashed ? $t('permanently_delete') : $t('delete')}
onclick={() => trashOrDelete(asset.isTrashed)}
icon={forceDefault ? mdiDeleteForeverOutline : mdiDeleteOutline}
aria-label={forceDefault ? $t('permanently_delete') : $t('delete')}
onclick={() => trashOrDelete()}
/>
{#if showConfirmModal}
<Portal target="body">
<DeleteAssetDialog size={1} onCancel={() => (showConfirmModal = false)} onConfirm={deleteAsset} />
</Portal>
{/if}

View File

@@ -32,8 +32,14 @@ describe('AssetViewerNavBar component', () => {
vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })),
);
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { smartSearch: true } } as any };
return {
featureFlagsManager: {
init: vi.fn(),
loadFeatureFlags: vi.fn(),
value: { trash: true, smartSearch: true },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
};
});
});

View File

@@ -7,6 +7,7 @@
import Portal from '$lib/elements/Portal.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -23,9 +24,8 @@
import { modalManager } from '@immich/ui';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
interface Props {
type Props = {
initialAssetId?: string;
assets: AssetResponseDto[];
assetInteraction: AssetInteraction;
@@ -34,7 +34,6 @@
viewport: Viewport;
onIntersected?: (() => void) | undefined;
showAssetName?: boolean;
isShowDeleteConfirmation?: boolean;
onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
@@ -42,7 +41,7 @@
pageHeaderOffset?: number;
slidingWindowOffset?: number;
arrowNavigation?: boolean;
}
};
let {
initialAssetId = undefined,
@@ -53,7 +52,6 @@
viewport,
onIntersected = undefined,
showAssetName = false,
isShowDeleteConfirmation = $bindable(false),
onPrevious = undefined,
onNext = undefined,
onRandom = undefined,
@@ -209,30 +207,27 @@
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const onForceDelete = () => {
if ($showDeleteModal) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(true));
};
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
const forceOrNoTrash = force || !featureFlagsManager.value.trash;
const selectedAssets = assetInteraction.selectedAssets;
if ($showDeleteModal && forceOrNoTrash) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: selectedAssets.length });
if (!confirmed) {
return;
}
}
await deleteAssets(
!(isTrashEnabled && !force),
forceOrNoTrash,
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
assetInteraction.selectedAssets,
selectedAssets,
onReload,
);
assetInteraction.clearMultiselect();
};
@@ -285,7 +280,7 @@
shortcuts.push(
{ shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets },
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
@@ -405,8 +400,6 @@
}
};
let isTrashEnabled = $derived(featureFlagsManager.value.trash);
$effect(() => {
if (!lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates();
@@ -440,14 +433,6 @@
onscroll={() => updateSlidingWindow()}
/>
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={assetInteraction.selectedAssets.length}
onCancel={() => (isShowDeleteConfirmation = false)}
onConfirm={() => handlePromiseError(trashOrDelete(true))}
/>
{/if}
{#if assets.length > 0}
<div
style:position="relative"

View File

@@ -46,7 +46,6 @@
album?: AlbumResponseDto;
albumUsers?: UserResponseDto[];
person?: PersonResponseDto;
isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void;
onEscape?: () => void;
children?: Snippet;
@@ -79,7 +78,6 @@
album,
albumUsers = [],
person,
isShowDeleteConfirmation = $bindable(false),
onSelect = () => {},
onEscape = () => {},
children,
@@ -600,7 +598,6 @@
scrollToAsset={(asset) => scrollToAsset(asset) ?? false}
{timelineManager}
{assetInteraction}
bind:isShowDeleteConfirmation
{onEscape}
/>

View File

@@ -1,55 +1,48 @@
<script lang="ts">
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions';
import { IconButton } from '@immich/ui';
import { IconButton, modalManager } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiDeleteOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
type Props = {
onAssetDelete: OnDelete;
onUndoDelete?: OnUndoDelete | undefined;
menuItem?: boolean;
force?: boolean;
}
};
let {
onAssetDelete,
onUndoDelete = undefined,
menuItem = false,
force = !featureFlagsManager.value.trash,
}: Props = $props();
let { onAssetDelete, onUndoDelete = undefined, menuItem = false, force: forceRequested }: Props = $props();
const force = $derived(forceRequested || !featureFlagsManager.value.trash);
let label = $derived(force ? $t('permanently_delete') : $t('delete'));
let loading = $state(false);
const { clearSelect, getOwnedAssets } = getAssetControlContext();
let isShowConfirmation = $state(false);
let loading = $state(false);
const onAction = async () => {
const assets = getOwnedAssets();
let label = $derived(force ? $t('permanently_delete') : $t('delete'));
const handleTrash = async () => {
if (force) {
isShowConfirmation = true;
return;
if (force && $showDeleteModal) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: assets.length });
if (!confirmed) {
return;
}
}
await handleDelete();
};
const handleDelete = async () => {
loading = true;
const assets = [...getOwnedAssets()];
await deleteAssets(force, onAssetDelete, assets, onUndoDelete);
clearSelect();
isShowConfirmation = false;
loading = false;
};
</script>
{#if menuItem}
<MenuOption text={label} icon={mdiDeleteOutline} onClick={handleTrash} />
<MenuOption text={label} icon={mdiDeleteOutline} onClick={onAction} />
{:else if loading}
<IconButton
shape="round"
@@ -66,14 +59,6 @@
variant="ghost"
aria-label={label}
icon={mdiDeleteForeverOutline}
onclick={handleTrash}
/>
{/if}
{#if isShowConfirmation}
<DeleteAssetDialog
size={getOwnedAssets().length}
onConfirm={handleDelete}
onCancel={() => (isShowConfirmation = false)}
onclick={onAction}
/>
{/if}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import {
setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
@@ -10,6 +9,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
@@ -22,53 +22,42 @@
import { AssetVisibility } from '@immich/sdk';
import { modalManager } from '@immich/ui';
interface Props {
type Props = {
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
isShowDeleteConfirmation: boolean;
onEscape?: () => void;
scrollToAsset: (asset: TimelineAsset) => boolean;
}
};
let {
timelineManager = $bindable(),
assetInteraction,
isShowDeleteConfirmation = $bindable(false),
onEscape,
scrollToAsset,
}: Props = $props();
let { timelineManager = $bindable(), assetInteraction, onEscape, scrollToAsset }: Props = $props();
const { isViewing: showAssetViewer } = assetViewingStore;
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
const trashOrDelete = async (forceRequested?: boolean) => {
const force = forceRequested || !featureFlagsManager.value.trash;
const selectedAssets = assetInteraction.selectedAssets;
if ($showDeleteModal && force) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: selectedAssets.length });
if (!confirmed) {
return;
}
}
await deleteAssets(
!(isTrashEnabled && !force),
force,
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
!isTrashEnabled || force ? undefined : (assets) => timelineManager.upsertAssets(assets),
selectedAssets,
force ? undefined : (assets) => timelineManager.upsertAssets(assets),
);
assetInteraction.clearMultiselect();
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const onForceDelete = () => {
if ($showDeleteModal) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(true));
};
const onStackAssets = async () => {
const result = await stackAssets(assetInteraction.selectedAssets);
@@ -118,9 +107,7 @@
}
};
const isTrashEnabled = $derived(featureFlagsManager.value.trash);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
@@ -176,7 +163,7 @@
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
@@ -189,11 +176,3 @@
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={idsSelectedAssets.length}
onCancel={() => (isShowDeleteConfirmation = false)}
onConfirm={() => handlePromiseError(trashOrDelete(true))}
/>
{/if}

View File

@@ -5,21 +5,21 @@
import { mdiDeleteForeverOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
size: number;
onConfirm: () => void;
onCancel: () => void;
}
onClose: (confirmed?: boolean) => void;
};
let { size, onConfirm, onCancel }: Props = $props();
let { size, onClose: onCloseParent }: Props = $props();
let checked = $state(false);
const handleConfirm = () => {
if (checked) {
const onClose = (confirmed: boolean) => {
if (confirmed && checked) {
$showDeleteModal = false;
}
onConfirm();
onCloseParent(confirmed);
};
</script>
@@ -27,7 +27,7 @@
title={$t('permanently_delete_assets_count', { values: { count: size } })}
confirmText={$t('delete')}
icon={mdiDeleteForeverOutline}
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
{onClose}
>
{#snippet promptSnippet()}
<p>