From 53f7ff407a0cc375e01e0ae5d3f48299d647b7e1 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 29 Oct 2026 12:23:22 +0800 Subject: [PATCH] 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. --- locales/de.json | 5 +- locales/en.json | 5 +- locales/es.json | 5 +- locales/fr.json | 5 +- locales/he.json | 5 +- locales/ja.json | 5 +- locales/ko.json | 5 +- locales/ru.json | 5 +- locales/zh-CN.json | 5 +- locales/zh-TW.json | 5 +- static/js/managers/ModalManager.js | 13 ++++ static/js/utils/modalUtils.js | 69 ++++++++++++++++++- static/js/utils/workflowActions.js | 14 +++- .../components/modals/confirm_modals.html | 13 ++++ .../ContextMenu/workflowContextMenu.test.js | 25 +++++-- 15 files changed, 166 insertions(+), 18 deletions(-) diff --git a/locales/de.json b/locales/de.json index 68227fb7..a102bcaa 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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}" }, diff --git a/locales/en.json b/locales/en.json index 73354129..c854f8cf 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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}" }, diff --git a/locales/es.json b/locales/es.json index 2563fa6f..41622fef 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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}" }, diff --git a/locales/fr.json b/locales/fr.json index ea8121b0..d80ce1ee 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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}" }, diff --git a/locales/he.json b/locales/he.json index e25a830c..ce688bf8 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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}" }, diff --git a/locales/ja.json b/locales/ja.json index c82dcc64..f6ea9525 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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}" }, diff --git a/locales/ko.json b/locales/ko.json index 36c9c099..c6ec8bc0 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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}" }, diff --git a/locales/ru.json b/locales/ru.json index 14b54eaf..e1d35839 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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}" }, diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 3a18ee17..f0f34d9b 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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}" }, diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 05af93fc..9b01b91c 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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}" }, diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 20d5eda3..cedc09c9 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -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" ) { diff --git a/static/js/utils/modalUtils.js b/static/js/utils/modalUtils.js index 45ffb9ec..50e80407 100644 --- a/static/js/utils/modalUtils.js +++ b/static/js/utils/modalUtils.js @@ -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} 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)); + }); +} diff --git a/static/js/utils/workflowActions.js b/static/js/utils/workflowActions.js index 97920f06..aba43b82 100644 --- a/static/js/utils/workflowActions.js +++ b/static/js/utils/workflowActions.js @@ -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(); } } diff --git a/templates/components/modals/confirm_modals.html b/templates/components/modals/confirm_modals.html index ed28157c..e90bcc49 100644 --- a/templates/components/modals/confirm_modals.html +++ b/templates/components/modals/confirm_modals.html @@ -82,6 +82,19 @@ + + + diff --git a/tests/frontend/components/ContextMenu/workflowContextMenu.test.js b/tests/frontend/components/ContextMenu/workflowContextMenu.test.js index d5ffbe4a..3293d3c3 100644 --- a/tests/frontend/components/ContextMenu/workflowContextMenu.test.js +++ b/tests/frontend/components/ContextMenu/workflowContextMenu.test.js @@ -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(); }); });