fix(workflows): restore uses the app confirmation modal and a loading overlay
- Replace the browser confirm() with a generic #confirmationModal (template + ModalManager registration + showConfirmationModal() helper in modalUtils.js, promise-based, danger-styled confirm button). - Show the loadingManager overlay while the restore request is in flight; .json restores re-download from CivitAI and the wait previously looked like a dead click. The overlay hides in a finally block.
This commit is contained in:
+4
-1
@@ -1468,7 +1468,10 @@
|
||||
"failed": "[TODO: Translate] Failed to send workflow: {message}"
|
||||
},
|
||||
"restoreDownloaded": {
|
||||
"confirm": "[TODO: Translate] Restore this workflow to its downloaded version? Local edits will be overwritten.",
|
||||
"title": "[TODO: Translate] Restore Downloaded Version?",
|
||||
"message": "[TODO: Translate] This discards local edits and restores the workflow to its downloaded version.",
|
||||
"confirmButton": "[TODO: Translate] Restore",
|
||||
"restoring": "[TODO: Translate] Restoring workflow...",
|
||||
"success": "[TODO: Translate] Workflow restored to its downloaded version",
|
||||
"failed": "[TODO: Translate] Failed to restore workflow: {message}"
|
||||
},
|
||||
|
||||
+4
-1
@@ -1468,7 +1468,10 @@
|
||||
"failed": "Failed to send workflow: {message}"
|
||||
},
|
||||
"restoreDownloaded": {
|
||||
"confirm": "Restore this workflow to its downloaded version? Local edits will be overwritten.",
|
||||
"title": "Restore Downloaded Version?",
|
||||
"message": "This discards local edits and restores the workflow to its downloaded version.",
|
||||
"confirmButton": "Restore",
|
||||
"restoring": "Restoring workflow...",
|
||||
"success": "Workflow restored to its downloaded version",
|
||||
"failed": "Failed to restore workflow: {message}"
|
||||
},
|
||||
|
||||
+4
-1
@@ -1468,7 +1468,10 @@
|
||||
"failed": "[TODO: Translate] Failed to send workflow: {message}"
|
||||
},
|
||||
"restoreDownloaded": {
|
||||
"confirm": "[TODO: Translate] Restore this workflow to its downloaded version? Local edits will be overwritten.",
|
||||
"title": "[TODO: Translate] Restore Downloaded Version?",
|
||||
"message": "[TODO: Translate] This discards local edits and restores the workflow to its downloaded version.",
|
||||
"confirmButton": "[TODO: Translate] Restore",
|
||||
"restoring": "[TODO: Translate] Restoring workflow...",
|
||||
"success": "[TODO: Translate] Workflow restored to its downloaded version",
|
||||
"failed": "[TODO: Translate] Failed to restore workflow: {message}"
|
||||
},
|
||||
|
||||
+4
-1
@@ -1468,7 +1468,10 @@
|
||||
"failed": "[TODO: Translate] Failed to send workflow: {message}"
|
||||
},
|
||||
"restoreDownloaded": {
|
||||
"confirm": "[TODO: Translate] Restore this workflow to its downloaded version? Local edits will be overwritten.",
|
||||
"title": "[TODO: Translate] Restore Downloaded Version?",
|
||||
"message": "[TODO: Translate] This discards local edits and restores the workflow to its downloaded version.",
|
||||
"confirmButton": "[TODO: Translate] Restore",
|
||||
"restoring": "[TODO: Translate] Restoring workflow...",
|
||||
"success": "[TODO: Translate] Workflow restored to its downloaded version",
|
||||
"failed": "[TODO: Translate] Failed to restore workflow: {message}"
|
||||
},
|
||||
|
||||
+4
-1
@@ -1468,7 +1468,10 @@
|
||||
"failed": "[TODO: Translate] Failed to send workflow: {message}"
|
||||
},
|
||||
"restoreDownloaded": {
|
||||
"confirm": "[TODO: Translate] Restore this workflow to its downloaded version? Local edits will be overwritten.",
|
||||
"title": "[TODO: Translate] Restore Downloaded Version?",
|
||||
"message": "[TODO: Translate] This discards local edits and restores the workflow to its downloaded version.",
|
||||
"confirmButton": "[TODO: Translate] Restore",
|
||||
"restoring": "[TODO: Translate] Restoring workflow...",
|
||||
"success": "[TODO: Translate] Workflow restored to its downloaded version",
|
||||
"failed": "[TODO: Translate] Failed to restore workflow: {message}"
|
||||
},
|
||||
|
||||
+4
-1
@@ -1468,7 +1468,10 @@
|
||||
"failed": "[TODO: Translate] Failed to send workflow: {message}"
|
||||
},
|
||||
"restoreDownloaded": {
|
||||
"confirm": "[TODO: Translate] Restore this workflow to its downloaded version? Local edits will be overwritten.",
|
||||
"title": "[TODO: Translate] Restore Downloaded Version?",
|
||||
"message": "[TODO: Translate] This discards local edits and restores the workflow to its downloaded version.",
|
||||
"confirmButton": "[TODO: Translate] Restore",
|
||||
"restoring": "[TODO: Translate] Restoring workflow...",
|
||||
"success": "[TODO: Translate] Workflow restored to its downloaded version",
|
||||
"failed": "[TODO: Translate] Failed to restore workflow: {message}"
|
||||
},
|
||||
|
||||
+4
-1
@@ -1468,7 +1468,10 @@
|
||||
"failed": "[TODO: Translate] Failed to send workflow: {message}"
|
||||
},
|
||||
"restoreDownloaded": {
|
||||
"confirm": "[TODO: Translate] Restore this workflow to its downloaded version? Local edits will be overwritten.",
|
||||
"title": "[TODO: Translate] Restore Downloaded Version?",
|
||||
"message": "[TODO: Translate] This discards local edits and restores the workflow to its downloaded version.",
|
||||
"confirmButton": "[TODO: Translate] Restore",
|
||||
"restoring": "[TODO: Translate] Restoring workflow...",
|
||||
"success": "[TODO: Translate] Workflow restored to its downloaded version",
|
||||
"failed": "[TODO: Translate] Failed to restore workflow: {message}"
|
||||
},
|
||||
|
||||
+4
-1
@@ -1468,7 +1468,10 @@
|
||||
"failed": "[TODO: Translate] Failed to send workflow: {message}"
|
||||
},
|
||||
"restoreDownloaded": {
|
||||
"confirm": "[TODO: Translate] Restore this workflow to its downloaded version? Local edits will be overwritten.",
|
||||
"title": "[TODO: Translate] Restore Downloaded Version?",
|
||||
"message": "[TODO: Translate] This discards local edits and restores the workflow to its downloaded version.",
|
||||
"confirmButton": "[TODO: Translate] Restore",
|
||||
"restoring": "[TODO: Translate] Restoring workflow...",
|
||||
"success": "[TODO: Translate] Workflow restored to its downloaded version",
|
||||
"failed": "[TODO: Translate] Failed to restore workflow: {message}"
|
||||
},
|
||||
|
||||
+4
-1
@@ -1468,7 +1468,10 @@
|
||||
"failed": "[TODO: Translate] Failed to send workflow: {message}"
|
||||
},
|
||||
"restoreDownloaded": {
|
||||
"confirm": "[TODO: Translate] Restore this workflow to its downloaded version? Local edits will be overwritten.",
|
||||
"title": "[TODO: Translate] Restore Downloaded Version?",
|
||||
"message": "[TODO: Translate] This discards local edits and restores the workflow to its downloaded version.",
|
||||
"confirmButton": "[TODO: Translate] Restore",
|
||||
"restoring": "[TODO: Translate] Restoring workflow...",
|
||||
"success": "[TODO: Translate] Workflow restored to its downloaded version",
|
||||
"failed": "[TODO: Translate] Failed to restore workflow: {message}"
|
||||
},
|
||||
|
||||
+4
-1
@@ -1468,7 +1468,10 @@
|
||||
"failed": "[TODO: Translate] Failed to send workflow: {message}"
|
||||
},
|
||||
"restoreDownloaded": {
|
||||
"confirm": "[TODO: Translate] Restore this workflow to its downloaded version? Local edits will be overwritten.",
|
||||
"title": "[TODO: Translate] Restore Downloaded Version?",
|
||||
"message": "[TODO: Translate] This discards local edits and restores the workflow to its downloaded version.",
|
||||
"confirmButton": "[TODO: Translate] Restore",
|
||||
"restoring": "[TODO: Translate] Restoring workflow...",
|
||||
"success": "[TODO: Translate] Workflow restored to its downloaded version",
|
||||
"failed": "[TODO: Translate] Failed to restore workflow: {message}"
|
||||
},
|
||||
|
||||
@@ -243,6 +243,18 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Add confirmationModal registration (generic JS-driven confirmation)
|
||||
const confirmationModal = document.getElementById('confirmationModal');
|
||||
if (confirmationModal) {
|
||||
this.registerModal('confirmationModal', {
|
||||
element: confirmationModal,
|
||||
onClose: () => {
|
||||
this.getModal('confirmationModal').element.classList.remove('show');
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add deleteFolderModal registration
|
||||
const deleteFolderModal = document.getElementById('deleteFolderModal');
|
||||
if (deleteFolderModal) {
|
||||
@@ -453,6 +465,7 @@ export class ModalManager {
|
||||
id === "clearCacheModal" ||
|
||||
id === "bulkDeleteModal" ||
|
||||
id === "checkUpdatesConfirmModal" ||
|
||||
id === "confirmationModal" ||
|
||||
id === "deleteFolderModal" ||
|
||||
id === "resolveFilenameConflictsModal"
|
||||
) {
|
||||
|
||||
@@ -97,10 +97,10 @@ export function closeExcludeModal() {
|
||||
|
||||
export async function confirmExclude() {
|
||||
if (!pendingExcludePath) return;
|
||||
|
||||
|
||||
try {
|
||||
await getModelApiClient().excludeModel(pendingExcludePath);
|
||||
|
||||
|
||||
closeExcludeModal();
|
||||
|
||||
if (window.modelDuplicatesManager) {
|
||||
@@ -110,3 +110,68 @@ export async function confirmExclude() {
|
||||
console.error('Error excluding model:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the generic confirmation modal (#confirmationModal) and resolve with
|
||||
* the user's choice. Text is provided by the caller (already translated).
|
||||
* @param {Object} options
|
||||
* @param {string} options.title - Modal heading
|
||||
* @param {string} options.message - Body text
|
||||
* @param {string} options.confirmText - Confirm button label
|
||||
* @param {boolean} [options.danger=false] - Style the confirm button as destructive
|
||||
* @returns {Promise<boolean>} true when confirmed, false otherwise
|
||||
*/
|
||||
export function showConfirmationModal({ title, message, confirmText, danger = false }) {
|
||||
const modalElement = document.getElementById('confirmationModal');
|
||||
if (!modalElement) {
|
||||
// Template missing (unexpected): fall back to the browser dialog.
|
||||
return Promise.resolve(window.confirm(`${title}\n${message}`));
|
||||
}
|
||||
|
||||
const titleElement = modalElement.querySelector('[data-role="title"]');
|
||||
const messageElement = modalElement.querySelector('[data-role="message"]');
|
||||
const confirmButton = modalElement.querySelector('[data-action="confirm-confirmation"]');
|
||||
const cancelButton = modalElement.querySelector('[data-action="cancel-confirmation"]');
|
||||
if (!titleElement || !messageElement || !confirmButton || !cancelButton) {
|
||||
return Promise.resolve(window.confirm(`${title}\n${message}`));
|
||||
}
|
||||
|
||||
titleElement.textContent = title;
|
||||
messageElement.textContent = message;
|
||||
confirmButton.textContent = confirmText;
|
||||
confirmButton.classList.toggle('delete-btn', danger);
|
||||
confirmButton.classList.toggle('primary-btn', !danger);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
confirmButton.removeEventListener('click', handleConfirm);
|
||||
cancelButton.removeEventListener('click', handleCancel);
|
||||
};
|
||||
|
||||
const finalize = (proceed) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve(proceed);
|
||||
};
|
||||
|
||||
const handleConfirm = (event) => {
|
||||
event.preventDefault();
|
||||
finalize(true);
|
||||
modalManager.closeModal('confirmationModal');
|
||||
};
|
||||
|
||||
const handleCancel = (event) => {
|
||||
event.preventDefault();
|
||||
finalize(false);
|
||||
modalManager.closeModal('confirmationModal');
|
||||
};
|
||||
|
||||
confirmButton.addEventListener('click', handleConfirm);
|
||||
cancelButton.addEventListener('click', handleCancel);
|
||||
|
||||
modalManager.showModal('confirmationModal', null, () => finalize(false));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { copyToClipboard, showToast } from './uiHelpers.js';
|
||||
import { translate } from './i18nHelpers.js';
|
||||
import { showConfirmationModal } from './modalUtils.js';
|
||||
|
||||
/**
|
||||
* Push a library workflow into the running ComfyUI graph (plugin mode only;
|
||||
@@ -66,9 +67,18 @@ export async function updateWorkflowFromComfy(filePath) {
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export async function restoreWorkflow(filePath) {
|
||||
if (!window.confirm(translate('workflows.restoreDownloaded.confirm', {}))) {
|
||||
const confirmed = await showConfirmationModal({
|
||||
title: translate('workflows.restoreDownloaded.title', {}),
|
||||
message: translate('workflows.restoreDownloaded.message', {}),
|
||||
confirmText: translate('workflows.restoreDownloaded.confirmButton', {}),
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
// .json restores re-download from CivitAI, which can take a moment; keep
|
||||
// a blocking indicator up so the wait does not look like a dead click.
|
||||
state.loadingManager?.showSimpleLoading(translate('workflows.restoreDownloaded.restoring', {}));
|
||||
try {
|
||||
const result = await getModelApiClient(MODEL_TYPES.WORKFLOW).restore(filePath);
|
||||
showToast('workflows.restoreDownloaded.success', {}, 'success');
|
||||
@@ -79,5 +89,7 @@ export async function restoreWorkflow(filePath) {
|
||||
} catch (error) {
|
||||
console.error('Failed to restore workflow:', error);
|
||||
showToast('workflows.restoreDownloaded.failed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
state.loadingManager?.hide();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generic Confirmation Modal (title/message/confirm text are filled by JS;
|
||||
use showConfirmationModal() from utils/modalUtils.js) -->
|
||||
<div id="confirmationModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2 data-role="title"></h2>
|
||||
<p class="delete-message" data-role="message"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel-confirmation">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="primary-btn" data-action="confirm-confirmation"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filename Template Apply/Revert Confirmation Modal
|
||||
Self-managed by SettingsManager (NOT registered with ModalManager): it
|
||||
stacks above the settings modal, like the directory picker. -->
|
||||
|
||||
@@ -9,12 +9,17 @@ const updateFromComfyMock = vi.fn();
|
||||
const restoreMock = vi.fn();
|
||||
const resetAndReloadMock = vi.fn();
|
||||
const updateSingleItemMock = vi.fn();
|
||||
const showConfirmationModalMock = vi.fn();
|
||||
|
||||
const showSimpleLoadingMock = vi.fn();
|
||||
const hideLoadingMock = vi.fn();
|
||||
|
||||
const stateStub = {
|
||||
global: { settings: { standalone_mode: false } },
|
||||
currentPageType: 'workflows',
|
||||
pages: {},
|
||||
virtualScroller: { updateSingleItem: updateSingleItemMock, items: [] },
|
||||
loadingManager: { showSimpleLoading: showSimpleLoadingMock, hide: hideLoadingMock },
|
||||
};
|
||||
|
||||
vi.mock('../../../../static/js/utils/uiHelpers.js', () => ({
|
||||
@@ -48,6 +53,7 @@ vi.mock('../../../../static/js/managers/MoveManager.js', () => ({
|
||||
vi.mock('../../../../static/js/utils/modalUtils.js', () => ({
|
||||
showDeleteModal: vi.fn(),
|
||||
showExcludeModal: vi.fn(),
|
||||
showConfirmationModal: showConfirmationModalMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../static/js/components/ContextMenu/ModelContextMenuMixin.js', () => ({
|
||||
@@ -246,7 +252,7 @@ describe('WorkflowContextMenu', () => {
|
||||
});
|
||||
|
||||
it('restores the workflow on restore-download after confirmation', async () => {
|
||||
window.confirm = vi.fn(() => true);
|
||||
showConfirmationModalMock.mockResolvedValue(true);
|
||||
restoreMock.mockResolvedValue({ success: true, restored: 'downloaded', sha256: 'restoredsha' });
|
||||
|
||||
menu.handleMenuAction('restore-download');
|
||||
@@ -260,6 +266,13 @@ describe('WorkflowContextMenu', () => {
|
||||
'success'
|
||||
)
|
||||
);
|
||||
// The confirmation goes through the app's modal, not the browser dialog.
|
||||
expect(showConfirmationModalMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ danger: true })
|
||||
);
|
||||
// A blocking indicator covers the (potentially slow) re-download.
|
||||
expect(showSimpleLoadingMock).toHaveBeenCalled();
|
||||
expect(hideLoadingMock).toHaveBeenCalled();
|
||||
// The card is refreshed in place so it shows the "Updated" cue.
|
||||
expect(updateSingleItemMock).toHaveBeenCalledWith('/library/workflows/flow.json', {
|
||||
sha256: 'restoredsha',
|
||||
@@ -267,16 +280,17 @@ describe('WorkflowContextMenu', () => {
|
||||
});
|
||||
|
||||
it('does not call restore when the confirmation is declined', async () => {
|
||||
window.confirm = vi.fn(() => false);
|
||||
showConfirmationModalMock.mockResolvedValue(false);
|
||||
|
||||
menu.handleMenuAction('restore-download');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await vi.waitFor(() => expect(showConfirmationModalMock).toHaveBeenCalled());
|
||||
|
||||
expect(restoreMock).not.toHaveBeenCalled();
|
||||
expect(showSimpleLoadingMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error toast when restore fails', async () => {
|
||||
window.confirm = vi.fn(() => true);
|
||||
it('shows an error toast when restore fails and still hides the loading', async () => {
|
||||
showConfirmationModalMock.mockResolvedValue(true);
|
||||
const error = new Error('Workflow has no recorded CivitAI source to restore from');
|
||||
error.kind = 'no_source';
|
||||
restoreMock.mockRejectedValue(error);
|
||||
@@ -289,5 +303,6 @@ describe('WorkflowContextMenu', () => {
|
||||
'error'
|
||||
)
|
||||
);
|
||||
expect(hideLoadingMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user