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:
Will Miao
2026-10-29 12:23:22 +08:00
parent f473678004
commit 53f7ff407a
15 changed files with 166 additions and 18 deletions
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}"
},
+13
View File
@@ -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"
) {
+67 -2
View File
@@ -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));
});
}
+13 -1
View File
@@ -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();
});
});