diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx index 707b0da267..2fdd35cc43 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx @@ -75,9 +75,9 @@ const defaultProps = { setSecretEnvList: vi.fn(), onEdit: vi.fn(), onCopy: vi.fn(), - onExport: vi.fn(), + onExport: vi.fn(async () => {}), exportCheck: vi.fn(), - handleConfirmExport: vi.fn(), + handleConfirmExport: vi.fn(async () => {}), onConfirmDelete: vi.fn(), } @@ -238,6 +238,42 @@ describe('AppInfoModals', () => { expect(defaultProps.handleConfirmExport).toHaveBeenCalledTimes(1) }) + it('should disable export confirm button and avoid duplicate submits while confirming export', async () => { + let resolveConfirmExport: () => void + const handleConfirmExport = vi.fn(() => new Promise((resolve) => { + resolveConfirmExport = resolve + })) + const user = userEvent.setup() + + await act(async () => { + render( + , + ) + }) + + const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' }) + + const firstClick = user.click(confirmButton) + await waitFor(() => { + expect(confirmButton).toBeDisabled() + expect(confirmButton).toHaveTextContent('common.operation.exporting') + }) + await user.click(confirmButton) + + expect(handleConfirmExport).toHaveBeenCalledTimes(1) + + resolveConfirmExport!() + await firstClick + await waitFor(() => { + expect(confirmButton).not.toBeDisabled() + expect(confirmButton).toHaveTextContent('common.operation.confirm') + }) + }) + it('should call exportCheck when backup on importDSL modal', async () => { const user = userEvent.setup() await act(async () => { diff --git a/web/app/components/app-sidebar/app-info/app-info-modals.tsx b/web/app/components/app-sidebar/app-info/app-info-modals.tsx index 7d142e0cc6..9535725cd3 100644 --- a/web/app/components/app-sidebar/app-info/app-info-modals.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-modals.tsx @@ -13,7 +13,7 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import * as React from 'react' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import dynamic from '@/next/dynamic' @@ -34,7 +34,7 @@ type AppInfoModalsProps = { onCopy: DuplicateAppModalProps['onConfirm'] onExport: (include?: boolean) => Promise exportCheck: () => void - handleConfirmExport: () => void + handleConfirmExport: () => Promise onConfirmDelete: () => void } @@ -53,6 +53,7 @@ const AppInfoModals = ({ }: AppInfoModalsProps) => { const { t } = useTranslation() const [confirmDeleteInput, setConfirmDeleteInput] = useState('') + const [isConfirmingExport, setIsConfirmingExport] = useState(false) const isDeleteConfirmDisabled = confirmDeleteInput !== appDetail.name const handleDeleteDialogClose = () => { @@ -60,6 +61,19 @@ const AppInfoModals = ({ closeModal() } + const handleExportWarningConfirm = useCallback(async () => { + if (isConfirmingExport) + return + + setIsConfirmingExport(true) + try { + await handleConfirmExport() + } + finally { + setIsConfirmingExport(false) + } + }, [handleConfirmExport, isConfirmingExport]) + return ( <> {activeModal === 'switch' && ( @@ -161,8 +175,15 @@ const AppInfoModals = ({ {t('operation.cancel', { ns: 'common' })} - - {t('operation.confirm', { ns: 'common' })} + + {isConfirmingExport + ? t('operation.exporting', { ns: 'common' }) + : t('operation.confirm', { ns: 'common' })} diff --git a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts index 5ca0e85c18..262a8c7db0 100644 --- a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts +++ b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts @@ -62,7 +62,7 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) { timestamp: Date.now(), }) }) - .catch(() => {}) + .catch(() => { }) }, [appDetail?.id]) useEffect(() => { @@ -89,7 +89,7 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) { } }) }) - .catch(() => {}) + .catch(() => { }) return () => { disposed = true @@ -183,7 +183,6 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) { const handleConfirmExport = useCallback(async () => { if (!appDetail) return - closeModal() try { const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`) const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret') @@ -196,6 +195,9 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) { catch { toast(t('exportFailed', { ns: 'app' }), { type: 'error' }) } + finally { + closeModal() + } }, [appDetail, closeModal, onExport, t]) const onConfirmDelete = useCallback(async () => { diff --git a/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx b/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx index 1e0ba380cd..d7753d163e 100644 --- a/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx +++ b/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import DSLExportConfirmModal from '../dsl-export-confirm-modal' @@ -105,6 +105,42 @@ describe('DSLExportConfirmModal', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('should show exporting state and prevent duplicate submits while exporting', async () => { + let resolveConfirm: () => void + const onConfirm = vi.fn(() => new Promise((resolve) => { + resolveConfirm = resolve + })) + const onClose = vi.fn() + const user = userEvent.setup() + + render( + , + ) + + const confirmButton = screen.getByRole('button', { name: 'workflow.env.export.ignore' }) + + const firstClick = user.click(confirmButton) + await waitFor(() => { + expect(confirmButton).toBeDisabled() + expect(confirmButton).toHaveTextContent('common.operation.exporting') + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeDisabled() + }) + + await user.click(confirmButton) + + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onClose).not.toHaveBeenCalled() + + resolveConfirm!() + await firstClick + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) + }) + it('should render border separators for all rows except the last one', () => { render( void + onConfirm: (state: boolean) => void | Promise onClose: () => void } @@ -25,11 +25,21 @@ const DSLExportConfirmModal = ({ const { t } = useTranslation() const [exportSecrets, setExportSecrets] = useState(false) + const [isExporting, setIsExporting] = useState(false) - const submit = () => { - onConfirm(exportSecrets) - onClose() - } + const submit = useCallback(async () => { + if (isExporting) + return + + setIsExporting(true) + try { + await onConfirm(exportSecrets) + onClose() + } + finally { + setIsExporting(false) + } + }, [exportSecrets, isExporting, onClose, onConfirm]) return (
{t('env.export.title', { ns: 'workflow' })}
-
+
!isExporting && onClose()} + >
@@ -72,13 +85,31 @@ const DSLExportConfirmModal = ({ setExportSecrets(!exportSecrets)} /> -
setExportSecrets(!exportSecrets)}>{t('env.export.checkbox', { ns: 'workflow' })}
+
!isExporting && setExportSecrets(!exportSecrets)} + > + {t('env.export.checkbox', { ns: 'workflow' })} +
- - + +
) diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index 22ede7acc2..893b9bdbfe 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "اكتمل التنزيل.", "operation.duplicate": "تكرار", "operation.edit": "تعديل", + "operation.exporting": "جارٍ التصدير", "operation.format": "تنسيق", "operation.getForFree": "احصل عليه مجانا", "operation.imageCopied": "تم نسخ الصورة", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index 582bf758a8..f24f94fb22 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Download abgeschlossen.", "operation.duplicate": "Duplikat", "operation.edit": "Bearbeiten", + "operation.exporting": "Exportiere", "operation.format": "Format", "operation.getForFree": "Kostenlos erhalten", "operation.imageCopied": "Kopiertes Bild", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index d8a312d573..d4f72c242d 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Download Completed.", "operation.duplicate": "Duplicate", "operation.edit": "Edit", + "operation.exporting": "Exporting", "operation.format": "Format", "operation.getForFree": "Get for free", "operation.imageCopied": "Image copied", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index a7279bfc8f..649ac803aa 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Descarga completada.", "operation.duplicate": "Duplicar", "operation.edit": "Editar", + "operation.exporting": "Exportando", "operation.format": "Formato", "operation.getForFree": "Obtener gratis", "operation.imageCopied": "Imagen copiada", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index 7e90357db2..ba1179520e 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "دانلود کامل شد.", "operation.duplicate": "تکرار", "operation.edit": "ویرایش", + "operation.exporting": "در حال خروجی گرفتن", "operation.format": "قالب", "operation.getForFree": "دریافت رایگان", "operation.imageCopied": "تصویر کپی شده", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index fd92838b55..ea53de39d0 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Téléchargement terminé.", "operation.duplicate": "Dupliquer", "operation.edit": "Modifier", + "operation.exporting": "Exportation", "operation.format": "Format", "operation.getForFree": "Obtenez gratuitement", "operation.imageCopied": "Image copied", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index 4d2ac80d97..736618363f 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "डाउनलोड पूरा हुआ।", "operation.duplicate": "डुप्लिकेट", "operation.edit": "संपादित करें", + "operation.exporting": "निर्यात हो रहा है", "operation.format": "फॉर्मेट", "operation.getForFree": "मुफ्त में प्राप्त करें", "operation.imageCopied": "कॉपी की गई छवि", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index 74f5e3593b..8bb344acb8 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Unduh Selesai.", "operation.duplicate": "Duplikat", "operation.edit": "Mengedit", + "operation.exporting": "Mengekspor", "operation.format": "Format", "operation.getForFree": "Dapatkan gratis", "operation.imageCopied": "Gambar yang disalin", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index f994f5cc26..803586e584 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Download completato.", "operation.duplicate": "Duplica", "operation.edit": "Modifica", + "operation.exporting": "Esportazione in corso", "operation.format": "Formato", "operation.getForFree": "Ottieni gratuitamente", "operation.imageCopied": "Immagine copiata", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index 938c02f032..700e9aa11b 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "ダウンロード完了", "operation.duplicate": "複製", "operation.edit": "編集", + "operation.exporting": "エクスポート中", "operation.format": "フォーマット", "operation.getForFree": "無料で入手", "operation.imageCopied": "コピーした画像", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index e7ba49a8a3..280b7fcfce 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "다운로드 완료.", "operation.duplicate": "중복", "operation.edit": "편집", + "operation.exporting": "내보내는 중", "operation.format": "형식", "operation.getForFree": "무료로 받기", "operation.imageCopied": "복사된 이미지", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index 3fdf51d8b0..6336dd7001 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Download Completed.", "operation.duplicate": "Duplicate", "operation.edit": "Edit", + "operation.exporting": "Exporteren", "operation.format": "Format", "operation.getForFree": "Get for free", "operation.imageCopied": "Image copied", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index d623672981..ceeab09acd 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Pobieranie zakończone.", "operation.duplicate": "Duplikuj", "operation.edit": "Edytuj", + "operation.exporting": "Eksportowanie", "operation.format": "Format", "operation.getForFree": "Zdobądź za darmo", "operation.imageCopied": "Skopiowany obraz", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index 83d2183a37..cd7d8a5487 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Download concluído.", "operation.duplicate": "Duplicada", "operation.edit": "Editar", + "operation.exporting": "Exportando", "operation.format": "Formato", "operation.getForFree": "Obter gratuitamente", "operation.imageCopied": "Imagem copiada", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index 508c026156..0d6dfa6c0f 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Descărcarea a fost finalizată.", "operation.duplicate": "Duplică", "operation.edit": "Editează", + "operation.exporting": "Se exportă", "operation.format": "Format", "operation.getForFree": "Obține gratuit", "operation.imageCopied": "Imagine copiată", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index cdfdc6d372..9e984bfa6e 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Загрузка завершена.", "operation.duplicate": "Дублировать", "operation.edit": "Редактировать", + "operation.exporting": "Экспорт", "operation.format": "Формат", "operation.getForFree": "Получить бесплатно", "operation.imageCopied": "Скопированное изображение", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index ffc370eb51..ff4e230a88 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Prenos končan.", "operation.duplicate": "Podvoji", "operation.edit": "Uredi", + "operation.exporting": "Izvažanje", "operation.format": "Format", "operation.getForFree": "Dobite brezplačno", "operation.imageCopied": "Kopirana slika", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index b193c960cf..b0e20f5e4f 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "ดาวน์โหลดเสร็จสิ้นแล้ว.", "operation.duplicate": "สำเนา", "operation.edit": "แก้ไข", + "operation.exporting": "กำลังส่งออก", "operation.format": "รูปแบบ", "operation.getForFree": "รับฟรี", "operation.imageCopied": "ภาพที่คัดลอก", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 1ebaf13758..78b0ed28cd 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "İndirme Tamamlandı.", "operation.duplicate": "Çoğalt", "operation.edit": "Düzenle", + "operation.exporting": "Dışa aktarılıyor", "operation.format": "Format", "operation.getForFree": "Ücretsiz edinin", "operation.imageCopied": "Kopyalanan görüntü", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index c0cc746db1..e3892099d6 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Завантаження завершено.", "operation.duplicate": "дублікат", "operation.edit": "Редагувати", + "operation.exporting": "Експорт", "operation.format": "Формат", "operation.getForFree": "Отримати безкоштовно", "operation.imageCopied": "Скопійоване зображення", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index 8ce4211cda..cd8e9dd6fb 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "Tải xuống đã hoàn thành.", "operation.duplicate": "Nhân bản", "operation.edit": "Chỉnh sửa", + "operation.exporting": "Đang xuất", "operation.format": "Định dạng", "operation.getForFree": "Nhận miễn phí", "operation.imageCopied": "Hình ảnh sao chép", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index 924142e36f..b2bdf8f1f1 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "下载完毕", "operation.duplicate": "复制", "operation.edit": "编辑", + "operation.exporting": "导出中", "operation.format": "格式化", "operation.getForFree": "免费获取", "operation.imageCopied": "图片已复制", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index ec8e78cf81..e6ee39bc47 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -494,6 +494,7 @@ "operation.downloadSuccess": "下載完成。", "operation.duplicate": "複製", "operation.edit": "編輯", + "operation.exporting": "匯出中", "operation.format": "格式", "operation.getForFree": "免費獲取", "operation.imageCopied": "複製的圖片",