diff --git a/static/css/components/toast.css b/static/css/components/toast.css index 229d77c4..fbf0732f 100644 --- a/static/css/components/toast.css +++ b/static/css/components/toast.css @@ -80,6 +80,33 @@ margin-top: 10px; } +/* Action toast: ghost action button + countdown (e.g. Undo delete) */ +.toast-action-btn { + margin-left: auto; + flex-shrink: 0; + padding: 4px 12px; + background: transparent; + color: var(--lora-accent); + border: 1px solid var(--lora-accent); + border-radius: var(--border-radius-sm); + font-size: 0.85em; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; +} + +.toast-action-btn:hover { + background: var(--lora-accent); + color: #fff; +} + +.toast-countdown { + flex-shrink: 0; + font-size: 0.75em; + opacity: 0.65; + white-space: nowrap; +} + /* Responsive adjustments */ @media (max-width: 768px) { .toast { diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index b119b950..e9f561b2 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -201,8 +201,13 @@ export class BaseModelApiClient { if (state.virtualScroller) { state.virtualScroller.removeItemByFilePath(filePath); } - showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success'); - return true; + const batchId = data.batch_id || null; + if (!batchId) { + // Not staged (undo disabled or staging failed): keep the legacy toast. + // When staged, the caller shows the undo action toast instead. + showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success'); + } + return { success: true, batch_id: batchId }; } else { throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`); } @@ -1622,9 +1627,14 @@ export class BaseModelApiClient { if (result.success) { return { success: true, - deleted_count: result.deleted_count, + deleted_count: result.deleted_count ?? result.total_deleted, failed_count: result.failed_count || 0, - errors: result.errors || [] + errors: result.errors || [], + // Undo batch fields — batch_id on merge success, batch_ids + // array on merge failure (same success dict for the + // status='cancelled' staged-subset path) + batch_id: result.batch_id || null, + batch_ids: result.batch_ids || null }; } else { throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`); diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index 70a2951e..215838c9 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -657,6 +657,10 @@ export class RecipeSidebarApiClient { deleted_count: result.total_deleted, failed_count: result.total_failed || 0, errors: result.failed || [], + // Undo batch fields — batch_id on merge success, batch_ids + // array on merge failure + batch_id: result.batch_id || null, + batch_ids: result.batch_ids || null, }; } finally { state.loadingManager?.hide(); diff --git a/static/js/components/DuplicatesManager.js b/static/js/components/DuplicatesManager.js index b17e124f..32acc2fb 100644 --- a/static/js/components/DuplicatesManager.js +++ b/static/js/components/DuplicatesManager.js @@ -1,5 +1,7 @@ // Duplicates Manager Component -import { showToast } from '../utils/uiHelpers.js'; +import { showToast, showActionToast } from '../utils/uiHelpers.js'; +import { handleUndoDelete } from '../utils/undoHelpers.js'; +import { armDeleteButton } from '../utils/modalUtils.js'; import { translate } from '../utils/i18nHelpers.js'; import { RecipeCard } from './RecipeCard.js'; import { state, getCurrentPageState } from '../state/index.js'; @@ -447,6 +449,7 @@ export class DuplicatesManager { // Use the modal manager to show the confirmation modal modalManager.showModal('duplicateDeleteModal'); + armDeleteButton(document.getElementById('duplicateDeleteModal')); } catch (error) { console.error('Error preparing delete:', error); showToast('toast.duplicates.deleteError', { message: error.message }, 'error'); @@ -479,8 +482,34 @@ export class DuplicatesManager { if (!data.success) { throw new Error(data.error || 'Unknown error deleting recipes'); } - - showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success'); + + const batchIds = !data.batch_id && Array.isArray(data.batch_ids) && data.batch_ids.length + ? data.batch_ids + : null; + + if (data.batch_id || batchIds) { + // One undo action restores the whole selected group + const refreshFn = () => window.recipeManager.loadRecipes(true); + const onAction = data.batch_id + ? () => handleUndoDelete(data.batch_id, refreshFn) + : async () => { + for (const id of batchIds) { + const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false }); + if (!succeeded) { + showToast('toast.undo.failed', { error: '' }, 'error'); + return; + } + } + refreshFn(); + showToast('toast.undo.restored', {}, 'success'); + }; + showActionToast('toast.undo.deletedBulk', { count: data.total_deleted }, 'success', { + actionText: translate('toast.undo.action'), + onAction, + }); + } else { + showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success'); + } // Exit duplicate mode if deletions were successful if (data.total_deleted > 0) { diff --git a/static/js/components/ModelDuplicatesManager.js b/static/js/components/ModelDuplicatesManager.js index 7c0f7ee2..ebe21a7e 100644 --- a/static/js/components/ModelDuplicatesManager.js +++ b/static/js/components/ModelDuplicatesManager.js @@ -1,5 +1,8 @@ // Model Duplicates Manager Component for LoRAs and Checkpoints -import { showToast } from '../utils/uiHelpers.js'; +import { showToast, showActionToast } from '../utils/uiHelpers.js'; +import { handleUndoDelete } from '../utils/undoHelpers.js'; +import { armDeleteButton } from '../utils/modalUtils.js'; +import { translate } from '../utils/i18nHelpers.js'; import { state, getCurrentPageState } from '../state/index.js'; import { formatDate } from '../utils/formatters.js'; import { resetAndReload} from '../api/modelApiFactory.js'; @@ -700,6 +703,7 @@ export class ModelDuplicatesManager { // Use the modal manager to show the confirmation modal modalManager.showModal('modelDuplicateDeleteModal'); + armDeleteButton(document.getElementById('modelDuplicateDeleteModal')); } catch (error) { console.error('Error preparing delete:', error); showToast('toast.duplicates.deleteError', { message: error.message }, 'error'); @@ -732,8 +736,34 @@ export class ModelDuplicatesManager { if (!data.success) { throw new Error(data.error || 'Unknown error deleting models'); } - - showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success'); + + const batchIds = !data.batch_id && Array.isArray(data.batch_ids) && data.batch_ids.length + ? data.batch_ids + : null; + + if (data.batch_id || batchIds) { + // One undo action restores the whole selected group + const refreshFn = () => resetAndReload(true); + const onAction = data.batch_id + ? () => handleUndoDelete(data.batch_id, refreshFn) + : async () => { + for (const id of batchIds) { + const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false }); + if (!succeeded) { + showToast('toast.undo.failed', { error: '' }, 'error'); + return; + } + } + refreshFn(); + showToast('toast.undo.restored', {}, 'success'); + }; + showActionToast('toast.undo.deletedBulk', { count: data.total_deleted }, 'success', { + actionText: translate('toast.undo.action'), + onAction, + }); + } else { + showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success'); + } // If models were successfully deleted if (data.total_deleted > 0) { diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index cd1617bf..780c861d 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -1,5 +1,5 @@ // Recipe Card Component -import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js'; +import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js'; import { updateRecipeMetadata } from '../api/recipeApi.js'; import { configureModelCardVideo } from './shared/ModelCard.js'; import { modalManager } from '../managers/ModalManager.js'; @@ -7,6 +7,9 @@ import { getCurrentPageState } from '../state/index.js'; import { state } from '../state/index.js'; import { bulkManager } from '../managers/BulkManager.js'; import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js'; +import { translate } from '../utils/i18nHelpers.js'; +import { handleUndoDelete } from '../utils/undoHelpers.js'; +import { armDeleteButton } from '../utils/modalUtils.js'; class RecipeCard { constructor(recipe, clickHandler) { @@ -375,8 +378,13 @@ class RecipeCard { `; // Show the modal with custom content and setup callbacks + let deleteArmTimer = null; modalManager.showModal('deleteModal', deleteModalContent, () => { // This is the onClose callback + if (deleteArmTimer) { + clearTimeout(deleteArmTimer); + deleteArmTimer = null; + } const deleteModal = document.getElementById('deleteModal'); const deleteBtn = deleteModal.querySelector('.delete-btn'); deleteBtn.textContent = 'Delete'; @@ -396,6 +404,8 @@ class RecipeCard { cancelBtn.onclick = () => modalManager.closeModal('deleteModal'); deleteBtn.onclick = () => this.confirmDeleteRecipe(); + deleteArmTimer = armDeleteButton(deleteModal); + } catch (error) { console.error('Error showing delete confirmation:', error); showToast('toast.recipes.deleteConfirmationError', {}, 'error'); @@ -432,7 +442,16 @@ class RecipeCard { return response.json(); }) .then(data => { - showToast('toast.recipes.deletedSuccessfully', {}, 'success'); + if (data.batch_id) { + // Staged delete: offer undo instead of the plain success toast + const batchId = data.batch_id; + showActionToast('toast.undo.deleted', { name: this.recipe.title }, 'success', { + actionText: translate('toast.undo.action'), + onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)), + }); + } else { + showToast('toast.recipes.deletedSuccessfully', {}, 'success'); + } state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath); diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 12e2dc8f..ca9c94fe 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -1,5 +1,7 @@ import { state, getCurrentPageState } from '../state/index.js'; -import { showToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js'; +import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js'; +import { handleUndoDelete } from '../utils/undoHelpers.js'; +import { armDeleteButton } from '../utils/modalUtils.js'; import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { modalManager } from './ModalManager.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; @@ -628,6 +630,7 @@ export class BulkManager { } modalManager.showModal('bulkDeleteModal'); + armDeleteButton(document.getElementById('bulkDeleteModal')); } async confirmBulkDelete() { @@ -649,10 +652,38 @@ export class BulkManager { showToast('toast.api.operationCancelled', {}, 'info'); } else if (result.success) { const currentConfig = this.getCurrentDisplayConfig(); - showToast('toast.models.deletedSuccessfully', { - count: result.deleted_count, - type: currentConfig.displayName.toLowerCase() - }, 'success'); + const isRecipes = state.currentPageType === 'recipes'; + const refreshFn = isRecipes + ? () => window.recipeManager.loadRecipes(true) + : () => resetAndReload(true); + + if (result.batch_id || (result.batch_ids && result.batch_ids.length)) { + // One undo action for the whole bulk action — the backend + // merges staged per-file batches into a single batch, with + // a batch_ids fallback array when the merge failed + const onAction = result.batch_id + ? () => handleUndoDelete(result.batch_id, refreshFn) + : async () => { + for (const id of result.batch_ids) { + const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false }); + if (!succeeded) { + showToast('toast.undo.failed', { error: '' }, 'error'); + return; + } + } + refreshFn(); + showToast('toast.undo.restored', {}, 'success'); + }; + showActionToast('toast.undo.deletedBulk', { count: result.deleted_count }, 'success', { + actionText: translate('toast.undo.action'), + onAction, + }); + } else { + showToast('toast.models.deletedSuccessfully', { + count: result.deleted_count, + type: currentConfig.displayName.toLowerCase() + }, 'success'); + } filePaths.forEach(path => { state.virtualScroller.removeItemByFilePath(path); diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index fe336664..f3b2203c 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -434,6 +434,22 @@ export class ModalManager { this.currentOpenModal = id; // Update currently open modal document.body.style.top = `-${this.scrollPosition}px`; document.body.classList.add('modal-open'); + + modal.restoreFocusTo = null; + if (this._isDeleteConfirmModal(modal.element)) { + const activeElement = document.activeElement; + modal.restoreFocusTo = activeElement && activeElement !== document.body + ? activeElement + : null; + modal.element.querySelector('.cancel-btn')?.focus(); + } + } + + // Several non-delete modals share the delete-modal styling class, so an + // actual .delete-btn is required before focus is moved to Cancel. + _isDeleteConfirmModal(element) { + return element.classList.contains('delete-modal') && + Boolean(element.querySelector('.delete-btn')); } closeModal(id) { @@ -463,6 +479,13 @@ export class ModalManager { modal.cleanupCallback(); modal.cleanupCallback = null; } + + if (modal.restoreFocusTo) { + if (modal.restoreFocusTo.isConnected) { + modal.restoreFocusTo.focus(); + } + modal.restoreFocusTo = null; + } } handleEscape(e) { diff --git a/static/js/utils/modalUtils.js b/static/js/utils/modalUtils.js index e7e49e2e..0b7377ae 100644 --- a/static/js/utils/modalUtils.js +++ b/static/js/utils/modalUtils.js @@ -1,37 +1,84 @@ import { modalManager } from '../managers/ModalManager.js'; -import { getModelApiClient } from '../api/modelApiFactory.js'; +import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; +import { showActionToast } from './uiHelpers.js'; +import { translate } from './i18nHelpers.js'; +import { handleUndoDelete } from './undoHelpers.js'; +import { state } from '../state/index.js'; +import { formatFileSize } from '../components/shared/utils.js'; + +const DELETE_BUTTON_ARM_DELAY_MS = 1500; let pendingDeletePath = null; +let pendingDeleteName = null; let pendingExcludePath = null; +let pendingDeleteArmTimer = null; + +// Delay-activates every delete button inside a delete-confirmation modal so a +// misclick in the first moments after opening cannot confirm the deletion. +// Returns the pending timeout id so callers can cancel it when the modal closes early. +export function armDeleteButton(modalElement, delayMs = DELETE_BUTTON_ARM_DELAY_MS) { + if (!modalElement) return null; + + const deleteButtons = modalElement.querySelectorAll('.delete-btn'); + if (!deleteButtons.length) return null; + + deleteButtons.forEach((button) => { button.disabled = true; }); + + return setTimeout(() => { + deleteButtons.forEach((button) => { button.disabled = false; }); + }, delayMs); +} export function showDeleteModal(filePath) { pendingDeletePath = filePath; - + const escapedPath = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape(filePath) : filePath.replace(/["\\]/g, '\\$&'); const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`); const modelName = card ? card.dataset.name : filePath.split('/').pop(); + pendingDeleteName = modelName; const modal = modalManager.getModal('deleteModal').element; const modelInfo = modal.querySelector('.delete-model-info'); - + + const undoEnabled = state.global?.settings?.delete_undo_enabled; + const warningKey = undoEnabled + ? 'modals.deleteModel.recoverableWarning' + : 'modals.deleteModel.permanentWarning'; + const fileSize = card?.dataset.file_size; + const sizeLine = fileSize + ? `
${translate('modals.deleteModel.freesSpace', { size: formatFileSize(parseInt(fileSize, 10)) })}` + : ''; + modelInfo.innerHTML = ` Model: ${modelName}
File: ${filePath} +
+ ${translate(warningKey)}${sizeLine} `; - + modalManager.showModal('deleteModal'); + pendingDeleteArmTimer = armDeleteButton(modal); } export async function confirmDelete() { if (!pendingDeletePath) return; - + try { - await getModelApiClient().deleteModel(pendingDeletePath); - + const modelName = pendingDeleteName; + const result = await getModelApiClient().deleteModel(pendingDeletePath); + closeDeleteModal(); + if (result?.batch_id) { + const batchId = result.batch_id; + showActionToast('toast.undo.deleted', { name: modelName }, 'success', { + actionText: translate('toast.undo.action'), + onAction: () => handleUndoDelete(batchId, () => resetAndReload(true)), + }); + } + if (window.modelDuplicatesManager) { window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh(); } @@ -43,7 +90,12 @@ export async function confirmDelete() { export function closeDeleteModal() { modalManager.closeModal('deleteModal'); + if (pendingDeleteArmTimer) { + clearTimeout(pendingDeleteArmTimer); + pendingDeleteArmTimer = null; + } pendingDeletePath = null; + pendingDeleteName = null; } // Functions for the exclude modal diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index 1cacd9ab..e944af4e 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -133,15 +133,28 @@ export async function copyToClipboard(text, successMessage = null) { } } -export function showToast(key, params = {}, type = 'info', fallback = null) { - // Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim - // to avoid spurious "Translation key not found" warnings from i18next - const isPlainMessage = typeof key === 'string' && /\s/.test(key); - const message = isPlainMessage ? key : translate(key, params, fallback); +/** + * Build a toast element (internal — not exported). + * @param {string} message - Already-resolved message text + * @param {string} type - Toast type (info/success/warning/error) + * @returns {HTMLElement} The toast element (not yet attached to the DOM) + */ +function createToastElement(message, type) { const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; + return toast; +} +/** + * Attach a toast to the shared container, position it, and schedule its + * dismissal (internal — not exported). + * @param {HTMLElement} toast - The toast element to display + * @param {number} durationMs - How long the toast stays visible + * @param {Function} [onDismiss] - Optional callback fired once when dismissal begins + * @returns {Function} Manual dismiss function (idempotent) + */ +function appendToast(toast, durationMs, onDismiss = null) { // Get or create toast container let toastContainer = document.querySelector('.toast-container'); if (!toastContainer) { @@ -161,35 +174,127 @@ export function showToast(key, params = {}, type = 'info', fallback = null) { // Set position based on existing toasts toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`; - requestAnimationFrame(() => { - toast.classList.add('show'); + let dismissed = false; + const dismiss = () => { + if (dismissed) return; + dismissed = true; - // Set timeout based on type - let timeout = 2000; // Default (info) - if (type === 'warning' || type === 'error') { - timeout = 5000; + if (typeof onDismiss === 'function') { + onDismiss(); } - setTimeout(() => { - toast.classList.remove('show'); - toast.addEventListener('transitionend', () => { - toast.remove(); + toast.classList.remove('show'); + toast.addEventListener('transitionend', () => { + toast.remove(); - // Reposition remaining toasts - if (toastContainer) { - const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast')); - remainingToasts.forEach((t, index) => { - t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`; - }); + // Reposition remaining toasts + if (toastContainer) { + const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast')); + remainingToasts.forEach((t, index) => { + t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`; + }); - // Remove container if empty - if (remainingToasts.length === 0) { - toastContainer.remove(); - } + // Remove container if empty + if (remainingToasts.length === 0) { + toastContainer.remove(); } - }); - }, timeout); + } + }); + }; + + requestAnimationFrame(() => { + toast.classList.add('show'); + setTimeout(dismiss, durationMs); }); + + return dismiss; +} + +export function showToast(key, params = {}, type = 'info', fallback = null) { + // Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim + // to avoid spurious "Translation key not found" warnings from i18next + const isPlainMessage = typeof key === 'string' && /\s/.test(key); + const message = isPlainMessage ? key : translate(key, params, fallback); + const toast = createToastElement(message, type); + + // Set timeout based on type + let duration = 2000; // Default (info) + if (type === 'warning' || type === 'error') { + duration = 5000; + } + + appendToast(toast, duration); +} + +/** + * Show a toast with an action button (e.g. Undo) and an optional countdown. + * The message accepts the same key/plain-string contract as showToast, so + * callers may pass either an i18n key or an already-translated string. + * @param {string} key - i18n key or plain message + * @param {Object} [params] - i18n interpolation params + * @param {string} [type] - Toast type (info/success/warning/error) + * @param {Object} [options] + * @param {string} [options.actionText] - Label for the action button (button omitted when empty) + * @param {Function} [options.onAction] - Callback invoked at most once on button click + * @param {number} [options.durationMs=30000] - How long the toast stays visible + * @param {boolean} [options.countdown=true] - Show a ticking `(N)s` countdown + */ +export function showActionToast(key, params = {}, type = 'info', options = {}) { + const { actionText, onAction, durationMs = 30000, countdown = true } = options; + + const isPlainMessage = typeof key === 'string' && /\s/.test(key); + const message = isPlainMessage ? key : translate(key, params); + const toast = createToastElement(message, type); + + let countdownInterval = null; + const clearCountdown = () => { + if (countdownInterval !== null) { + clearInterval(countdownInterval); + countdownInterval = null; + } + }; + + // The interval must be cleared on EVERY dismiss path (timeout, countdown end, + // manual button click) — the onDismiss hook covers the appendToast timeout path. + const dismiss = appendToast(toast, durationMs, clearCountdown); + + let actionFired = false; + if (actionText) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'toast-action-btn'; + button.textContent = actionText; + button.addEventListener('click', (event) => { + event.preventDefault(); + // Guard against double-click firing the action twice + if (actionFired) return; + actionFired = true; + + clearCountdown(); + if (typeof onAction === 'function') { + onAction(); + } + dismiss(); + }); + toast.append(button); + } + + if (countdown) { + const countdownEl = document.createElement('span'); + countdownEl.className = 'toast-countdown'; + let remainingSeconds = Math.max(0, Math.ceil(durationMs / 1000)); + countdownEl.textContent = `(${remainingSeconds}s)`; + toast.append(countdownEl); + + countdownInterval = setInterval(() => { + remainingSeconds -= 1; + countdownEl.textContent = `(${Math.max(remainingSeconds, 0)}s)`; + if (remainingSeconds <= 0) { + clearCountdown(); + dismiss(); + } + }, 1000); + } } export function restoreFolderFilter() { diff --git a/static/js/utils/undoHelpers.js b/static/js/utils/undoHelpers.js new file mode 100644 index 00000000..70a2b23e --- /dev/null +++ b/static/js/utils/undoHelpers.js @@ -0,0 +1,55 @@ +import { showToast } from './uiHelpers.js'; + +/** + * Undo a staged delete batch via the pending-delete endpoint. + * @param {string} batchId - The batch id returned by a staged delete response + * @param {Function|null} refreshFn - Called once after a successful restore (unless options.refresh is false) + * @param {Object} [options] + * @param {boolean} [options.showToast=true] - Suppress toasts (used by sequential multi-batch undo loops) + * @param {boolean} [options.refresh=true] - Suppress the refresh call (used by sequential multi-batch undo loops) + * @returns {Promise} Whether the undo succeeded + */ +export async function handleUndoDelete(batchId, refreshFn, options = {}) { + const { showToast: showToastEnabled = true, refresh: refreshEnabled = true } = options; + + try { + const response = await fetch('/api/lm/undo-delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ batch_id: batchId }), + }); + + if (response.ok) { + if (refreshEnabled && typeof refreshFn === 'function') { + refreshFn(); + } + if (showToastEnabled) { + showToast('toast.undo.restored', {}, 'success'); + } + return true; + } + + // Read the error body to distinguish an expired batch from other failures + let errorMessage = ''; + try { + const body = await response.json(); + errorMessage = body?.error || ''; + } catch { + errorMessage = ''; + } + + if (showToastEnabled) { + if (response.status === 404 && errorMessage.toLowerCase().includes('expired')) { + showToast('toast.undo.expired', {}, 'error'); + } else { + showToast('toast.undo.failed', { error: errorMessage || response.statusText }, 'error'); + } + } + return false; + } catch (error) { + if (showToastEnabled) { + showToast('toast.undo.failed', { error: error.message }, 'error'); + } + return false; + } +} diff --git a/tests/frontend/api/baseModelApi.bulkDelete.test.js b/tests/frontend/api/baseModelApi.bulkDelete.test.js new file mode 100644 index 00000000..ff267215 --- /dev/null +++ b/tests/frontend/api/baseModelApi.bulkDelete.test.js @@ -0,0 +1,201 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + BASE_MODEL_API_MODULE, + STATE_MODULE, + UI_HELPERS_MODULE, + I18N_MODULE, + STORAGE_MODULE, + API_CONFIG_MODULE, + API_FACTORY_MODULE, + SIDEBAR_MANAGER_MODULE, +} = vi.hoisted(() => ({ + BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname, + API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname, + API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, + SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname, +})); + +const showToastMock = vi.fn(); +const showSimpleLoadingMock = vi.fn(); +const showCancelButtonMock = vi.fn(); +const hideLoadingMock = vi.fn(); + +vi.mock(STATE_MODULE, () => ({ + state: { + loadingManager: { + showSimpleLoading: showSimpleLoadingMock, + showCancelButton: showCancelButtonMock, + hide: hideLoadingMock, + }, + virtualScroller: { + removeItemByFilePath: vi.fn(), + }, + }, + getCurrentPageState: vi.fn(() => ({})), +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: showToastMock, +})); + +vi.mock(I18N_MODULE, () => ({ + translate: vi.fn((key) => key), +})); + +vi.mock(STORAGE_MODULE, () => ({ + getStorageItem: vi.fn(), + getSessionItem: vi.fn(), + removeSessionItem: vi.fn(), + saveMapToStorage: vi.fn(), +})); + +vi.mock(API_CONFIG_MODULE, () => ({ + getCompleteApiConfig: vi.fn(() => ({ + endpoints: { bulkDelete: '/api/lm/loras/bulk-delete' }, + config: { displayName: 'LoRA', singularName: 'LoRA' }, + })), + getCurrentModelType: vi.fn(() => 'loras'), + isValidModelType: vi.fn(() => true), + DOWNLOAD_ENDPOINTS: {}, + HF_ENDPOINTS: {}, + WS_ENDPOINTS: {}, +})); + +vi.mock(API_FACTORY_MODULE, () => ({ + resetAndReload: vi.fn(), +})); + +vi.mock(SIDEBAR_MANAGER_MODULE, () => ({ + sidebarManager: { refresh: vi.fn() }, +})); + +describe('BaseModelApiClient.bulkDeleteModels undo contract', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + delete global.fetch; + }); + + async function createClient() { + const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE); + class TestClient extends BaseModelApiClient {} + return new TestClient('loras'); + } + + function mockBulkDeleteResponse(payload) { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => payload, + }); + } + + it('posts the file paths and defaults both batch fields to null', async () => { + mockBulkDeleteResponse({ + success: true, + status: 'success', + total_deleted: 3, + total_attempted: 3, + cache_updated: true, + results: [], + }); + + const client = await createClient(); + const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/lm/loras/bulk-delete', + expect.objectContaining({ method: 'POST' }) + ); + expect(result).toEqual({ + success: true, + deleted_count: 3, + failed_count: 0, + errors: [], + batch_id: null, + batch_ids: null, + }); + expect(hideLoadingMock).toHaveBeenCalledTimes(1); + }); + + it('passes through the merged batch_id when the backend staged the bulk delete', async () => { + mockBulkDeleteResponse({ + success: true, + status: 'success', + total_deleted: 2, + total_attempted: 2, + cache_updated: true, + results: [], + batch_id: 'merged-batch-1', + }); + + const client = await createClient(); + const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']); + + expect(result.batch_id).toBe('merged-batch-1'); + expect(result.batch_ids).toBeNull(); + }); + + it('passes through the batch_ids fallback array when the merge failed', async () => { + mockBulkDeleteResponse({ + success: true, + status: 'success', + total_deleted: 2, + total_attempted: 2, + cache_updated: true, + results: [], + batch_ids: ['batch-1', 'batch-2'], + }); + + const client = await createClient(); + const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']); + + expect(result.batch_id).toBeNull(); + expect(result.batch_ids).toEqual(['batch-1', 'batch-2']); + }); + + it('keeps the batch field on the cancelled-status path (staged subset is undoable)', async () => { + mockBulkDeleteResponse({ + success: true, + status: 'cancelled', + total_deleted: 1, + total_attempted: 2, + cache_updated: true, + results: [], + batch_id: 'partial-batch', + }); + + const client = await createClient(); + const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']); + + expect(result.success).toBe(true); + expect(result.deleted_count).toBe(1); + expect(result.batch_id).toBe('partial-batch'); + expect(result.batch_ids).toBeNull(); + }); + + it('returns the cancelled marker when the user aborts the fetch', async () => { + const abortError = new Error('The user aborted a request.'); + abortError.name = 'AbortError'; + global.fetch = vi.fn().mockRejectedValue(abortError); + + const client = await createClient(); + const result = await client.bulkDeleteModels(['/models/a.safetensors']); + + expect(result).toEqual({ success: false, cancelled: true }); + expect(hideLoadingMock).toHaveBeenCalledTimes(1); + }); + + it('throws the backend error message when the bulk delete fails', async () => { + mockBulkDeleteResponse({ success: false, error: 'disk full' }); + + const client = await createClient(); + await expect(client.bulkDeleteModels(['/models/a.safetensors'])).rejects.toThrow('disk full'); + }); +}); diff --git a/tests/frontend/api/baseModelApi.delete.test.js b/tests/frontend/api/baseModelApi.delete.test.js new file mode 100644 index 00000000..a8faff8f --- /dev/null +++ b/tests/frontend/api/baseModelApi.delete.test.js @@ -0,0 +1,161 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + BASE_MODEL_API_MODULE, + STATE_MODULE, + UI_HELPERS_MODULE, + I18N_MODULE, + STORAGE_MODULE, + API_CONFIG_MODULE, + API_FACTORY_MODULE, + SIDEBAR_MANAGER_MODULE, +} = vi.hoisted(() => ({ + BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname, + API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname, + API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, + SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname, +})); + +const showToastMock = vi.fn(); +const removeItemByFilePathMock = vi.fn(); +const showSimpleLoadingMock = vi.fn(); +const hideLoadingMock = vi.fn(); + +vi.mock(STATE_MODULE, () => ({ + state: { + loadingManager: { + showSimpleLoading: showSimpleLoadingMock, + hide: hideLoadingMock, + }, + virtualScroller: { + removeItemByFilePath: removeItemByFilePathMock, + }, + }, + getCurrentPageState: vi.fn(() => ({})), +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: showToastMock, +})); + +vi.mock(I18N_MODULE, () => ({ + translate: vi.fn((key) => key), +})); + +vi.mock(STORAGE_MODULE, () => ({ + getStorageItem: vi.fn(), + getSessionItem: vi.fn(), + removeSessionItem: vi.fn(), + saveMapToStorage: vi.fn(), +})); + +vi.mock(API_CONFIG_MODULE, () => ({ + getCompleteApiConfig: vi.fn(() => ({ + endpoints: { delete: '/api/lm/loras/delete' }, + config: { displayName: 'LoRA', singularName: 'LoRA' }, + })), + getCurrentModelType: vi.fn(() => 'loras'), + isValidModelType: vi.fn(() => true), + DOWNLOAD_ENDPOINTS: {}, + HF_ENDPOINTS: {}, + WS_ENDPOINTS: {}, +})); + +vi.mock(API_FACTORY_MODULE, () => ({ + resetAndReload: vi.fn(), +})); + +vi.mock(SIDEBAR_MANAGER_MODULE, () => ({ + sidebarManager: { refresh: vi.fn() }, +})); + +describe('BaseModelApiClient.deleteModel undo contract', () => { + beforeEach(() => { + showToastMock.mockReset(); + removeItemByFilePathMock.mockReset(); + showSimpleLoadingMock.mockReset(); + hideLoadingMock.mockReset(); + }); + + afterEach(() => { + delete global.fetch; + }); + + async function createClient() { + const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE); + class TestClient extends BaseModelApiClient {} + return new TestClient('loras'); + } + + it('returns the batch id and suppresses the legacy success toast when staged', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, deleted_files: [], batch_id: 'batch-42' }), + }); + + const client = await createClient(); + const result = await client.deleteModel('/models/foo.safetensors'); + + expect(result).toEqual({ success: true, batch_id: 'batch-42' }); + // The card is still removed from the scroller — the file is gone either way + expect(removeItemByFilePathMock).toHaveBeenCalledWith('/models/foo.safetensors'); + // No legacy toast: the caller shows the undo action toast instead + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.api.deleteSuccess', + expect.anything(), + expect.anything() + ); + expect(hideLoadingMock).toHaveBeenCalledTimes(1); + }); + + it('keeps the legacy success toast when the delete was not staged', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, deleted_files: ['/models/foo.safetensors'] }), + }); + + const client = await createClient(); + const result = await client.deleteModel('/models/foo.safetensors'); + + expect(result).toEqual({ success: true, batch_id: null }); + expect(removeItemByFilePathMock).toHaveBeenCalledWith('/models/foo.safetensors'); + expect(showToastMock).toHaveBeenCalledWith('toast.api.deleteSuccess', { type: 'LoRA' }, 'success'); + }); + + it('returns a truthy result so undo-blind callers keep working (ModelVersionsTab)', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, deleted_files: [], batch_id: 'batch-7' }), + }); + + const client = await createClient(); + const result = await client.deleteModel('/models/v2.safetensors'); + + // ModelVersionsTab.js:1136-1144 awaits deleteModel and treats any truthy + // result as success — the new object must satisfy that check shape. + expect(result).toBeTruthy(); + expect(Boolean(result && result.success)).toBe(true); + }); + + it('returns false and shows the failure toast when the server reports failure', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: false, error: 'disk error' }), + }); + + const client = await createClient(); + const result = await client.deleteModel('/models/foo.safetensors'); + + expect(result).toBe(false); + expect(removeItemByFilePathMock).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledWith( + 'toast.api.deleteFailed', + expect.objectContaining({ type: 'LoRA' }), + 'error' + ); + }); +}); diff --git a/tests/frontend/api/recipeApi.bulk.test.js b/tests/frontend/api/recipeApi.bulk.test.js index 55a58487..63d552b1 100644 --- a/tests/frontend/api/recipeApi.bulk.test.js +++ b/tests/frontend/api/recipeApi.bulk.test.js @@ -142,10 +142,50 @@ describe('RecipeSidebarApiClient bulk operations', () => { success: true, deleted_count: 2, failed_count: 0, + batch_id: null, + batch_ids: null, }); expect(loadingManagerMock.hide).toHaveBeenCalled(); }); + it('passes through the merged batch_id from a staged bulk delete', async () => { + const api = new RecipeSidebarApiClient(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + total_deleted: 2, + total_failed: 0, + failed: [], + batch_id: 'merged-recipe-batch', + }), + }); + + const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']); + + expect(result.batch_id).toBe('merged-recipe-batch'); + expect(result.batch_ids).toBeNull(); + }); + + it('passes through the batch_ids fallback array when the merge failed', async () => { + const api = new RecipeSidebarApiClient(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + total_deleted: 2, + total_failed: 0, + failed: [], + batch_ids: ['recipe-batch-1', 'recipe-batch-2'], + }), + }); + + const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']); + + expect(result.batch_id).toBeNull(); + expect(result.batch_ids).toEqual(['recipe-batch-1', 'recipe-batch-2']); + }); + it('encodes recipe IDs when fetching recipe details', async () => { global.fetch.mockResolvedValue({ ok: true, diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index a817f816..94aff178 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -123,6 +123,7 @@ vi.mock('../../../static/js/state/index.js', () => ({ vi.mock('../../../static/js/utils/modalUtils.js', () => ({ showExcludeModal: vi.fn(), showDeleteModal: vi.fn(), + armDeleteButton: vi.fn(), })); vi.mock('../../../static/js/managers/MoveManager.js', () => ({ diff --git a/tests/frontend/components/duplicatesManager.test.js b/tests/frontend/components/duplicatesManager.test.js index 6ff6251f..5e95e425 100644 --- a/tests/frontend/components/duplicatesManager.test.js +++ b/tests/frontend/components/duplicatesManager.test.js @@ -1,11 +1,18 @@ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; const showToastMock = vi.fn(); +const showActionToastMock = vi.fn(); +const handleUndoDeleteMock = vi.fn(); const recreateVirtualScrollMock = vi.fn(); const translateMock = vi.fn((key) => key); vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ showToast: showToastMock, + showActionToast: showActionToastMock, +})); + +vi.mock('../../../static/js/utils/undoHelpers.js', () => ({ + handleUndoDelete: handleUndoDeleteMock, })); vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ @@ -20,6 +27,17 @@ vi.mock('../../../static/js/components/RecipeCard.js', () => ({ }, })); +vi.mock('../../../static/js/utils/modalUtils.js', () => ({ + armDeleteButton: (modalElement) => { + if (!modalElement) return null; + const buttons = modalElement.querySelectorAll('.delete-btn'); + buttons.forEach((button) => { button.disabled = true; }); + return setTimeout(() => { + buttons.forEach((button) => { button.disabled = false; }); + }, 1500); + }, +})); + vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ recreateVirtualScroll: recreateVirtualScrollMock, })); @@ -211,3 +229,152 @@ describe('DuplicatesManager prompt matching toggle', () => { expect(document.getElementById('duplicatesBasis').textContent).toBe('recipes.duplicates.basis.loraCombo'); }); }); + +describe('DuplicatesManager confirmDeleteDuplicates undo flows', () => { + beforeEach(() => { + vi.clearAllMocks(); + setCurrentPageType('recipes'); + setupDom(); + state.pendingLayoutRecreate = false; + state.virtualScroller = { enable: vi.fn(), disable: vi.fn() }; + handleUndoDeleteMock.mockResolvedValue(true); + globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() }; + globalThis.recipeManager = { loadRecipes: vi.fn() }; + }); + + afterEach(() => { + state.pendingLayoutRecreate = false; + state.virtualScroller = null; + delete globalThis.modalManager; + delete globalThis.recipeManager; + delete globalThis.fetch; + }); + + function mockBulkDelete(payload) { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => payload, + }); + } + + function lastActionToastOptions() { + const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1]; + return call[3]; + } + + it('shows the undo action toast with the batch id and reloads recipes on undo', async () => { + mockBulkDelete({ success: true, total_deleted: 2, batch_id: 'recipe-batch-1' }); + + const manager = new DuplicatesManager({}); + manager.inDuplicateMode = true; + manager.selectedForDeletion.add('r1'); + manager.selectedForDeletion.add('r2'); + + await manager.confirmDeleteDuplicates(); + + expect(showActionToastMock).toHaveBeenCalledTimes(1); + expect(showActionToastMock).toHaveBeenCalledWith( + 'toast.undo.deletedBulk', + { count: 2 }, + 'success', + expect.objectContaining({ + actionText: 'toast.undo.action', + onAction: expect.any(Function), + }) + ); + // The legacy duplicates success toast is replaced, not duplicated + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.duplicates.deleteSuccess', + expect.anything(), + expect.anything() + ); + // exitDuplicateMode still runs for successful deletions + expect(manager.inDuplicateMode).toBe(false); + + lastActionToastOptions().onAction(); + + expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1); + expect(handleUndoDeleteMock).toHaveBeenCalledWith('recipe-batch-1', expect.any(Function)); + + const refreshFn = handleUndoDeleteMock.mock.calls[0][1]; + refreshFn(); + expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledWith(true); + }); + + it('undoes the batch_ids fallback sequentially with one final refresh and restored toast', async () => { + mockBulkDelete({ success: true, total_deleted: 2, batch_ids: ['rb-1', 'rb-2'] }); + + const manager = new DuplicatesManager({}); + manager.inDuplicateMode = true; + manager.selectedForDeletion.add('r1'); + manager.selectedForDeletion.add('r2'); + + await manager.confirmDeleteDuplicates(); + + expect(showActionToastMock).toHaveBeenCalledTimes(1); + await lastActionToastOptions().onAction(); + + expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2); + expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['rb-1', null, { showToast: false, refresh: false }]); + expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['rb-2', null, { showToast: false, refresh: false }]); + expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledTimes(1); + expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledWith(true); + expect(showToastMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success'); + }); + + it('keeps the legacy success toast when the response carries no batch field', async () => { + mockBulkDelete({ success: true, total_deleted: 1 }); + + const manager = new DuplicatesManager({}); + manager.inDuplicateMode = true; + manager.selectedForDeletion.add('r1'); + + await manager.confirmDeleteDuplicates(); + + expect(showActionToastMock).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledWith( + 'toast.duplicates.deleteSuccess', + { count: 1, type: 'recipes' }, + 'success' + ); + }); +}); + +describe('DuplicatesManager deleteSelectedDuplicates delay-activate', () => { + beforeEach(() => { + vi.useFakeTimers(); + setCurrentPageType('recipes'); + setupDom(); + document.body.insertAdjacentHTML('beforeend', ` + + `); + globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() }; + }); + + afterEach(() => { + vi.useRealTimers(); + delete globalThis.modalManager; + }); + + it('opens with the delete button disabled and enables it after 1500ms', async () => { + const manager = new DuplicatesManager({}); + manager.selectedForDeletion.add('r1'); + + await manager.deleteSelectedDuplicates(); + + expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('duplicateDeleteModal'); + const deleteBtn = document.querySelector('#duplicateDeleteModal .delete-btn'); + expect(deleteBtn.disabled).toBe(true); + + deleteBtn.click(); + expect(deleteBtn.disabled).toBe(true); + + vi.advanceTimersByTime(1500); + expect(deleteBtn.disabled).toBe(false); + }); +}); diff --git a/tests/frontend/components/modelDuplicatesManager.test.js b/tests/frontend/components/modelDuplicatesManager.test.js index 4bc47244..f72e6533 100644 --- a/tests/frontend/components/modelDuplicatesManager.test.js +++ b/tests/frontend/components/modelDuplicatesManager.test.js @@ -1,16 +1,34 @@ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; const showToastMock = vi.fn(); +const showActionToastMock = vi.fn(); +const handleUndoDeleteMock = vi.fn(); const resetAndReloadMock = vi.fn(); vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ showToast: showToastMock, + showActionToast: showActionToastMock, +})); + +vi.mock('../../../static/js/utils/undoHelpers.js', () => ({ + handleUndoDelete: handleUndoDeleteMock, })); vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ resetAndReload: resetAndReloadMock, })); +vi.mock('../../../static/js/utils/modalUtils.js', () => ({ + armDeleteButton: (modalElement) => { + if (!modalElement) return null; + const buttons = modalElement.querySelectorAll('.delete-btn'); + buttons.forEach((button) => { button.disabled = true; }); + return setTimeout(() => { + buttons.forEach((button) => { button.disabled = false; }); + }, 1500); + }, +})); + const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js'); const { state } = await import('../../../static/js/state/index.js'); @@ -230,3 +248,153 @@ describe('ModelDuplicatesManager verification state', () => { expect(manager.verifiedGroups.has('visible-hash')).toBe(true); }); }); + +describe('ModelDuplicatesManager confirmDeleteDuplicates undo flows', () => { + function mockDeleteAndRecheck(deletePayload) { + global.fetch = vi.fn((url) => { + if (String(url).includes('bulk-delete')) { + return Promise.resolve({ + ok: true, + statusText: 'OK', + json: async () => deletePayload, + }); + } + return Promise.resolve({ + ok: true, + statusText: 'OK', + json: async () => ({ success: true, duplicates: [] }), + }); + }); + } + + function lastActionToastOptions() { + const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1]; + return call[3]; + } + + beforeEach(() => { + handleUndoDeleteMock.mockResolvedValue(true); + state.virtualScroller = { enable: vi.fn(), disable: vi.fn() }; + globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() }; + }); + + afterEach(() => { + state.virtualScroller = null; + delete globalThis.modalManager; + }); + + it('shows the undo action toast with the batch id and refreshes models on undo', async () => { + const manager = await createManager(); + mockDeleteAndRecheck({ success: true, total_deleted: 1, batch_id: 'model-batch-1' }); + + manager.inDuplicateMode = true; + manager.selectedForDeletion.add(carPath); + + await manager.confirmDeleteDuplicates(); + + expect(showActionToastMock).toHaveBeenCalledTimes(1); + expect(showActionToastMock).toHaveBeenCalledWith( + 'toast.undo.deletedBulk', + { count: 1 }, + 'success', + expect.objectContaining({ + actionText: 'toast.undo.action', + onAction: expect.any(Function), + }) + ); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.duplicates.deleteSuccess', + expect.anything(), + expect.anything() + ); + + // The existing reset + find-duplicates re-check path still runs + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + // No remaining duplicates -> duplicate mode exited + expect(manager.inDuplicateMode).toBe(false); + + lastActionToastOptions().onAction(); + + expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1); + expect(handleUndoDeleteMock).toHaveBeenCalledWith('model-batch-1', expect.any(Function)); + + const refreshFn = handleUndoDeleteMock.mock.calls[0][1]; + resetAndReloadMock.mockClear(); + refreshFn(); + expect(resetAndReloadMock).toHaveBeenCalledTimes(1); + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + }); + + it('undoes the batch_ids fallback sequentially with one final refresh and restored toast', async () => { + const manager = await createManager(); + mockDeleteAndRecheck({ success: true, total_deleted: 2, batch_ids: ['mb-1', 'mb-2'] }); + + manager.inDuplicateMode = true; + manager.selectedForDeletion.add(carPath); + manager.selectedForDeletion.add(copyPath); + + await manager.confirmDeleteDuplicates(); + + expect(showActionToastMock).toHaveBeenCalledTimes(1); + resetAndReloadMock.mockClear(); + await lastActionToastOptions().onAction(); + + expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2); + expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['mb-1', null, { showToast: false, refresh: false }]); + expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['mb-2', null, { showToast: false, refresh: false }]); + expect(resetAndReloadMock).toHaveBeenCalledTimes(1); + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + expect(showToastMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success'); + }); + + it('keeps the legacy success toast when the response carries no batch field', async () => { + const manager = await createManager(); + mockDeleteAndRecheck({ success: true, total_deleted: 1 }); + + manager.inDuplicateMode = true; + manager.selectedForDeletion.add(carPath); + + await manager.confirmDeleteDuplicates(); + + expect(showActionToastMock).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledWith( + 'toast.duplicates.deleteSuccess', + { count: 1, type: 'loras' }, + 'success' + ); + }); +}); + +describe('ModelDuplicatesManager deleteSelectedDuplicates delay-activate', () => { + beforeEach(() => { + vi.useFakeTimers(); + globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() }; + }); + + afterEach(() => { + vi.useRealTimers(); + delete globalThis.modalManager; + }); + + it('opens with the delete button disabled and enables it after 1500ms', async () => { + const manager = await createManager(); + document.body.insertAdjacentHTML('beforeend', ` + + `); + manager.selectedForDeletion.add(carPath); + + await manager.deleteSelectedDuplicates(); + + expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('modelDuplicateDeleteModal'); + const deleteBtn = document.querySelector('#modelDuplicateDeleteModal .delete-btn'); + expect(deleteBtn.disabled).toBe(true); + + vi.advanceTimersByTime(1500); + expect(deleteBtn.disabled).toBe(false); + }); +}); diff --git a/tests/frontend/components/recipeCard.delete.test.js b/tests/frontend/components/recipeCard.delete.test.js new file mode 100644 index 00000000..6f1ebe94 --- /dev/null +++ b/tests/frontend/components/recipeCard.delete.test.js @@ -0,0 +1,191 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + RECIPE_CARD_MODULE, + UI_HELPERS_MODULE, + RECIPE_API_MODULE, + MODEL_CARD_MODULE, + MODAL_MANAGER_MODULE, + STATE_MODULE, + BULK_MANAGER_MODULE, + CONSTANTS_MODULE, + I18N_MODULE, + UNDO_HELPERS_MODULE, +} = vi.hoisted(() => ({ + RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname, + MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname, + MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname, + CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname, + I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname, +})); + +const showToastMock = vi.fn(); +const showActionToastMock = vi.fn(); +const handleUndoDeleteMock = vi.fn(); +const translateMock = vi.fn((key) => key); +const closeModalMock = vi.fn(); +const removeItemByFilePathMock = vi.fn(); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: showToastMock, + showActionToast: showActionToastMock, + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), +})); + +vi.mock(RECIPE_API_MODULE, () => ({ + updateRecipeMetadata: vi.fn(), +})); + +vi.mock(MODEL_CARD_MODULE, () => ({ + configureModelCardVideo: vi.fn(), +})); + +vi.mock(MODAL_MANAGER_MODULE, () => ({ + modalManager: { + showModal: vi.fn(), + closeModal: closeModalMock, + }, +})); + +vi.mock(STATE_MODULE, () => ({ + state: { + virtualScroller: { + removeItemByFilePath: removeItemByFilePathMock, + }, + }, + getCurrentPageState: vi.fn(() => ({})), +})); + +vi.mock(BULK_MANAGER_MODULE, () => ({ + bulkManager: {}, +})); + +vi.mock(CONSTANTS_MODULE, () => ({ + NSFW_LEVELS: {}, + getBaseModelAbbreviation: vi.fn(), + getMatureBlurThreshold: vi.fn(), +})); + +vi.mock(I18N_MODULE, () => ({ + translate: translateMock, +})); + +vi.mock(UNDO_HELPERS_MODULE, () => ({ + handleUndoDelete: handleUndoDeleteMock, +})); + +function setupDeleteModal() { + document.body.innerHTML = ` +
+ +
+ `; + const deleteModal = document.getElementById('deleteModal'); + // jsdom maps data-file-path to dataset.filePath + deleteModal.dataset.recipeId = 'recipe-1'; + deleteModal.dataset.filePath = '/recipes/r1.json'; + return deleteModal; +} + +async function flushPromises() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('RecipeCard confirmDeleteRecipe undo flow', () => { + beforeEach(() => { + showToastMock.mockReset(); + showActionToastMock.mockReset(); + handleUndoDeleteMock.mockReset(); + translateMock.mockClear(); + closeModalMock.mockReset(); + removeItemByFilePathMock.mockReset(); + setupDeleteModal(); + window.recipeManager = { loadRecipes: vi.fn() }; + }); + + afterEach(() => { + delete global.fetch; + delete window.recipeManager; + document.body.innerHTML = ''; + }); + + async function createCard() { + const { RecipeCard } = await import(RECIPE_CARD_MODULE); + const card = Object.create(RecipeCard.prototype); + card.recipe = { id: 'recipe-1', title: 'My Recipe', file_path: '/recipes/r1.json' }; + return card; + } + + it('shows the undo action toast and wires undo to handleUndoDelete + loadRecipes(true)', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, message: 'deleted', batch_id: 'recipe-batch-1' }), + }); + + const card = await createCard(); + card.confirmDeleteRecipe(); + await flushPromises(); + + expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1', expect.objectContaining({ + method: 'DELETE', + })); + // No legacy success toast when the delete was staged + expect(showToastMock).not.toHaveBeenCalledWith('toast.recipes.deletedSuccessfully', {}, 'success'); + expect(showActionToastMock).toHaveBeenCalledTimes(1); + + const [key, params, type, options] = showActionToastMock.mock.calls[0]; + expect(key).toBe('toast.undo.deleted'); + expect(params).toEqual({ name: 'My Recipe' }); + expect(type).toBe('success'); + expect(options.actionText).toBe('toast.undo.action'); + + options.onAction(); + expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1); + const [batchId, refreshFn] = handleUndoDeleteMock.mock.calls[0]; + expect(batchId).toBe('recipe-batch-1'); + + refreshFn(); + expect(window.recipeManager.loadRecipes).toHaveBeenCalledWith(true); + + expect(removeItemByFilePathMock).toHaveBeenCalledWith('/recipes/r1.json'); + expect(closeModalMock).toHaveBeenCalledWith('deleteModal'); + }); + + it('keeps the legacy success toast when the delete was not staged', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, message: 'deleted' }), + }); + + const card = await createCard(); + card.confirmDeleteRecipe(); + await flushPromises(); + + expect(showToastMock).toHaveBeenCalledWith('toast.recipes.deletedSuccessfully', {}, 'success'); + expect(showActionToastMock).not.toHaveBeenCalled(); + expect(closeModalMock).toHaveBeenCalledWith('deleteModal'); + }); + + it('shows the failure toast when the server rejects the delete', async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: false }); + + const card = await createCard(); + const deleteBtn = document.querySelector('.delete-btn'); + card.confirmDeleteRecipe(); + await flushPromises(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.deleteFailed', + expect.objectContaining({ message: expect.any(String) }), + 'error' + ); + expect(deleteBtn.disabled).toBe(false); + expect(deleteBtn.textContent).toBe('Delete'); + }); +}); diff --git a/tests/frontend/components/recipeCard.deleteFriction.test.js b/tests/frontend/components/recipeCard.deleteFriction.test.js new file mode 100644 index 00000000..5104fb86 --- /dev/null +++ b/tests/frontend/components/recipeCard.deleteFriction.test.js @@ -0,0 +1,160 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + RECIPE_CARD_MODULE, + UI_HELPERS_MODULE, + RECIPE_API_MODULE, + MODEL_CARD_MODULE, + MODAL_MANAGER_MODULE, + BULK_MANAGER_MODULE, + I18N_MODULE, + UNDO_HELPERS_MODULE, + API_FACTORY_MODULE, + STATE_MODULE, +} = vi.hoisted(() => ({ + RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname, + MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname, + MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname, + BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname, + I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname, + API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, +})); + +const showModalMock = vi.fn(); +const closeModalMock = vi.fn(); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: vi.fn(), + showActionToast: vi.fn(), + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), +})); + +vi.mock(RECIPE_API_MODULE, () => ({ + updateRecipeMetadata: vi.fn(), +})); + +vi.mock(MODEL_CARD_MODULE, () => ({ + configureModelCardVideo: vi.fn(), +})); + +vi.mock(MODAL_MANAGER_MODULE, () => ({ + modalManager: { + showModal: showModalMock, + closeModal: closeModalMock, + }, +})); + +vi.mock(BULK_MANAGER_MODULE, () => ({ + bulkManager: {}, +})); + +vi.mock(I18N_MODULE, () => ({ + translate: vi.fn((key) => key), +})); + +vi.mock(UNDO_HELPERS_MODULE, () => ({ + handleUndoDelete: vi.fn(), +})); + +// modalUtils.js is intentionally NOT mocked — its real armDeleteButton drives +// the delay-activate behavior under test. Its own imports are mocked below. +vi.mock(API_FACTORY_MODULE, () => ({ + getModelApiClient: vi.fn(), + resetAndReload: vi.fn(), +})); + +describe('RecipeCard delete confirmation delay-activate', () => { + let capturedOnClose; + + beforeEach(async () => { + vi.useFakeTimers(); + showModalMock.mockReset(); + closeModalMock.mockReset(); + capturedOnClose = null; + document.body.innerHTML = ''; + showModalMock.mockImplementation((id, content, onClose) => { + if (content) { + document.getElementById(id).innerHTML = content; + } + capturedOnClose = onClose; + }); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + window.recipeManager = { loadRecipes: vi.fn() }; + const { state } = await import(STATE_MODULE); + state.virtualScroller = { removeItemByFilePath: vi.fn() }; + }); + + afterEach(() => { + vi.useRealTimers(); + delete global.fetch; + delete window.recipeManager; + document.body.innerHTML = ''; + }); + + async function createCard() { + const { RecipeCard } = await import(RECIPE_CARD_MODULE); + const card = Object.create(RecipeCard.prototype); + card.recipe = { id: 'recipe-1', title: 'My Recipe', file_path: '/recipes/r1.json', file_url: '/preview.png' }; + return card; + } + + it('opens with a disabled delete button that ignores clicks until 1500ms elapse', async () => { + const card = await createCard(); + card.showDeleteConfirmation(); + + const deleteBtn = document.querySelector('#deleteModal .delete-btn'); + expect(deleteBtn.disabled).toBe(true); + + deleteBtn.click(); + expect(global.fetch).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1500); + expect(deleteBtn.disabled).toBe(false); + + deleteBtn.click(); + expect(global.fetch).toHaveBeenCalledWith( + '/api/lm/recipe/recipe-1', + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('clears the pending arm timer when the modal closes during the countdown', async () => { + const card = await createCard(); + card.showDeleteConfirmation(); + + const deleteBtn = document.querySelector('#deleteModal .delete-btn'); + expect(deleteBtn.disabled).toBe(true); + + vi.advanceTimersByTime(700); + capturedOnClose(); + + expect(deleteBtn.disabled).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it('re-arms a full 1500ms countdown when the modal is reopened', async () => { + const card = await createCard(); + card.showDeleteConfirmation(); + + vi.advanceTimersByTime(1400); + capturedOnClose(); + + card.showDeleteConfirmation(); + const deleteBtn = document.querySelector('#deleteModal .delete-btn'); + expect(deleteBtn.disabled).toBe(true); + + vi.advanceTimersByTime(1499); + expect(deleteBtn.disabled).toBe(true); + + vi.advanceTimersByTime(1); + expect(deleteBtn.disabled).toBe(false); + }); +}); diff --git a/tests/frontend/managers/BulkManager.bulkDelete.test.js b/tests/frontend/managers/BulkManager.bulkDelete.test.js new file mode 100644 index 00000000..fc939d16 --- /dev/null +++ b/tests/frontend/managers/BulkManager.bulkDelete.test.js @@ -0,0 +1,372 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + UNDO_HELPERS_MODULE, +} = vi.hoisted(() => ({ + UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname, +})); + +const showToastMock = vi.fn(); +const showActionToastMock = vi.fn(); +const handleUndoDeleteMock = vi.fn(); +const resetAndReloadMock = vi.fn(); +const bulkDeleteModelsMock = vi.fn(); +const recipeBulkDeleteModelsMock = vi.fn(); + +const loadingManagerStub = { + showSimpleLoading: vi.fn(), + hide: vi.fn(), + restoreProgressBar: vi.fn(), +}; + +const stateStub = { + currentPageType: 'loras', + bulkMode: false, + selectedModels: new Set(), + loadingManager: loadingManagerStub, + virtualScroller: { removeItemByFilePath: vi.fn() }, + global: { settings: {} }, +}; + +vi.mock('../../../static/js/state/index.js', () => ({ + state: stateStub, + getCurrentPageState: vi.fn(), +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, + showActionToast: showActionToastMock, + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), + sendEmbeddingToWorkflow: vi.fn(), + buildLoraSyntax: vi.fn(), + getNSFWLevelName: vi.fn(), +})); + +vi.mock(UNDO_HELPERS_MODULE, () => ({ + handleUndoDelete: handleUndoDeleteMock, +})); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + getModelApiClient: vi.fn(() => ({ bulkDeleteModels: bulkDeleteModelsMock })), + resetAndReload: resetAndReloadMock, +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + RecipeSidebarApiClient: class { + constructor() { + this.bulkDeleteModels = recipeBulkDeleteModelsMock; + } + }, + updateRecipeMetadata: vi.fn(), + extractRecipeId: vi.fn(), +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' }, + MODEL_CONFIG: {}, +})); + +vi.mock('../../../static/js/managers/ModalManager.js', () => ({ + modalManager: { showModal: vi.fn(), closeModal: vi.fn() }, +})); + +vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({ + updateCardsForBulkMode: vi.fn(), +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: vi.fn((key) => key), +})); + +vi.mock('../../../static/js/utils/priorityTagHelpers.js', () => ({ + getPriorityTagSuggestions: vi.fn(), +})); + +vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({ + getNsfwLevelSelector: vi.fn(), +})); + +describe('BulkManager.confirmBulkDelete undo flows', () => { + beforeEach(() => { + vi.clearAllMocks(); + stateStub.currentPageType = 'loras'; + stateStub.bulkMode = false; + stateStub.selectedModels.clear(); + stateStub.selectedModels.add('/models/a.safetensors'); + stateStub.selectedModels.add('/models/b.safetensors'); + handleUndoDeleteMock.mockResolvedValue(true); + }); + + afterEach(() => { + delete window.recipeManager; + delete window.modelDuplicatesManager; + }); + + async function createBulkManager() { + const { BulkManager } = await import('../../../static/js/managers/BulkManager.js'); + return new BulkManager(); + } + + function lastActionToastOptions() { + const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1]; + return call[3]; + } + + it('shows one action toast for the merged batch id and undoes it with a model refresh', async () => { + bulkDeleteModelsMock.mockResolvedValue({ + success: true, + deleted_count: 2, + failed_count: 0, + errors: [], + batch_id: 'merged-1', + batch_ids: null, + }); + + const bulk = await createBulkManager(); + await bulk.confirmBulkDelete(); + + expect(showActionToastMock).toHaveBeenCalledTimes(1); + expect(showActionToastMock).toHaveBeenCalledWith( + 'toast.undo.deletedBulk', + { count: 2 }, + 'success', + expect.objectContaining({ + actionText: 'toast.undo.action', + onAction: expect.any(Function), + }) + ); + // The legacy success and cancelled toasts must NOT fire + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.models.deletedSuccessfully', + expect.anything(), + expect.anything() + ); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.api.operationCancelled', + expect.anything(), + expect.anything() + ); + + lastActionToastOptions().onAction(); + + expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1); + expect(handleUndoDeleteMock).toHaveBeenCalledWith('merged-1', expect.any(Function)); + + // The undo refresh targets the model library + const refreshFn = handleUndoDeleteMock.mock.calls[0][1]; + refreshFn(); + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + }); + + it('keeps the legacy success toast when both batch fields are null', async () => { + bulkDeleteModelsMock.mockResolvedValue({ + success: true, + deleted_count: 2, + failed_count: 0, + errors: [], + batch_id: null, + batch_ids: null, + }); + + const bulk = await createBulkManager(); + await bulk.confirmBulkDelete(); + + expect(showActionToastMock).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledWith( + 'toast.models.deletedSuccessfully', + { count: 2, type: 'model' }, + 'success' + ); + }); + + it('undoes the batch_ids fallback sequentially with exactly one final refresh and restored toast', async () => { + bulkDeleteModelsMock.mockResolvedValue({ + success: true, + deleted_count: 2, + failed_count: 0, + errors: [], + batch_id: null, + batch_ids: ['id-1', 'id-2'], + }); + + const bulk = await createBulkManager(); + await bulk.confirmBulkDelete(); + + expect(showActionToastMock).toHaveBeenCalledTimes(1); + expect(showActionToastMock).toHaveBeenCalledWith( + 'toast.undo.deletedBulk', + { count: 2 }, + 'success', + expect.objectContaining({ onAction: expect.any(Function) }) + ); + + await lastActionToastOptions().onAction(); + + // Sequential suppressed undos in order + expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2); + expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['id-1', null, { showToast: false, refresh: false }]); + expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['id-2', null, { showToast: false, refresh: false }]); + + // Exactly ONE final refresh and ONE restored toast + expect(resetAndReloadMock).toHaveBeenCalledTimes(1); + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + expect(showToastMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success'); + }); + + it('stops the fallback loop on the first failure and skips the final refresh', async () => { + bulkDeleteModelsMock.mockResolvedValue({ + success: true, + deleted_count: 2, + failed_count: 0, + errors: [], + batch_id: null, + batch_ids: ['id-1', 'id-2', 'id-3'], + }); + handleUndoDeleteMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + const bulk = await createBulkManager(); + await bulk.confirmBulkDelete(); + await lastActionToastOptions().onAction(); + + // The loop stops at the failing second id — the third is never attempted + expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2); + expect(handleUndoDeleteMock.mock.calls[1][0]).toBe('id-2'); + + // The suppressed undo shows no error toast itself — the loop re-shows it + expect(showToastMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: '' }, 'error'); + + // No final refresh, no restored toast + expect(resetAndReloadMock).not.toHaveBeenCalled(); + expect(showToastMock).not.toHaveBeenCalledWith('toast.undo.restored', {}, 'success'); + }); + + it('shows the action toast for a cancelled bulk that staged a subset (batch_id)', async () => { + bulkDeleteModelsMock.mockResolvedValue({ + success: true, + deleted_count: 1, + failed_count: 0, + errors: [], + batch_id: 'partial-1', + batch_ids: null, + }); + + const bulk = await createBulkManager(); + await bulk.confirmBulkDelete(); + + expect(showActionToastMock).toHaveBeenCalledTimes(1); + expect(showActionToastMock).toHaveBeenCalledWith( + 'toast.undo.deletedBulk', + { count: 1 }, + 'success', + expect.objectContaining({ onAction: expect.any(Function) }) + ); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.api.operationCancelled', + expect.anything(), + expect.anything() + ); + }); + + it('shows the action toast for a cancelled bulk with the batch_ids fallback', async () => { + bulkDeleteModelsMock.mockResolvedValue({ + success: true, + deleted_count: 1, + failed_count: 0, + errors: [], + batch_id: null, + batch_ids: ['partial-1'], + }); + + const bulk = await createBulkManager(); + await bulk.confirmBulkDelete(); + + expect(showActionToastMock).toHaveBeenCalledTimes(1); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.api.operationCancelled', + expect.anything(), + expect.anything() + ); + }); + + it('keeps the cancelled toast when the user aborted and nothing was staged', async () => { + bulkDeleteModelsMock.mockResolvedValue({ success: false, cancelled: true }); + + const bulk = await createBulkManager(); + await bulk.confirmBulkDelete(); + + expect(showToastMock).toHaveBeenCalledWith('toast.api.operationCancelled', {}, 'info'); + expect(showActionToastMock).not.toHaveBeenCalled(); + }); + + it('refreshes recipes through window.recipeManager when undoing a recipe bulk delete', async () => { + stateStub.currentPageType = 'recipes'; + stateStub.selectedModels.clear(); + stateStub.selectedModels.add('/recipes/a.webp'); + const loadRecipesMock = vi.fn(); + window.recipeManager = { loadRecipes: loadRecipesMock }; + + recipeBulkDeleteModelsMock.mockResolvedValue({ + success: true, + deleted_count: 1, + failed_count: 0, + errors: [], + batch_id: 'recipe-batch-1', + batch_ids: null, + }); + + const bulk = await createBulkManager(); + await bulk.confirmBulkDelete(); + + expect(showActionToastMock).toHaveBeenCalledTimes(1); + lastActionToastOptions().onAction(); + + expect(handleUndoDeleteMock).toHaveBeenCalledWith('recipe-batch-1', expect.any(Function)); + const refreshFn = handleUndoDeleteMock.mock.calls[0][1]; + refreshFn(); + expect(loadRecipesMock).toHaveBeenCalledWith(true); + expect(resetAndReloadMock).not.toHaveBeenCalled(); + }); +}); + +describe('BulkManager.showBulkDeleteModal delay-activate', () => { + beforeEach(() => { + vi.useFakeTimers(); + stateStub.currentPageType = 'loras'; + stateStub.selectedModels.clear(); + stateStub.selectedModels.add('/models/a.safetensors'); + document.body.innerHTML = ` + + `; + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; + }); + + it('opens with the delete button disabled and enables it after 1500ms', async () => { + const { BulkManager } = await import('../../../static/js/managers/BulkManager.js'); + const bulk = new BulkManager(); + bulk.showBulkDeleteModal(); + + const deleteBtn = document.querySelector('#bulkDeleteModal .delete-btn'); + expect(deleteBtn.disabled).toBe(true); + + deleteBtn.click(); + expect(bulkDeleteModelsMock).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1500); + expect(deleteBtn.disabled).toBe(false); + }); +}); diff --git a/tests/frontend/managers/ModalManager.focus.test.js b/tests/frontend/managers/ModalManager.focus.test.js new file mode 100644 index 00000000..d7ae202e --- /dev/null +++ b/tests/frontend/managers/ModalManager.focus.test.js @@ -0,0 +1,103 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const MODAL_MANAGER_MODULE = new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname; + +function setupDom() { + document.body.innerHTML = ` + + + + + `; +} + +describe('ModalManager delete-modal focus handling', () => { + let ModalManager; + let manager; + + beforeEach(async () => { + setupDom(); + window.scrollTo = vi.fn(); + ({ ModalManager } = await import(MODAL_MANAGER_MODULE)); + manager = new ModalManager(); + for (const id of ['deleteModal', 'excludeModal', 'plainModal']) { + manager.registerModal(id, { + element: document.getElementById(id), + onClose: () => {}, + }); + } + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('focuses the cancel button when a delete-type modal opens', () => { + const trigger = document.getElementById('triggerBtn'); + trigger.focus(); + + manager.showModal('deleteModal'); + + expect(document.activeElement).toBe( + document.querySelector('#deleteModal .cancel-btn') + ); + }); + + it('restores focus to the previously focused element on close', () => { + const trigger = document.getElementById('triggerBtn'); + trigger.focus(); + + manager.showModal('deleteModal'); + manager.closeModal('deleteModal'); + + expect(document.activeElement).toBe(trigger); + }); + + it('does not touch focus for a non-delete modal', () => { + const trigger = document.getElementById('triggerBtn'); + trigger.focus(); + + manager.showModal('plainModal'); + + expect(document.activeElement).toBe(trigger); + + manager.closeModal('plainModal'); + expect(document.activeElement).toBe(trigger); + }); + + it('does not treat delete-modal-styled modals without a delete button as delete modals', () => { + const trigger = document.getElementById('triggerBtn'); + trigger.focus(); + + manager.showModal('excludeModal'); + + expect(document.activeElement).toBe(trigger); + + manager.closeModal('excludeModal'); + expect(document.activeElement).toBe(trigger); + }); + + it('skips the focus restore when the previously focused element is gone', () => { + const trigger = document.getElementById('triggerBtn'); + trigger.focus(); + + manager.showModal('deleteModal'); + trigger.remove(); + + expect(() => manager.closeModal('deleteModal')).not.toThrow(); + }); +}); diff --git a/tests/frontend/pages/checkpointsPage.test.js b/tests/frontend/pages/checkpointsPage.test.js index ea982e57..d5b0462f 100644 --- a/tests/frontend/pages/checkpointsPage.test.js +++ b/tests/frontend/pages/checkpointsPage.test.js @@ -34,6 +34,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({ closeDeleteModal: closeDeleteModalMock, confirmExclude: confirmExcludeMock, closeExcludeModal: closeExcludeModalMock, + armDeleteButton: vi.fn(), })); vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({ diff --git a/tests/frontend/pages/embeddingsPage.test.js b/tests/frontend/pages/embeddingsPage.test.js index 4d0f754f..02bc12dd 100644 --- a/tests/frontend/pages/embeddingsPage.test.js +++ b/tests/frontend/pages/embeddingsPage.test.js @@ -26,6 +26,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({ closeDeleteModal: closeDeleteModalMock, confirmExclude: confirmExcludeMock, closeExcludeModal: closeExcludeModalMock, + armDeleteButton: vi.fn(), })); vi.mock('../../../static/js/api/apiConfig.js', () => ({ diff --git a/tests/frontend/pages/lorasPage.test.js b/tests/frontend/pages/lorasPage.test.js index 1768fa35..02a6637e 100644 --- a/tests/frontend/pages/lorasPage.test.js +++ b/tests/frontend/pages/lorasPage.test.js @@ -36,6 +36,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({ closeDeleteModal: closeDeleteModalMock, confirmExclude: confirmExcludeMock, closeExcludeModal: closeExcludeModalMock, + armDeleteButton: vi.fn(), })); vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({ diff --git a/tests/frontend/utils/modalUtils.test.js b/tests/frontend/utils/modalUtils.test.js new file mode 100644 index 00000000..b1cab3da --- /dev/null +++ b/tests/frontend/utils/modalUtils.test.js @@ -0,0 +1,283 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + MODAL_UTILS_MODULE, + MODAL_MANAGER_MODULE, + API_FACTORY_MODULE, + UI_HELPERS_MODULE, + I18N_MODULE, + UNDO_HELPERS_MODULE, + STATE_MODULE, +} = vi.hoisted(() => ({ + MODAL_UTILS_MODULE: new URL('../../../static/js/utils/modalUtils.js', import.meta.url).pathname, + MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname, + API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, +})); + +const deleteModelMock = vi.fn(); +const resetAndReloadMock = vi.fn(); +const showActionToastMock = vi.fn(); +const handleUndoDeleteMock = vi.fn(); +const translateMock = vi.fn((key) => key); +const closeModalMock = vi.fn(); +const showModalMock = vi.fn(); + +vi.mock(MODAL_MANAGER_MODULE, () => ({ + modalManager: { + getModal: vi.fn((id) => ({ element: document.getElementById(id) })), + showModal: showModalMock, + closeModal: closeModalMock, + }, +})); + +vi.mock(API_FACTORY_MODULE, () => ({ + getModelApiClient: vi.fn(() => ({ deleteModel: deleteModelMock })), + resetAndReload: resetAndReloadMock, +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showActionToast: showActionToastMock, +})); + +vi.mock(I18N_MODULE, () => ({ + translate: translateMock, +})); + +vi.mock(UNDO_HELPERS_MODULE, () => ({ + handleUndoDelete: handleUndoDeleteMock, +})); + +describe('modalUtils confirmDelete undo flow', () => { + beforeEach(() => { + deleteModelMock.mockReset(); + resetAndReloadMock.mockReset(); + showActionToastMock.mockReset(); + handleUndoDeleteMock.mockReset(); + translateMock.mockClear(); + closeModalMock.mockReset(); + showModalMock.mockReset(); + document.body.innerHTML = ` +
+
+ `; + window.modelDuplicatesManager = undefined; + }); + + it('shows the undo action toast and wires undo to handleUndoDelete + resetAndReload', async () => { + deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-9' }); + + const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE); + + showDeleteModal('/models/foo.safetensors'); + await confirmDelete(); + + expect(deleteModelMock).toHaveBeenCalledWith('/models/foo.safetensors'); + expect(closeModalMock).toHaveBeenCalledWith('deleteModal'); + expect(showActionToastMock).toHaveBeenCalledTimes(1); + + const [key, params, type, options] = showActionToastMock.mock.calls[0]; + expect(key).toBe('toast.undo.deleted'); + expect(params).toEqual({ name: 'Foo Model' }); + expect(type).toBe('success'); + expect(options.actionText).toBe('toast.undo.action'); + expect(translateMock).toHaveBeenCalledWith('toast.undo.action'); + + // Clicking Undo posts the batch and refreshes the model list + options.onAction(); + expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1); + const [batchId, refreshFn] = handleUndoDeleteMock.mock.calls[0]; + expect(batchId).toBe('batch-9'); + expect(typeof refreshFn).toBe('function'); + + refreshFn(); + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + }); + + it('does not show the action toast when the delete was not staged', async () => { + deleteModelMock.mockResolvedValue({ success: true, batch_id: null }); + + const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE); + + showDeleteModal('/models/foo.safetensors'); + await confirmDelete(); + + expect(showActionToastMock).not.toHaveBeenCalled(); + expect(closeModalMock).toHaveBeenCalledWith('deleteModal'); + }); + + it('falls back to the file name when no card is present', async () => { + deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-10' }); + document.querySelector('.model-card').remove(); + + const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE); + + showDeleteModal('/models/bar.safetensors'); + await confirmDelete(); + + expect(showActionToastMock).toHaveBeenCalledTimes(1); + expect(showActionToastMock.mock.calls[0][1]).toEqual({ name: 'bar.safetensors' }); + }); + + it('refreshes the duplicates badge when the manager is available', async () => { + deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-11' }); + const updateBadge = vi.fn(); + window.modelDuplicatesManager = { updateDuplicatesBadgeAfterRefresh: updateBadge }; + + const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE); + + showDeleteModal('/models/foo.safetensors'); + await confirmDelete(); + + expect(updateBadge).toHaveBeenCalledTimes(1); + }); +}); + +describe('modalUtils armDeleteButton delay-activate', () => { + beforeEach(() => { + vi.useFakeTimers(); + deleteModelMock.mockReset(); + showModalMock.mockReset(); + closeModalMock.mockReset(); + document.body.innerHTML = ` +
+
+
+ + +
+ `; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('opens with the delete button disabled and enables it after exactly 1500ms', async () => { + const { showDeleteModal } = await import(MODAL_UTILS_MODULE); + + showDeleteModal('/models/foo.safetensors'); + + const deleteBtn = document.querySelector('#deleteModal .delete-btn'); + expect(deleteBtn.disabled).toBe(true); + + vi.advanceTimersByTime(1499); + expect(deleteBtn.disabled).toBe(true); + + vi.advanceTimersByTime(1); + expect(deleteBtn.disabled).toBe(false); + }); + + it('clicking the disabled delete button fires nothing', async () => { + const { showDeleteModal } = await import(MODAL_UTILS_MODULE); + + showDeleteModal('/models/foo.safetensors'); + + const deleteBtn = document.querySelector('#deleteModal .delete-btn'); + deleteBtn.click(); + + expect(deleteBtn.disabled).toBe(true); + expect(deleteModelMock).not.toHaveBeenCalled(); + }); + + it('closing during the countdown clears the timer and reopening re-arms a full 1500ms', async () => { + const { showDeleteModal, closeDeleteModal } = await import(MODAL_UTILS_MODULE); + + showDeleteModal('/models/foo.safetensors'); + const deleteBtn = document.querySelector('#deleteModal .delete-btn'); + + vi.advanceTimersByTime(1400); + closeDeleteModal(); + expect(closeModalMock).toHaveBeenCalledWith('deleteModal'); + + // Reopen — the stale timer must not enable the button early + showDeleteModal('/models/foo.safetensors'); + expect(deleteBtn.disabled).toBe(true); + + vi.advanceTimersByTime(1499); + expect(deleteBtn.disabled).toBe(true); + + vi.advanceTimersByTime(1); + expect(deleteBtn.disabled).toBe(false); + }); +}); + +describe('modalUtils showDeleteModal warning copy and size line', () => { + beforeEach(() => { + showModalMock.mockReset(); + closeModalMock.mockReset(); + translateMock.mockClear(); + translateMock.mockImplementation((key) => key); + document.body.innerHTML = ` +
+
+
+ +
+ `; + }); + + afterEach(async () => { + const { state } = await import(STATE_MODULE); + state.global.settings.delete_undo_enabled = true; + }); + + function modelInfoHtml() { + return document.querySelector('#deleteModal .delete-model-info').innerHTML; + } + + it('shows the recoverable warning when delete_undo_enabled is truthy', async () => { + const { state } = await import(STATE_MODULE); + state.global.settings.delete_undo_enabled = true; + + const { showDeleteModal } = await import(MODAL_UTILS_MODULE); + showDeleteModal('/models/foo.safetensors'); + + expect(modelInfoHtml()).toContain('modals.deleteModel.recoverableWarning'); + expect(modelInfoHtml()).not.toContain('modals.deleteModel.permanentWarning'); + }); + + it('shows the permanent warning when delete_undo_enabled is falsy', async () => { + const { state } = await import(STATE_MODULE); + state.global.settings.delete_undo_enabled = false; + + const { showDeleteModal } = await import(MODAL_UTILS_MODULE); + showDeleteModal('/models/foo.safetensors'); + + expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning'); + expect(modelInfoHtml()).not.toContain('modals.deleteModel.recoverableWarning'); + }); + + it('falls back to the neutral permanent warning when the setting is unavailable', async () => { + const { state } = await import(STATE_MODULE); + delete state.global.settings.delete_undo_enabled; + + const { showDeleteModal } = await import(MODAL_UTILS_MODULE); + showDeleteModal('/models/foo.safetensors'); + + expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning'); + }); + + it('appends a formatted "Frees {size}" line when the card carries a file size', async () => { + translateMock.mockImplementation((key, params) => + params && params.size ? `${key} ${params.size}` : key + ); + + const { showDeleteModal } = await import(MODAL_UTILS_MODULE); + showDeleteModal('/models/foo.safetensors'); + + expect(modelInfoHtml()).toContain('modals.deleteModel.freesSpace 2.0 GB'); + }); + + it('omits the size line when the card has no file size dataset', async () => { + document.querySelector('.model-card').removeAttribute('data-file_size'); + + const { showDeleteModal } = await import(MODAL_UTILS_MODULE); + showDeleteModal('/models/foo.safetensors'); + + expect(modelInfoHtml()).not.toContain('modals.deleteModel.freesSpace'); + }); +}); diff --git a/tests/frontend/utils/uiHelpers.dom.test.js b/tests/frontend/utils/uiHelpers.dom.test.js index 23b40ebe..60c5737a 100644 --- a/tests/frontend/utils/uiHelpers.dom.test.js +++ b/tests/frontend/utils/uiHelpers.dom.test.js @@ -110,6 +110,142 @@ describe('UI helper DOM utilities', () => { expect(toast.classList.contains('show')).toBe(false); }); + it('renders an action button and countdown span for action toasts', async () => { + vi.useFakeTimers(); + translateMock.mockReturnValue('Deleted Demo Model'); + + const { showActionToast } = await import(UI_HELPERS_MODULE); + + const onAction = vi.fn(); + showActionToast('toast.undo.deleted', { name: 'Demo Model' }, 'success', { + actionText: 'Undo', + onAction, + }); + + const toast = document.querySelector('.toast-container .toast'); + expect(toast).not.toBeNull(); + expect(toast.classList.contains('toast-success')).toBe(true); + expect(translateMock).toHaveBeenCalledWith('toast.undo.deleted', { name: 'Demo Model' }); + + const button = toast.querySelector('.toast-action-btn'); + expect(button).not.toBeNull(); + expect(button.textContent).toBe('Undo'); + + const countdown = toast.querySelector('.toast-countdown'); + expect(countdown).not.toBeNull(); + expect(countdown.textContent).toBe('(30s)'); + + // Ticking one second updates the countdown text + vi.advanceTimersByTime(1000); + expect(countdown.textContent).toBe('(29s)'); + + // Drain remaining timers so no state leaks into other tests + vi.advanceTimersByTime(30000); + }); + + it('invokes onAction once and dismisses immediately when the button is clicked', async () => { + vi.useFakeTimers(); + translateMock.mockReturnValue('Deleted Demo Model'); + + const { showActionToast } = await import(UI_HELPERS_MODULE); + + const onAction = vi.fn(); + showActionToast('toast.undo.deleted', {}, 'success', { + actionText: 'Undo', + onAction, + }); + + const toast = document.querySelector('.toast-container .toast'); + toast.querySelector('.toast-action-btn').click(); + + expect(onAction).toHaveBeenCalledTimes(1); + expect(toast.classList.contains('show')).toBe(false); + + // Dismissal removes the element after the transition ends + toast.dispatchEvent(new Event('transitionend', { bubbles: true })); + expect(document.querySelector('.toast-container .toast')).toBeNull(); + expect(document.querySelector('.toast-container')).toBeNull(); + }); + + it('calls onAction exactly once when the button is double-clicked', async () => { + vi.useFakeTimers(); + translateMock.mockReturnValue('Deleted Demo Model'); + + const { showActionToast } = await import(UI_HELPERS_MODULE); + + const onAction = vi.fn(); + showActionToast('toast.undo.deleted', {}, 'success', { + actionText: 'Undo', + onAction, + }); + + const button = document.querySelector('.toast-action-btn'); + button.click(); + button.click(); + + expect(onAction).toHaveBeenCalledTimes(1); + }); + + it('dismisses the toast when the countdown reaches zero', async () => { + vi.useFakeTimers(); + translateMock.mockReturnValue('Deleted Demo Model'); + // Async RAF mirrors production ordering: the countdown interval is + // registered before the dismiss timeout, so the final tick displays (0s) + globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); + + const { showActionToast } = await import(UI_HELPERS_MODULE); + + showActionToast('toast.undo.deleted', {}, 'success', { + actionText: 'Undo', + onAction: vi.fn(), + durationMs: 3000, + }); + + vi.advanceTimersByTime(0); // Flush the RAF callback + const toast = document.querySelector('.toast-container .toast'); + const countdown = toast.querySelector('.toast-countdown'); + expect(countdown.textContent).toBe('(3s)'); + + vi.advanceTimersByTime(2000); + expect(countdown.textContent).toBe('(1s)'); + expect(toast.classList.contains('show')).toBe(true); + + vi.advanceTimersByTime(1000); + expect(countdown.textContent).toBe('(0s)'); + expect(toast.classList.contains('show')).toBe(false); + + toast.dispatchEvent(new Event('transitionend', { bubbles: true })); + expect(document.querySelector('.toast-container .toast')).toBeNull(); + }); + + it('clears the countdown interval when dismissed via the action button', async () => { + vi.useFakeTimers(); + translateMock.mockReturnValue('Deleted Demo Model'); + + const { showActionToast } = await import(UI_HELPERS_MODULE); + + const onAction = vi.fn(); + showActionToast('toast.undo.deleted', {}, 'success', { + actionText: 'Undo', + onAction, + durationMs: 30000, + }); + + const toast = document.querySelector('.toast-container .toast'); + const countdown = toast.querySelector('.toast-countdown'); + toast.querySelector('.toast-action-btn').click(); + + // Advancing past the full duration must not tick the countdown further, + // throw, or re-dismiss the already-dismissed toast + vi.advanceTimersByTime(60000); + expect(countdown.textContent).toBe('(30s)'); + expect(onAction).toHaveBeenCalledTimes(1); + expect(toast.classList.contains('show')).toBe(false); + + toast.dispatchEvent(new Event('transitionend', { bubbles: true })); + expect(document.querySelector('.toast-container')).toBeNull(); + }); + it('toggles the persisted theme and updates DOM attributes', async () => { getStorageItemMock.mockReturnValue('light'); document.body.innerHTML = ''; diff --git a/tests/frontend/utils/undoHelpers.test.js b/tests/frontend/utils/undoHelpers.test.js new file mode 100644 index 00000000..88d368f6 --- /dev/null +++ b/tests/frontend/utils/undoHelpers.test.js @@ -0,0 +1,133 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + UI_HELPERS_MODULE, + UNDO_HELPERS_MODULE, +} = vi.hoisted(() => ({ + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname, +})); + +const showToastMock = vi.fn(); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: showToastMock, +})); + +describe('handleUndoDelete', () => { + beforeEach(() => { + showToastMock.mockReset(); + }); + + afterEach(() => { + delete global.fetch; + }); + + it('posts the batch id, refreshes once, and shows the restored toast on 200', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, restored: ['/models/foo.safetensors'] }), + }); + + const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE); + + const refreshFn = vi.fn(); + const result = await handleUndoDelete('batch-1', refreshFn); + + expect(result).toBe(true); + expect(global.fetch).toHaveBeenCalledWith('/api/lm/undo-delete', expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ batch_id: 'batch-1' }), + })); + expect(refreshFn).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success'); + }); + + it('shows the expired toast for a 404 whose error body mentions expired', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ success: false, error: 'Undo batch expired and was purged' }), + }); + + const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE); + + const refreshFn = vi.fn(); + const result = await handleUndoDelete('batch-gone', refreshFn); + + expect(result).toBe(false); + expect(refreshFn).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith('toast.undo.expired', {}, 'error'); + }); + + it('shows the failed toast with the server message for a 404 occupied path', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ success: false, error: 'Target path occupied' }), + }); + + const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE); + + const result = await handleUndoDelete('batch-occupied', vi.fn()); + + expect(result).toBe(false); + expect(showToastMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'Target path occupied' }, 'error'); + }); + + it('shows the failed toast when the error body is not parseable', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => { throw new Error('invalid json'); }, + }); + + const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE); + + const result = await handleUndoDelete('batch-malformed', vi.fn()); + + expect(result).toBe(false); + expect(showToastMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'Not Found' }, 'error'); + }); + + it('shows the failed toast on network errors', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('connection reset')); + + const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE); + + const refreshFn = vi.fn(); + const result = await handleUndoDelete('batch-net', refreshFn); + + expect(result).toBe(false); + expect(refreshFn).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'connection reset' }, 'error'); + }); + + it('suppresses the toast and refresh when the options disable them', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true }), + }); + + const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE); + + const refreshFn = vi.fn(); + const result = await handleUndoDelete('batch-quiet', refreshFn, { showToast: false, refresh: false }); + + expect(result).toBe(true); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(refreshFn).not.toHaveBeenCalled(); + expect(showToastMock).not.toHaveBeenCalled(); + }); +});