mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-14 01:33:21 -03:00
feat(delete): add undo toasts and harden delete modals
This commit is contained in:
@@ -80,6 +80,33 @@
|
|||||||
margin-top: 10px;
|
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 */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.toast {
|
.toast {
|
||||||
|
|||||||
@@ -201,8 +201,13 @@ export class BaseModelApiClient {
|
|||||||
if (state.virtualScroller) {
|
if (state.virtualScroller) {
|
||||||
state.virtualScroller.removeItemByFilePath(filePath);
|
state.virtualScroller.removeItemByFilePath(filePath);
|
||||||
}
|
}
|
||||||
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
const batchId = data.batch_id || null;
|
||||||
return true;
|
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 {
|
} else {
|
||||||
throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`);
|
throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`);
|
||||||
}
|
}
|
||||||
@@ -1622,9 +1627,14 @@ export class BaseModelApiClient {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
deleted_count: result.deleted_count,
|
deleted_count: result.deleted_count ?? result.total_deleted,
|
||||||
failed_count: result.failed_count || 0,
|
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 {
|
} else {
|
||||||
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
||||||
|
|||||||
@@ -657,6 +657,10 @@ export class RecipeSidebarApiClient {
|
|||||||
deleted_count: result.total_deleted,
|
deleted_count: result.total_deleted,
|
||||||
failed_count: result.total_failed || 0,
|
failed_count: result.total_failed || 0,
|
||||||
errors: result.failed || [],
|
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 {
|
} finally {
|
||||||
state.loadingManager?.hide();
|
state.loadingManager?.hide();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Duplicates Manager Component
|
// 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 { translate } from '../utils/i18nHelpers.js';
|
||||||
import { RecipeCard } from './RecipeCard.js';
|
import { RecipeCard } from './RecipeCard.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
@@ -447,6 +449,7 @@ export class DuplicatesManager {
|
|||||||
|
|
||||||
// Use the modal manager to show the confirmation modal
|
// Use the modal manager to show the confirmation modal
|
||||||
modalManager.showModal('duplicateDeleteModal');
|
modalManager.showModal('duplicateDeleteModal');
|
||||||
|
armDeleteButton(document.getElementById('duplicateDeleteModal'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error preparing delete:', error);
|
console.error('Error preparing delete:', error);
|
||||||
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
|
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
|
||||||
@@ -479,8 +482,34 @@ export class DuplicatesManager {
|
|||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
throw new Error(data.error || 'Unknown error deleting recipes');
|
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
|
// Exit duplicate mode if deletions were successful
|
||||||
if (data.total_deleted > 0) {
|
if (data.total_deleted > 0) {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// Model Duplicates Manager Component for LoRAs and Checkpoints
|
// 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 { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { formatDate } from '../utils/formatters.js';
|
import { formatDate } from '../utils/formatters.js';
|
||||||
import { resetAndReload} from '../api/modelApiFactory.js';
|
import { resetAndReload} from '../api/modelApiFactory.js';
|
||||||
@@ -700,6 +703,7 @@ export class ModelDuplicatesManager {
|
|||||||
|
|
||||||
// Use the modal manager to show the confirmation modal
|
// Use the modal manager to show the confirmation modal
|
||||||
modalManager.showModal('modelDuplicateDeleteModal');
|
modalManager.showModal('modelDuplicateDeleteModal');
|
||||||
|
armDeleteButton(document.getElementById('modelDuplicateDeleteModal'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error preparing delete:', error);
|
console.error('Error preparing delete:', error);
|
||||||
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
|
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
|
||||||
@@ -732,8 +736,34 @@ export class ModelDuplicatesManager {
|
|||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
throw new Error(data.error || 'Unknown error deleting models');
|
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 models were successfully deleted
|
||||||
if (data.total_deleted > 0) {
|
if (data.total_deleted > 0) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Recipe Card Component
|
// 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 { updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { configureModelCardVideo } from './shared/ModelCard.js';
|
import { configureModelCardVideo } from './shared/ModelCard.js';
|
||||||
import { modalManager } from '../managers/ModalManager.js';
|
import { modalManager } from '../managers/ModalManager.js';
|
||||||
@@ -7,6 +7,9 @@ import { getCurrentPageState } from '../state/index.js';
|
|||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { bulkManager } from '../managers/BulkManager.js';
|
import { bulkManager } from '../managers/BulkManager.js';
|
||||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.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 {
|
class RecipeCard {
|
||||||
constructor(recipe, clickHandler) {
|
constructor(recipe, clickHandler) {
|
||||||
@@ -375,8 +378,13 @@ class RecipeCard {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Show the modal with custom content and setup callbacks
|
// Show the modal with custom content and setup callbacks
|
||||||
|
let deleteArmTimer = null;
|
||||||
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
||||||
// This is the onClose callback
|
// This is the onClose callback
|
||||||
|
if (deleteArmTimer) {
|
||||||
|
clearTimeout(deleteArmTimer);
|
||||||
|
deleteArmTimer = null;
|
||||||
|
}
|
||||||
const deleteModal = document.getElementById('deleteModal');
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||||
deleteBtn.textContent = 'Delete';
|
deleteBtn.textContent = 'Delete';
|
||||||
@@ -396,6 +404,8 @@ class RecipeCard {
|
|||||||
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
||||||
deleteBtn.onclick = () => this.confirmDeleteRecipe();
|
deleteBtn.onclick = () => this.confirmDeleteRecipe();
|
||||||
|
|
||||||
|
deleteArmTimer = armDeleteButton(deleteModal);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error showing delete confirmation:', error);
|
console.error('Error showing delete confirmation:', error);
|
||||||
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
||||||
@@ -432,7 +442,16 @@ class RecipeCard {
|
|||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.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);
|
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
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 { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
@@ -628,6 +630,7 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
modalManager.showModal('bulkDeleteModal');
|
modalManager.showModal('bulkDeleteModal');
|
||||||
|
armDeleteButton(document.getElementById('bulkDeleteModal'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmBulkDelete() {
|
async confirmBulkDelete() {
|
||||||
@@ -649,10 +652,38 @@ export class BulkManager {
|
|||||||
showToast('toast.api.operationCancelled', {}, 'info');
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
} else if (result.success) {
|
} else if (result.success) {
|
||||||
const currentConfig = this.getCurrentDisplayConfig();
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
showToast('toast.models.deletedSuccessfully', {
|
const isRecipes = state.currentPageType === 'recipes';
|
||||||
count: result.deleted_count,
|
const refreshFn = isRecipes
|
||||||
type: currentConfig.displayName.toLowerCase()
|
? () => window.recipeManager.loadRecipes(true)
|
||||||
}, 'success');
|
: () => 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 => {
|
filePaths.forEach(path => {
|
||||||
state.virtualScroller.removeItemByFilePath(path);
|
state.virtualScroller.removeItemByFilePath(path);
|
||||||
|
|||||||
@@ -434,6 +434,22 @@ export class ModalManager {
|
|||||||
this.currentOpenModal = id; // Update currently open modal
|
this.currentOpenModal = id; // Update currently open modal
|
||||||
document.body.style.top = `-${this.scrollPosition}px`;
|
document.body.style.top = `-${this.scrollPosition}px`;
|
||||||
document.body.classList.add('modal-open');
|
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) {
|
closeModal(id) {
|
||||||
@@ -463,6 +479,13 @@ export class ModalManager {
|
|||||||
modal.cleanupCallback();
|
modal.cleanupCallback();
|
||||||
modal.cleanupCallback = null;
|
modal.cleanupCallback = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (modal.restoreFocusTo) {
|
||||||
|
if (modal.restoreFocusTo.isConnected) {
|
||||||
|
modal.restoreFocusTo.focus();
|
||||||
|
}
|
||||||
|
modal.restoreFocusTo = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEscape(e) {
|
handleEscape(e) {
|
||||||
|
|||||||
@@ -1,37 +1,84 @@
|
|||||||
import { modalManager } from '../managers/ModalManager.js';
|
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 pendingDeletePath = null;
|
||||||
|
let pendingDeleteName = null;
|
||||||
let pendingExcludePath = 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) {
|
export function showDeleteModal(filePath) {
|
||||||
pendingDeletePath = filePath;
|
pendingDeletePath = filePath;
|
||||||
|
|
||||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||||
? window.CSS.escape(filePath)
|
? window.CSS.escape(filePath)
|
||||||
: filePath.replace(/["\\]/g, '\\$&');
|
: filePath.replace(/["\\]/g, '\\$&');
|
||||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||||
const modelName = card ? card.dataset.name : filePath.split('/').pop();
|
const modelName = card ? card.dataset.name : filePath.split('/').pop();
|
||||||
|
pendingDeleteName = modelName;
|
||||||
const modal = modalManager.getModal('deleteModal').element;
|
const modal = modalManager.getModal('deleteModal').element;
|
||||||
const modelInfo = modal.querySelector('.delete-model-info');
|
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
|
||||||
|
? `<br>${translate('modals.deleteModel.freesSpace', { size: formatFileSize(parseInt(fileSize, 10)) })}`
|
||||||
|
: '';
|
||||||
|
|
||||||
modelInfo.innerHTML = `
|
modelInfo.innerHTML = `
|
||||||
<strong>Model:</strong> ${modelName}
|
<strong>Model:</strong> ${modelName}
|
||||||
<br>
|
<br>
|
||||||
<strong>File:</strong> ${filePath}
|
<strong>File:</strong> ${filePath}
|
||||||
|
<br>
|
||||||
|
${translate(warningKey)}${sizeLine}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
modalManager.showModal('deleteModal');
|
modalManager.showModal('deleteModal');
|
||||||
|
pendingDeleteArmTimer = armDeleteButton(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmDelete() {
|
export async function confirmDelete() {
|
||||||
if (!pendingDeletePath) return;
|
if (!pendingDeletePath) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getModelApiClient().deleteModel(pendingDeletePath);
|
const modelName = pendingDeleteName;
|
||||||
|
const result = await getModelApiClient().deleteModel(pendingDeletePath);
|
||||||
|
|
||||||
closeDeleteModal();
|
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) {
|
if (window.modelDuplicatesManager) {
|
||||||
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
||||||
}
|
}
|
||||||
@@ -43,7 +90,12 @@ export async function confirmDelete() {
|
|||||||
|
|
||||||
export function closeDeleteModal() {
|
export function closeDeleteModal() {
|
||||||
modalManager.closeModal('deleteModal');
|
modalManager.closeModal('deleteModal');
|
||||||
|
if (pendingDeleteArmTimer) {
|
||||||
|
clearTimeout(pendingDeleteArmTimer);
|
||||||
|
pendingDeleteArmTimer = null;
|
||||||
|
}
|
||||||
pendingDeletePath = null;
|
pendingDeletePath = null;
|
||||||
|
pendingDeleteName = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Functions for the exclude modal
|
// Functions for the exclude modal
|
||||||
|
|||||||
+132
-27
@@ -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
|
* Build a toast element (internal — not exported).
|
||||||
// to avoid spurious "Translation key not found" warnings from i18next
|
* @param {string} message - Already-resolved message text
|
||||||
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
|
* @param {string} type - Toast type (info/success/warning/error)
|
||||||
const message = isPlainMessage ? key : translate(key, params, fallback);
|
* @returns {HTMLElement} The toast element (not yet attached to the DOM)
|
||||||
|
*/
|
||||||
|
function createToastElement(message, type) {
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast toast-${type}`;
|
toast.className = `toast toast-${type}`;
|
||||||
toast.textContent = message;
|
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
|
// Get or create toast container
|
||||||
let toastContainer = document.querySelector('.toast-container');
|
let toastContainer = document.querySelector('.toast-container');
|
||||||
if (!toastContainer) {
|
if (!toastContainer) {
|
||||||
@@ -161,35 +174,127 @@ export function showToast(key, params = {}, type = 'info', fallback = null) {
|
|||||||
// Set position based on existing toasts
|
// Set position based on existing toasts
|
||||||
toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`;
|
toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
let dismissed = false;
|
||||||
toast.classList.add('show');
|
const dismiss = () => {
|
||||||
|
if (dismissed) return;
|
||||||
|
dismissed = true;
|
||||||
|
|
||||||
// Set timeout based on type
|
if (typeof onDismiss === 'function') {
|
||||||
let timeout = 2000; // Default (info)
|
onDismiss();
|
||||||
if (type === 'warning' || type === 'error') {
|
|
||||||
timeout = 5000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
toast.classList.remove('show');
|
||||||
toast.classList.remove('show');
|
toast.addEventListener('transitionend', () => {
|
||||||
toast.addEventListener('transitionend', () => {
|
toast.remove();
|
||||||
toast.remove();
|
|
||||||
|
|
||||||
// Reposition remaining toasts
|
// Reposition remaining toasts
|
||||||
if (toastContainer) {
|
if (toastContainer) {
|
||||||
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
|
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
|
||||||
remainingToasts.forEach((t, index) => {
|
remainingToasts.forEach((t, index) => {
|
||||||
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
|
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove container if empty
|
// Remove container if empty
|
||||||
if (remainingToasts.length === 0) {
|
if (remainingToasts.length === 0) {
|
||||||
toastContainer.remove();
|
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() {
|
export function restoreFolderFilter() {
|
||||||
|
|||||||
@@ -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<boolean>} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -142,10 +142,50 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
success: true,
|
success: true,
|
||||||
deleted_count: 2,
|
deleted_count: 2,
|
||||||
failed_count: 0,
|
failed_count: 0,
|
||||||
|
batch_id: null,
|
||||||
|
batch_ids: null,
|
||||||
});
|
});
|
||||||
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
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 () => {
|
it('encodes recipe IDs when fetching recipe details', async () => {
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ vi.mock('../../../static/js/state/index.js', () => ({
|
|||||||
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||||
showExcludeModal: vi.fn(),
|
showExcludeModal: vi.fn(),
|
||||||
showDeleteModal: vi.fn(),
|
showDeleteModal: vi.fn(),
|
||||||
|
armDeleteButton: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
|
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
const showToastMock = vi.fn();
|
const showToastMock = vi.fn();
|
||||||
|
const showActionToastMock = vi.fn();
|
||||||
|
const handleUndoDeleteMock = vi.fn();
|
||||||
const recreateVirtualScrollMock = vi.fn();
|
const recreateVirtualScrollMock = vi.fn();
|
||||||
const translateMock = vi.fn((key) => key);
|
const translateMock = vi.fn((key) => key);
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
showToast: showToastMock,
|
showToast: showToastMock,
|
||||||
|
showActionToast: showActionToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/undoHelpers.js', () => ({
|
||||||
|
handleUndoDelete: handleUndoDeleteMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
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', () => ({
|
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||||
recreateVirtualScroll: recreateVirtualScrollMock,
|
recreateVirtualScroll: recreateVirtualScrollMock,
|
||||||
}));
|
}));
|
||||||
@@ -211,3 +229,152 @@ describe('DuplicatesManager prompt matching toggle', () => {
|
|||||||
expect(document.getElementById('duplicatesBasis').textContent).toBe('recipes.duplicates.basis.loraCombo');
|
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', `
|
||||||
|
<div id="duplicateDeleteModal" class="modal delete-modal">
|
||||||
|
<div class="delete-model-info"><p><span id="duplicateDeleteCount">0</span></p></div>
|
||||||
|
<button class="cancel-btn">Cancel</button>
|
||||||
|
<button class="delete-btn">Delete</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
const showToastMock = vi.fn();
|
const showToastMock = vi.fn();
|
||||||
|
const showActionToastMock = vi.fn();
|
||||||
|
const handleUndoDeleteMock = vi.fn();
|
||||||
const resetAndReloadMock = vi.fn();
|
const resetAndReloadMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
showToast: showToastMock,
|
showToast: showToastMock,
|
||||||
|
showActionToast: showActionToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/undoHelpers.js', () => ({
|
||||||
|
handleUndoDelete: handleUndoDeleteMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
resetAndReload: resetAndReloadMock,
|
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 { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
|
||||||
const { state } = await import('../../../static/js/state/index.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);
|
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', `
|
||||||
|
<div id="modelDuplicateDeleteModal" class="modal delete-modal">
|
||||||
|
<div class="delete-model-info"><p><span id="modelDuplicateDeleteCount">0</span></p></div>
|
||||||
|
<button class="cancel-btn">Cancel</button>
|
||||||
|
<button class="delete-btn">Delete</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<div id="deleteModal" data-recipe-id="recipe-1" data-file-path="/recipes/r1.json">
|
||||||
|
<button class="delete-btn">Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = '<div id="deleteModal" class="modal delete-modal"></div>';
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = `
|
||||||
|
<div id="bulkDeleteModal" class="modal delete-modal">
|
||||||
|
<h2></h2>
|
||||||
|
<p class="delete-message"></p>
|
||||||
|
<div class="delete-model-info"><p></p></div>
|
||||||
|
<button class="cancel-btn">Cancel</button>
|
||||||
|
<button class="delete-btn">Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = `
|
||||||
|
<button id="triggerBtn">Trigger</button>
|
||||||
|
<div id="deleteModal" class="modal delete-modal">
|
||||||
|
<div class="modal-content delete-modal-content">
|
||||||
|
<button class="cancel-btn">Cancel</button>
|
||||||
|
<button class="delete-btn">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="excludeModal" class="modal delete-modal">
|
||||||
|
<div class="modal-content delete-modal-content">
|
||||||
|
<button class="cancel-btn">Cancel</button>
|
||||||
|
<button class="exclude-btn">Exclude</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="plainModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<button class="cancel-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,6 +34,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
|||||||
closeDeleteModal: closeDeleteModalMock,
|
closeDeleteModal: closeDeleteModalMock,
|
||||||
confirmExclude: confirmExcludeMock,
|
confirmExclude: confirmExcludeMock,
|
||||||
closeExcludeModal: closeExcludeModalMock,
|
closeExcludeModal: closeExcludeModalMock,
|
||||||
|
armDeleteButton: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
|
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
|||||||
closeDeleteModal: closeDeleteModalMock,
|
closeDeleteModal: closeDeleteModalMock,
|
||||||
confirmExclude: confirmExcludeMock,
|
confirmExclude: confirmExcludeMock,
|
||||||
closeExcludeModal: closeExcludeModalMock,
|
closeExcludeModal: closeExcludeModalMock,
|
||||||
|
armDeleteButton: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
|||||||
closeDeleteModal: closeDeleteModalMock,
|
closeDeleteModal: closeDeleteModalMock,
|
||||||
confirmExclude: confirmExcludeMock,
|
confirmExclude: confirmExcludeMock,
|
||||||
closeExcludeModal: closeExcludeModalMock,
|
closeExcludeModal: closeExcludeModalMock,
|
||||||
|
armDeleteButton: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
|
vi.mock('../../../static/js/components/ModelDuplicatesManager.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 = `
|
||||||
|
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model"></div>
|
||||||
|
<div id="deleteModal"><div class="delete-model-info"></div></div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model"></div>
|
||||||
|
<div id="deleteModal">
|
||||||
|
<div class="delete-model-info"></div>
|
||||||
|
<button class="cancel-btn">Cancel</button>
|
||||||
|
<button class="delete-btn">Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model" data-file_size="2147483648"></div>
|
||||||
|
<div id="deleteModal">
|
||||||
|
<div class="delete-model-info"></div>
|
||||||
|
<button class="delete-btn">Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -110,6 +110,142 @@ describe('UI helper DOM utilities', () => {
|
|||||||
expect(toast.classList.contains('show')).toBe(false);
|
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 () => {
|
it('toggles the persisted theme and updates DOM attributes', async () => {
|
||||||
getStorageItemMock.mockReturnValue('light');
|
getStorageItemMock.mockReturnValue('light');
|
||||||
document.body.innerHTML = '<button class="theme-toggle"></button>';
|
document.body.innerHTML = '<button class="theme-toggle"></button>';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user