chore: export dsl add loading (#35427)

This commit is contained in:
Joel
2026-04-20 16:47:15 +08:00
committed by GitHub
parent 39dc636b02
commit 3cd6ef4464
28 changed files with 169 additions and 20 deletions

View File

@@ -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<void>((resolve) => {
resolveConfirmExport = resolve
}))
const user = userEvent.setup()
await act(async () => {
render(
<AppInfoModals
{...defaultProps}
activeModal="exportWarning"
handleConfirmExport={handleConfirmExport}
/>,
)
})
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 () => {

View File

@@ -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<void>
exportCheck: () => void
handleConfirmExport: () => void
handleConfirmExport: () => Promise<void>
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 = ({
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton tone="default" onClick={handleConfirmExport}>
{t('operation.confirm', { ns: 'common' })}
<AlertDialogConfirmButton
tone="default"
loading={isConfirmingExport}
disabled={isConfirmingExport}
onClick={handleExportWarningConfirm}
>
{isConfirmingExport
? t('operation.exporting', { ns: 'common' })
: t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>

View File

@@ -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 () => {

View File

@@ -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<void>((resolve) => {
resolveConfirm = resolve
}))
const onClose = vi.fn()
const user = userEvent.setup()
render(
<DSLExportConfirmModal
envList={envList}
onConfirm={onConfirm}
onClose={onClose}
/>,
)
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(
<DSLExportConfirmModal

View File

@@ -5,7 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseLine, RiLock2Line } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import { Env } from '@/app/components/base/icons/src/vender/line/others'
@@ -13,7 +13,7 @@ import Modal from '@/app/components/base/modal'
export type DSLExportConfirmModalProps = {
envList: EnvironmentVariable[]
onConfirm: (state: boolean) => void
onConfirm: (state: boolean) => void | Promise<void>
onClose: () => void
}
@@ -25,11 +25,21 @@ const DSLExportConfirmModal = ({
const { t } = useTranslation()
const [exportSecrets, setExportSecrets] = useState<boolean>(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 (
<Modal
@@ -38,7 +48,10 @@ const DSLExportConfirmModal = ({
className={cn('w-[480px] max-w-[480px]')}
>
<div className="relative pb-6 title-2xl-semi-bold text-text-primary">{t('env.export.title', { ns: 'workflow' })}</div>
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}>
<div
className={cn('absolute top-4 right-4 p-2', !isExporting && 'cursor-pointer')}
onClick={() => !isExporting && onClose()}
>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
<div className="relative">
@@ -72,13 +85,31 @@ const DSLExportConfirmModal = ({
<Checkbox
className="shrink-0"
checked={exportSecrets}
disabled={isExporting}
onCheck={() => setExportSecrets(!exportSecrets)}
/>
<div className="cursor-pointer system-sm-medium text-text-primary" onClick={() => setExportSecrets(!exportSecrets)}>{t('env.export.checkbox', { ns: 'workflow' })}</div>
<div
className={cn('system-sm-medium text-text-primary', !isExporting && 'cursor-pointer')}
onClick={() => !isExporting && setExportSecrets(!exportSecrets)}
>
{t('env.export.checkbox', { ns: 'workflow' })}
</div>
</div>
<div className="flex flex-row-reverse pt-6">
<Button className="ml-2" variant="primary" onClick={submit}>{exportSecrets ? t('env.export.export', { ns: 'workflow' }) : t('env.export.ignore', { ns: 'workflow' })}</Button>
<Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
className="ml-2"
variant="primary"
loading={isExporting}
disabled={isExporting}
onClick={submit}
>
{isExporting
? t('operation.exporting', { ns: 'common' })
: exportSecrets
? t('env.export.export', { ns: 'workflow' })
: t('env.export.ignore', { ns: 'workflow' })}
</Button>
<Button disabled={isExporting} onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
</div>
</Modal>
)

View File

@@ -494,6 +494,7 @@
"operation.downloadSuccess": "اكتمل التنزيل.",
"operation.duplicate": "تكرار",
"operation.edit": "تعديل",
"operation.exporting": "جارٍ التصدير",
"operation.format": "تنسيق",
"operation.getForFree": "احصل عليه مجانا",
"operation.imageCopied": "تم نسخ الصورة",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -494,6 +494,7 @@
"operation.downloadSuccess": "دانلود کامل شد.",
"operation.duplicate": "تکرار",
"operation.edit": "ویرایش",
"operation.exporting": "در حال خروجی گرفتن",
"operation.format": "قالب",
"operation.getForFree": "دریافت رایگان",
"operation.imageCopied": "تصویر کپی شده",

View File

@@ -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",

View File

@@ -494,6 +494,7 @@
"operation.downloadSuccess": "डाउनलोड पूरा हुआ।",
"operation.duplicate": "डुप्लिकेट",
"operation.edit": "संपादित करें",
"operation.exporting": "निर्यात हो रहा है",
"operation.format": "फॉर्मेट",
"operation.getForFree": "मुफ्त में प्राप्त करें",
"operation.imageCopied": "कॉपी की गई छवि",

View File

@@ -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",

View File

@@ -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",

View File

@@ -494,6 +494,7 @@
"operation.downloadSuccess": "ダウンロード完了",
"operation.duplicate": "複製",
"operation.edit": "編集",
"operation.exporting": "エクスポート中",
"operation.format": "フォーマット",
"operation.getForFree": "無料で入手",
"operation.imageCopied": "コピーした画像",

View File

@@ -494,6 +494,7 @@
"operation.downloadSuccess": "다운로드 완료.",
"operation.duplicate": "중복",
"operation.edit": "편집",
"operation.exporting": "내보내는 중",
"operation.format": "형식",
"operation.getForFree": "무료로 받기",
"operation.imageCopied": "복사된 이미지",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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ă",

View File

@@ -494,6 +494,7 @@
"operation.downloadSuccess": "Загрузка завершена.",
"operation.duplicate": "Дублировать",
"operation.edit": "Редактировать",
"operation.exporting": "Экспорт",
"operation.format": "Формат",
"operation.getForFree": "Получить бесплатно",
"operation.imageCopied": "Скопированное изображение",

View File

@@ -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",

View File

@@ -494,6 +494,7 @@
"operation.downloadSuccess": "ดาวน์โหลดเสร็จสิ้นแล้ว.",
"operation.duplicate": "สำเนา",
"operation.edit": "แก้ไข",
"operation.exporting": "กำลังส่งออก",
"operation.format": "รูปแบบ",
"operation.getForFree": "รับฟรี",
"operation.imageCopied": "ภาพที่คัดลอก",

View File

@@ -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ü",

View File

@@ -494,6 +494,7 @@
"operation.downloadSuccess": "Завантаження завершено.",
"operation.duplicate": "дублікат",
"operation.edit": "Редагувати",
"operation.exporting": "Експорт",
"operation.format": "Формат",
"operation.getForFree": "Отримати безкоштовно",
"operation.imageCopied": "Скопійоване зображення",

View File

@@ -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",

View File

@@ -494,6 +494,7 @@
"operation.downloadSuccess": "下载完毕",
"operation.duplicate": "复制",
"operation.edit": "编辑",
"operation.exporting": "导出中",
"operation.format": "格式化",
"operation.getForFree": "免费获取",
"operation.imageCopied": "图片已复制",

View File

@@ -494,6 +494,7 @@
"operation.downloadSuccess": "下載完成。",
"operation.duplicate": "複製",
"operation.edit": "編輯",
"operation.exporting": "匯出中",
"operation.format": "格式",
"operation.getForFree": "免費獲取",
"operation.imageCopied": "複製的圖片",