fix(web): improve DSL export feedback (#39409)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-07-22 17:40:47 +08:00 committed by GitHub
parent de07730548
commit 62db37c403
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 776 additions and 436 deletions

View File

@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, within } from 'storybook/test'
import { toast, ToastHost } from '.'
import { Button } from '../button'
const longToastTitle =
'operation error S3: PutObject, exceeded maximum number of attempts, 3, StatusCode: 0, RequestID: , HostID: , request send failed'
@ -155,58 +156,62 @@ const StackExamples = () => {
}
const PromiseExamples = () => {
const createPromiseToast = () => {
const request = new Promise<string>((resolve) => {
window.setTimeout(() => resolve('The deployment is now available in production.'), 1400)
const [pendingExample, setPendingExample] = React.useState<'success' | 'error' | null>(null)
const exportDsl = async (outcome: 'success' | 'error') => {
if (pendingExample) return
setPendingExample(outcome)
const request = new Promise<string>((resolve, reject) => {
window.setTimeout(() => {
if (outcome === 'success') resolve('customer-support-agent.yml')
else reject(new Error('The DSL could not be generated.'))
}, 1400)
})
void toast.promise(request, {
loading: {
type: 'info',
title: 'Deploying workflow',
description: 'Provisioning runtime and publishing the latest version.',
},
success: (result) => ({
type: 'success',
title: 'Deployment complete',
description: result,
}),
error: () => ({
type: 'error',
title: 'Deployment failed',
description: 'The release could not be completed.',
}),
})
}
await toast
.promise(request, {
loading: {
title: 'Preparing DSL export',
description: 'Collecting the app configuration and generating a YAML file.',
},
success: (fileName) => ({
title: 'Download started',
description: `${fileName} was sent to your browser.`,
timeout: 3000,
}),
error: () => ({
title: 'Export failed',
description: 'The DSL could not be generated. Try again.',
}),
})
.catch(() => undefined)
const createRejectingPromiseToast = () => {
const request = new Promise<string>((_, reject) => {
window.setTimeout(() => reject(new Error('intentional story failure')), 1200)
})
void toast.promise(request, {
loading: 'Validating model credentials…',
success: 'Credentials verified',
error: () => ({
type: 'error',
title: 'Credentials rejected',
description: 'The model provider returned an authentication error.',
}),
})
setPendingExample(null)
}
return (
<ExampleCard
eyebrow="Promise"
title="Async lifecycle"
description="The promise helper should swap the same toast through loading, success, and error states instead of growing the stack unnecessarily."
title="Export lifecycle"
description="A single toast follows the export from preparation to browser handoff, while the trigger prevents duplicate requests."
>
<button type="button" className={buttonClassName} onClick={createPromiseToast}>
Promise success
</button>
<button type="button" className={buttonClassName} onClick={createRejectingPromiseToast}>
Promise error
</button>
<Button
variant="secondary"
loading={pendingExample === 'success'}
disabled={pendingExample === 'error'}
onClick={() => exportDsl('success')}
>
Export DSL
</Button>
<Button
variant="secondary"
loading={pendingExample === 'error'}
disabled={pendingExample === 'success'}
onClick={() => exportDsl('error')}
>
Simulate failure
</Button>
</ExampleCard>
)
}

View File

@ -16,6 +16,11 @@ type ToastToneStyle = {
}
const TOAST_TONE_STYLES = {
loading: {
iconClassName: 'i-ri-loader-2-line animate-spin text-text-accent motion-reduce:animate-none',
gradientClassName:
'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent',
},
success: {
iconClassName: 'i-ri-checkbox-circle-fill text-text-success',
gradientClassName:
@ -41,7 +46,8 @@ const TOAST_TONE_STYLES = {
const toastCloseLabel = 'Close notification'
const toastViewportLabel = 'Notifications'
type ToastType = keyof typeof TOAST_TONE_STYLES
type ToastRenderType = keyof typeof TOAST_TONE_STYLES
type ToastType = Exclude<ToastRenderType, 'loading'>
type ToastAddOptions = Omit<
ToastManagerAddOptions<ToastData>,
@ -96,12 +102,12 @@ type ToastApi = {
const toastManager = BaseToast.createToastManager<ToastData>()
function isToastType(type: string): type is ToastType {
function isToastRenderType(type: string): type is ToastRenderType {
return Object.prototype.hasOwnProperty.call(TOAST_TONE_STYLES, type)
}
function getToastType(type?: string): ToastType | undefined {
return type && isToastType(type) ? type : undefined
function getToastRenderType(type?: string): ToastRenderType | undefined {
return type && isToastRenderType(type) ? type : undefined
}
function addToast(options: ToastAddOptions) {
@ -145,19 +151,19 @@ export const toast: ToastApi = Object.assign(showToast, {
promise: promiseToast,
})
function ToastIcon({ type }: { type?: ToastType }) {
function ToastIcon({ type }: { type?: ToastRenderType }) {
return type ? (
<span aria-hidden="true" className={cn('h-5 w-5', TOAST_TONE_STYLES[type].iconClassName)} />
) : null
}
function getToneGradientClasses(type?: ToastType) {
function getToneGradientClasses(type?: ToastRenderType) {
if (type) return TOAST_TONE_STYLES[type].gradientClassName
return 'from-background-default-subtle to-background-gradient-mask-transparent'
}
function ToastCard({ toast: toastItem }: { toast: ToastObject<ToastData> }) {
const toastType = getToastType(toastItem.type)
const toastType = getToastRenderType(toastItem.type)
return (
<BaseToast.Root

View File

@ -89,12 +89,25 @@ vi.mock('../app-operations', () => ({
primaryOperations,
secondaryOperations,
}: {
primaryOperations?: Array<{ id: string; title: string; onClick: () => void }>
primaryOperations?: Array<{
id: string
title: string
onClick: () => void
disabled?: boolean
loading?: boolean
}>
secondaryOperations?: Array<{ id: string; title: string; onClick: () => void; type?: string }>
}) => (
<div data-testid="app-operations">
{primaryOperations?.map((op) => (
<button key={op.id} type="button" data-testid={`op-${op.id}`} onClick={op.onClick}>
<button
key={op.id}
type="button"
data-testid={`op-${op.id}`}
data-loading={op.loading || undefined}
disabled={op.disabled}
onClick={op.onClick}
>
{op.title}
</button>
))}
@ -140,6 +153,7 @@ describe('AppInfoDetailPanel', () => {
show: true,
onClose: vi.fn(),
openModal: vi.fn(),
isExporting: false,
exportCheck: vi.fn(),
}
@ -247,6 +261,13 @@ describe('AppInfoDetailPanel', () => {
expect(defaultProps.exportCheck).toHaveBeenCalledTimes(1)
})
it('should show the export operation as loading while export is pending', () => {
render(<AppInfoDetailPanel {...defaultProps} isExporting />)
expect(screen.getByTestId('op-export')).toHaveAttribute('data-loading', 'true')
expect(screen.getByTestId('op-export')).not.toBeDisabled()
})
it('should render delete operation', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('op-delete')).toBeInTheDocument()

View File

@ -2,7 +2,6 @@ import type { App, AppSSO } from '@/types/app'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { expectLoadingButton } from '@/test/button'
import { AppModeEnum } from '@/types/app'
import AppInfoModals from '../app-info-modals'
@ -131,6 +130,7 @@ const defaultProps = {
onEdit: vi.fn(),
onCopy: vi.fn(),
onExport: vi.fn(async () => {}),
isExporting: false,
exportCheck: vi.fn(),
handleConfirmExport: vi.fn(async () => {}),
onConfirmDelete: vi.fn(),
@ -305,43 +305,14 @@ 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()
it('should show the export warning confirmation as pending during export', async () => {
await act(async () => {
render(
<AppInfoModals
{...defaultProps}
activeModal="exportWarning"
handleConfirmExport={handleConfirmExport}
/>,
)
render(<AppInfoModals {...defaultProps} activeModal="exportWarning" isExporting />)
})
const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
const firstClick = user.click(confirmButton)
await waitFor(() => {
expectLoadingButton(confirmButton)
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')
})
expect(
await screen.findByRole('button', { name: 'common.operation.exporting' }),
).toBeInTheDocument()
})
it('should call exportCheck when backup on importDSL modal', async () => {

View File

@ -20,11 +20,12 @@ const mockInvalidateAppList = vi.fn()
const mockSetAppDetail = vi.fn()
const mockUpdateAppInfo = vi.fn()
const mockCopyApp = vi.fn()
const mockExportAppConfig = vi.fn()
const mockExportAppDsl = vi.fn()
const mockExportState = { isExporting: false }
const mockExportWorkflowAppDsl = vi.fn()
const mockWorkflowExportState = { isExporting: false }
const mockDeleteApp = vi.fn()
const mockFetchAppDetail = vi.fn()
const mockFetchWorkflowDraft = vi.fn()
const mockDownloadBlob = vi.fn()
const mockGetSocket = vi.fn()
const mockOnAppMetaUpdate = vi.fn()
const mockSetQueryData = vi.fn()
@ -54,6 +55,17 @@ vi.mock('@/app/components/app/store', () => ({
}),
}))
vi.mock('@/app/components/app/use-export-app-dsl', () => ({
useExportAppDsl: () => ({
exportAppDsl: mockExportAppDsl,
isExporting: mockExportState.isExporting,
}),
useExportWorkflowAppDsl: () => ({
exportWorkflowAppDsl: mockExportWorkflowAppDsl,
isExporting: mockWorkflowExportState.isExporting,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: Object.assign(toastMocks.api, {
success: vi.fn((message, options) => toastMocks.call({ type: 'success', message, ...options })),
@ -84,19 +96,10 @@ vi.mock('@tanstack/react-query', () => ({
vi.mock('@/service/apps', () => ({
updateAppInfo: (...args: unknown[]) => mockUpdateAppInfo(...args),
copyApp: (...args: unknown[]) => mockCopyApp(...args),
exportAppConfig: (...args: unknown[]) => mockExportAppConfig(...args),
deleteApp: (...args: unknown[]) => mockDeleteApp(...args),
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
vi.mock('@/utils/app-redirection', () => ({
getRedirection: vi.fn(),
}))
@ -116,6 +119,10 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', ()
describe('useAppInfoActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExportState.isExporting = false
mockExportAppDsl.mockResolvedValue({ status: 'downloaded' })
mockWorkflowExportState.isExporting = false
mockExportWorkflowAppDsl.mockResolvedValue({ status: 'downloaded' })
mockOnAppMetaUpdate.mockReturnValue(() => {})
mockGetSocket.mockReturnValue(null)
mockSetQueryData.mockReset()
@ -374,29 +381,18 @@ describe('useAppInfoActions', () => {
})
describe('onExport', () => {
it('should export app config and trigger download', async () => {
mockExportAppConfig.mockResolvedValue({ data: 'yaml-content' })
it('should export the app DSL', async () => {
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onExport(false)
})
expect(mockExportAppConfig).toHaveBeenCalledWith({ appID: 'app-1', include: false })
expect(mockDownloadBlob).toHaveBeenCalled()
})
it('should notify error on export failure', async () => {
mockExportAppConfig.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.onExport()
expect(mockExportAppDsl).toHaveBeenCalledWith({
appId: 'app-1',
appName: 'Test App',
includeSecret: false,
})
expect(toastMocks.call).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' })
})
})
@ -410,21 +406,19 @@ describe('useAppInfoActions', () => {
await result.current.onExport()
})
expect(mockExportAppConfig).not.toHaveBeenCalled()
expect(mockExportAppDsl).not.toHaveBeenCalled()
})
})
describe('exportCheck', () => {
it('should call onExport directly for non-workflow modes', async () => {
mockExportAppConfig.mockResolvedValue({ data: 'yaml' })
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.exportCheck()
})
expect(mockExportAppConfig).toHaveBeenCalled()
expect(mockExportAppDsl).toHaveBeenCalled()
})
it('should open export warning modal for workflow mode', async () => {
@ -462,32 +456,31 @@ describe('useAppInfoActions', () => {
await result.current.exportCheck()
})
expect(mockExportAppConfig).not.toHaveBeenCalled()
expect(mockExportAppDsl).not.toHaveBeenCalled()
})
})
describe('handleConfirmExport', () => {
it('should export directly when no secret env variables', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: [{ value_type: 'string' }],
})
mockExportAppConfig.mockResolvedValue({ data: 'yaml' })
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.handleConfirmExport()
})
expect(mockExportAppConfig).toHaveBeenCalled()
expect(mockExportWorkflowAppDsl).toHaveBeenCalledWith({
appId: 'app-1',
appName: 'Test App',
})
})
it('should set secret env list when secret variables exist', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
const secretVars = [{ value_type: 'secret', key: 'API_KEY' }]
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: secretVars,
const secretVars = [{ value_type: 'secret', name: 'API_KEY', value: 'secret' }]
mockExportWorkflowAppDsl.mockResolvedValue({
status: 'confirmation-required',
secretEnvList: secretVars,
})
const { result } = renderHook(() => useAppInfoActions({}))
@ -498,18 +491,6 @@ describe('useAppInfoActions', () => {
expect(result.current.secretEnvList).toEqual(secretVars)
})
it('should notify error on workflow draft fetch failure', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.handleConfirmExport()
})
expect(toastMocks.call).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' })
})
})
describe('handleConfirmExport - early return', () => {
@ -522,24 +503,7 @@ describe('useAppInfoActions', () => {
await result.current.handleConfirmExport()
})
expect(mockFetchWorkflowDraft).not.toHaveBeenCalled()
})
})
describe('handleConfirmExport - with environment variables', () => {
it('should handle empty environment_variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: undefined,
})
mockExportAppConfig.mockResolvedValue({ data: 'yaml' })
const { result } = renderHook(() => useAppInfoActions({}))
await act(async () => {
await result.current.handleConfirmExport()
})
expect(mockExportAppConfig).toHaveBeenCalled()
expect(mockExportWorkflowAppDsl).not.toHaveBeenCalled()
})
})

View File

@ -29,6 +29,7 @@ type AppInfoDetailPanelProps = {
show: boolean
onClose: () => void
openModal: (modal: Exclude<AppInfoModalType, null>) => void
isExporting: boolean
exportCheck: () => void
}
@ -37,6 +38,7 @@ const AppInfoDetailPanel = ({
show,
onClose,
openModal,
isExporting,
exportCheck,
}: AppInfoDetailPanelProps) => {
const { t } = useTranslation()
@ -82,11 +84,12 @@ const AppInfoDetailPanel = ({
title: t(($) => $.export, { ns: 'app' }),
icon: <RiFileDownloadLine />,
onClick: exportCheck,
loading: isExporting,
},
]
: []),
],
[appACLCapabilities, canCreateApp, t, openModal, exportCheck],
[appACLCapabilities, canCreateApp, t, openModal, exportCheck, isExporting],
)
const secondaryOperations = useMemo<Operation[]>(

View File

@ -1,7 +1,7 @@
import type { EnvironmentVariableItemResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { AppInfoModalType } from './use-app-info-actions'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
import {
AlertDialog,
@ -36,11 +36,12 @@ type AppInfoModalsProps = {
appDetail: App & Partial<AppSSO>
activeModal: AppInfoModalType
closeModal: () => void
secretEnvList: EnvironmentVariable[]
setSecretEnvList: (list: EnvironmentVariable[]) => void
secretEnvList: EnvironmentVariableItemResponse[]
setSecretEnvList: (list: EnvironmentVariableItemResponse[]) => void
onEdit: CreateAppModalProps['onConfirm']
onCopy: DuplicateAppModalProps['onConfirm']
onExport: (include?: boolean) => Promise<void>
isExporting: boolean
exportCheck: () => void
handleConfirmExport: () => Promise<void>
onConfirmDelete: () => void
@ -55,13 +56,13 @@ const AppInfoModals = ({
onEdit,
onCopy,
onExport,
isExporting,
exportCheck,
handleConfirmExport,
onConfirmDelete,
}: AppInfoModalsProps) => {
const { t } = useTranslation()
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
const [isConfirmingExport, setIsConfirmingExport] = useState(false)
const [isSecretExporting, setIsSecretExporting] = useState(false)
const isDeleteConfirmDisabled = confirmDeleteInput !== appDetail.name
const exportDialogMode =
@ -73,17 +74,6 @@ const AppInfoModals = ({
closeModal()
}
const handleExportWarningConfirm = useCallback(async () => {
if (isConfirmingExport) return
setIsConfirmingExport(true)
try {
await handleConfirmExport()
} finally {
setIsConfirmingExport(false)
}
}, [handleConfirmExport, isConfirmingExport])
const handleExportDialogClose = useCallback(() => {
if (exportDialogMode === 'secret') {
setSecretEnvList([])
@ -95,11 +85,11 @@ const AppInfoModals = ({
const handleExportDialogOpenChange = useCallback(
(open: boolean) => {
if (open || isConfirmingExport || isSecretExporting) return
if (open || isExporting || isSecretExporting) return
handleExportDialogClose()
},
[handleExportDialogClose, isConfirmingExport, isSecretExporting],
[handleExportDialogClose, isExporting, isSecretExporting],
)
return (
@ -234,11 +224,10 @@ const AppInfoModals = ({
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="default"
loading={isConfirmingExport}
disabled={isConfirmingExport}
onClick={handleExportWarningConfirm}
loading={isExporting}
onClick={handleConfirmExport}
>
{isConfirmingExport
{isExporting
? t(($) => $['operation.exporting'], { ns: 'common' })
: t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>

View File

@ -16,6 +16,8 @@ export type Operation = {
title: string
icon: JSX.Element
onClick: () => void
disabled?: boolean
loading?: boolean
type?: 'divider'
}
@ -127,6 +129,8 @@ const AppOperations = ({
size="small"
variant="secondary"
className="gap-px focus-visible:ring-inset"
disabled={operation.disabled}
loading={operation.loading}
tabIndex={-1}
>
{cloneElement(operation.icon, {
@ -158,6 +162,8 @@ const AppOperations = ({
size="small"
variant="secondary"
className="gap-px focus-visible:ring-inset"
disabled={operation.disabled}
loading={operation.loading}
onClick={operation.onClick}
>
{cloneElement(operation.icon, {
@ -195,7 +201,12 @@ const AppOperations = ({
item.type === 'divider' ? (
<DropdownMenuSeparator key={item.id} />
) : (
<DropdownMenuItem key={item.id} className="gap-x-1 px-1.5" onClick={item.onClick}>
<DropdownMenuItem
key={item.id}
className="gap-x-1 px-1.5"
disabled={item.disabled}
onClick={item.onClick}
>
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
<span className="system-md-regular text-text-secondary">{item.title}</span>
</DropdownMenuItem>

View File

@ -37,6 +37,7 @@ const AppInfoDetailLayer = ({ actions, open = actions.panelOpen }: AppInfoDetail
onEdit,
onCopy,
onExport,
isExporting,
exportCheck,
handleConfirmExport,
onConfirmDelete,
@ -51,6 +52,7 @@ const AppInfoDetailLayer = ({ actions, open = actions.panelOpen }: AppInfoDetail
show={open}
onClose={closePanel}
openModal={openModal}
isExporting={isExporting}
exportCheck={exportCheck}
/>
<AppInfoModals
@ -62,6 +64,7 @@ const AppInfoDetailLayer = ({ actions, open = actions.panelOpen }: AppInfoDetail
onEdit={onEdit}
onCopy={onCopy}
onExport={onExport}
isExporting={isExporting}
exportCheck={exportCheck}
handleConfirmExport={handleConfirmExport}
onConfirmDelete={onConfirmDelete}

View File

@ -1,22 +1,21 @@
import type { EnvironmentVariableItemResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { Dispatch, SetStateAction } from 'react'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/use-export-app-dsl'
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRouter } from '@/next/navigation'
import { copyApp, deleteApp, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps'
import { copyApp, deleteApp, fetchAppDetail, updateAppInfo } from '@/service/apps'
import { appDetailQueryKeyPrefix, useInvalidateAppList } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { downloadBlob } from '@/utils/download'
export type AppInfoModalType =
| 'edit'
@ -36,10 +35,10 @@ type AppInfoUiState = {
resetKey?: string
panelOpen: boolean
activeModal: AppInfoModalType
secretEnvList: EnvironmentVariable[]
secretEnvList: EnvironmentVariableItemResponse[]
}
const emptySecretEnvList: EnvironmentVariable[] = []
const emptySecretEnvList: EnvironmentVariableItemResponse[] = []
const createInitialUiState = (resetKey?: string): AppInfoUiState => ({
resetKey,
@ -64,6 +63,9 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction
const appDetail = useAppStore((state) => state.appDetail)
const setAppDetail = useAppStore((state) => state.setAppDetail)
const invalidateAppList = useInvalidateAppList()
const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl()
const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } = useExportWorkflowAppDsl()
const isExporting = isAppDslExporting || isWorkflowAppDslExporting
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
@ -99,7 +101,7 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction
[resetKey],
)
const setSecretEnvList = useCallback<Dispatch<SetStateAction<EnvironmentVariable[]>>>(
const setSecretEnvList = useCallback<Dispatch<SetStateAction<EnvironmentVariableItemResponse[]>>>(
(value) => {
setUiState((state) => {
const current = getCurrentUiState(state, resetKey)
@ -249,50 +251,33 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction
const onExport = useCallback(
async (include = false) => {
if (!appDetail) return
try {
const { data } = await exportAppConfig({ appID: appDetail.id, include })
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${appDetail.name}.yml` })
} catch {
toast(
t(($) => $.exportFailed, { ns: 'app' }),
{ type: 'error' },
)
}
await exportAppDsl({
appId: appDetail.id,
appName: appDetail.name,
includeSecret: include,
})
},
[appDetail, t],
[appDetail, exportAppDsl],
)
const exportCheck = useCallback(async () => {
if (!appDetail) return
if (!appDetail || isExporting) return
if (appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.ADVANCED_CHAT) {
onExport()
return
}
setActiveModal('exportWarning')
}, [appDetail, onExport, setActiveModal])
}, [appDetail, isExporting, onExport, setActiveModal])
const handleConfirmExport = useCallback(async () => {
if (!appDetail) return
try {
const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`)
const list = (workflowDraft.environment_variables || []).filter(
(env) => env.value_type === 'secret',
)
if (list.length === 0) {
onExport()
return
}
setSecretEnvList(list)
} catch {
toast(
t(($) => $.exportFailed, { ns: 'app' }),
{ type: 'error' },
)
} finally {
closeModal()
}
}, [appDetail, closeModal, onExport, setSecretEnvList, t])
const result = await exportWorkflowAppDsl({
appId: appDetail.id,
appName: appDetail.name,
})
if (result.status === 'confirmation-required') setSecretEnvList(result.secretEnvList)
closeModal()
}, [appDetail, closeModal, exportWorkflowAppDsl, setSecretEnvList])
const onConfirmDelete = useCallback(async () => {
if (!appDetail) return
@ -328,6 +313,7 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction
onEdit,
onCopy,
onExport,
isExporting,
exportCheck,
handleConfirmExport,
onConfirmDelete,

View File

@ -0,0 +1,266 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useExportAppDsl, useExportWorkflowAppDsl } from '../use-export-app-dsl'
const mocks = vi.hoisted(() => ({
downloadBlob: vi.fn(),
exportAppDsl: vi.fn(),
getEnvironmentVariables: vi.fn(),
toastError: vi.fn(),
toastPromise: vi.fn((promise: Promise<unknown>) => promise),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
apps: {
byAppId: {
export: {
get: mocks.exportAppDsl,
},
workflows: {
draft: {
environmentVariables: {
get: mocks.getEnvironmentVariables,
},
},
},
},
},
},
}))
vi.mock('@/utils/download', () => ({
downloadBlob: mocks.downloadBlob,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: mocks.toastError,
promise: mocks.toastPromise,
},
}))
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
mutations: { retry: false },
},
})
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
}
describe('useExportAppDsl', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getEnvironmentVariables.mockResolvedValue({ items: [] })
})
it('exports through the generated client and hands the YAML file to the browser', async () => {
mocks.exportAppDsl.mockResolvedValue({ data: 'kind: app\nversion: 0.1.5\n' })
const { result } = renderHook(() => useExportAppDsl(), { wrapper: createWrapper() })
await act(async () => {
await result.current.exportAppDsl({
appId: '4f6ae8f8-86c8-4ec8-82ef-e27f5932692b',
appName: 'Support Agent',
})
})
expect(mocks.exportAppDsl).toHaveBeenCalledWith(
{
params: { app_id: '4f6ae8f8-86c8-4ec8-82ef-e27f5932692b' },
query: { include_secret: false },
},
{ context: { silent: true } },
)
expect(mocks.toastPromise).toHaveBeenCalledTimes(1)
expect(mocks.getEnvironmentVariables).not.toHaveBeenCalled()
expect(mocks.downloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: 'Support Agent.yml',
})
const [{ data }] = mocks.downloadBlob.mock.calls[0] as [{ data: Blob }]
expect(await data.text()).toBe('kind: app\nversion: 0.1.5\n')
})
it('exposes pending state until the export command settles', async () => {
let resolveExport: ((value: { data: string }) => void) | undefined
mocks.exportAppDsl.mockImplementation(
() =>
new Promise((resolve) => {
resolveExport = resolve
}),
)
const { result } = renderHook(() => useExportAppDsl(), { wrapper: createWrapper() })
let exportPromise: Promise<unknown> | undefined
await act(async () => {
exportPromise = result.current.exportAppDsl({
appId: '4f6ae8f8-86c8-4ec8-82ef-e27f5932692b',
appName: 'Support Agent',
includeSecret: true,
})
await Promise.resolve()
})
await waitFor(() => {
expect(result.current.isExporting).toBe(true)
})
await act(async () => {
resolveExport?.({ data: 'kind: app\n' })
await exportPromise
})
await waitFor(() => {
expect(result.current.isExporting).toBe(false)
})
expect(mocks.exportAppDsl).toHaveBeenCalledWith(
expect.objectContaining({ query: { include_secret: true } }),
expect.anything(),
)
})
it('lets the promise toast own errors without triggering a download', async () => {
mocks.exportAppDsl.mockRejectedValue(new Error('Export failed'))
const { result } = renderHook(() => useExportAppDsl(), { wrapper: createWrapper() })
await act(async () => {
await expect(
result.current.exportAppDsl({
appId: '4f6ae8f8-86c8-4ec8-82ef-e27f5932692b',
appName: 'Support Agent',
}),
).resolves.toEqual({ status: 'failed' })
})
expect(mocks.downloadBlob).not.toHaveBeenCalled()
})
})
describe('useExportWorkflowAppDsl', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('checks generated workflow environment variables before exporting', async () => {
mocks.getEnvironmentVariables.mockResolvedValue({ items: [] })
mocks.exportAppDsl.mockResolvedValue({ data: 'kind: app\n' })
const { result } = renderHook(() => useExportWorkflowAppDsl(), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.exportWorkflowAppDsl({
appId: 'workflow-app-id',
appName: 'Support Workflow',
})
})
expect(mocks.getEnvironmentVariables).toHaveBeenCalledWith(
{ params: { app_id: 'workflow-app-id' } },
{ context: { silent: true } },
)
expect(mocks.getEnvironmentVariables.mock.invocationCallOrder[0]).toBeLessThan(
mocks.exportAppDsl.mock.invocationCallOrder[0]!,
)
expect(mocks.downloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: 'Support Workflow.yml',
})
})
it('returns generated secret variables without starting a download', async () => {
const secretEnvList = [
{
id: 'secret-id',
name: 'API_KEY',
value: 'secret',
value_type: 'secret',
},
]
mocks.getEnvironmentVariables.mockResolvedValue({ items: secretEnvList })
const { result } = renderHook(() => useExportWorkflowAppDsl(), {
wrapper: createWrapper(),
})
await act(async () => {
await expect(
result.current.exportWorkflowAppDsl({
appId: 'workflow-app-id',
appName: 'Support Workflow',
}),
).resolves.toEqual({ status: 'confirmation-required', secretEnvList })
})
expect(mocks.exportAppDsl).not.toHaveBeenCalled()
expect(mocks.toastPromise).not.toHaveBeenCalled()
})
it('owns workflow preflight errors without starting a download', async () => {
mocks.getEnvironmentVariables.mockRejectedValue(new Error('Draft unavailable'))
const { result } = renderHook(() => useExportWorkflowAppDsl(), {
wrapper: createWrapper(),
})
await act(async () => {
await expect(
result.current.exportWorkflowAppDsl({
appId: 'workflow-app-id',
appName: 'Support Workflow',
}),
).resolves.toEqual({ status: 'failed' })
})
expect(mocks.toastError).toHaveBeenCalledWith('app.exportFailed')
expect(mocks.exportAppDsl).not.toHaveBeenCalled()
})
it('keeps one pending lifecycle across the draft check and export', async () => {
let resolveEnvironmentVariables: ((value: { items: [] }) => void) | undefined
let resolveExport: ((value: { data: string }) => void) | undefined
mocks.getEnvironmentVariables.mockImplementation(
() =>
new Promise((resolve) => {
resolveEnvironmentVariables = resolve
}),
)
mocks.exportAppDsl.mockImplementation(
() =>
new Promise((resolve) => {
resolveExport = resolve
}),
)
const { result } = renderHook(() => useExportWorkflowAppDsl(), {
wrapper: createWrapper(),
})
let exportPromise: Promise<unknown> | undefined
await act(async () => {
exportPromise = result.current.exportWorkflowAppDsl({
appId: 'workflow-app-id',
appName: 'Support Workflow',
})
await Promise.resolve()
})
await waitFor(() => expect(result.current.isExporting).toBe(true))
expect(mocks.exportAppDsl).not.toHaveBeenCalled()
await act(async () => {
resolveEnvironmentVariables?.({ items: [] })
await waitFor(() => expect(mocks.exportAppDsl).toHaveBeenCalledTimes(1))
})
expect(result.current.isExporting).toBe(true)
await act(async () => {
resolveExport?.({ data: 'kind: app\n' })
await exportPromise
})
await waitFor(() => expect(result.current.isExporting).toBe(false))
})
})

View File

@ -0,0 +1,148 @@
'use client'
import type { EnvironmentVariableItemResponse } from '@dify/contracts/api/console/apps/types.gen'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleClient } from '@/service/client'
import { downloadBlob } from '@/utils/download'
type ExportAppDslInput = {
appId: string
appName: string
includeSecret?: boolean
}
type ExportWorkflowAppDslInput = Pick<ExportAppDslInput, 'appId' | 'appName'>
type ExportAppDslResult = { status: 'downloaded' } | { status: 'failed' }
type ExportAppDslMessages = {
loading: string
success: string
error: string
}
export type ExportWorkflowAppDslResult =
| ExportAppDslResult
| {
status: 'confirmation-required'
secretEnvList: EnvironmentVariableItemResponse[]
}
async function getSecretEnvironmentVariables(appId: string) {
const { items } = await consoleClient.apps.byAppId.workflows.draft.environmentVariables.get(
{ params: { app_id: appId } },
{ context: { silent: true } },
)
return items.filter((environmentVariable) => environmentVariable.value_type === 'secret')
}
async function exportAppDslFile({ appId, appName, includeSecret = false }: ExportAppDslInput) {
const { data } = await consoleClient.apps.byAppId.export.get(
{
params: { app_id: appId },
query: { include_secret: includeSecret },
},
{ context: { silent: true } },
)
downloadBlob({
data: new Blob([data], { type: 'application/yaml' }),
fileName: `${appName}.yml`,
})
}
async function downloadAppDsl(input: ExportAppDslInput, messages: ExportAppDslMessages) {
await toast.promise(exportAppDslFile(input), {
loading: {
title: messages.loading,
},
success: {
title: messages.success,
timeout: 3000,
},
error: {
title: messages.error,
},
})
return { status: 'downloaded' } as const
}
function useExportAppDslMessages() {
const { t: tApp } = useTranslation('app')
const { t: tCommon } = useTranslation('common')
return {
loading: tCommon(($) => $['operation.exporting']),
success: tCommon(($) => $['operation.downloadSuccess']),
error: tApp(($) => $.exportFailed),
}
}
/** Exports Agent, Chat, and Completion apps without reading a workflow draft. */
export function useExportAppDsl() {
const messages = useExportAppDslMessages()
const { mutateAsync, isPending } = useMutation({
mutationFn: (input: ExportAppDslInput): Promise<ExportAppDslResult> =>
downloadAppDsl(input, messages),
})
const exportAppDsl = useCallback(
async (input: ExportAppDslInput): Promise<ExportAppDslResult> => {
try {
return await mutateAsync(input)
} catch {
return { status: 'failed' }
}
},
[mutateAsync],
)
return {
exportAppDsl,
isExporting: isPending,
}
}
/** Checks Workflow and Advanced Chat secrets before exporting their DSL. */
export function useExportWorkflowAppDsl() {
const messages = useExportAppDslMessages()
const { mutateAsync, isPending } = useMutation({
mutationFn: async (input: ExportWorkflowAppDslInput): Promise<ExportWorkflowAppDslResult> => {
let secretEnvList: EnvironmentVariableItemResponse[]
try {
secretEnvList = await getSecretEnvironmentVariables(input.appId)
} catch (error) {
toast.error(messages.error)
throw error
}
if (secretEnvList.length > 0)
return {
status: 'confirmation-required',
secretEnvList,
}
return downloadAppDsl(input, messages)
},
})
const exportWorkflowAppDsl = useCallback(
async (input: ExportWorkflowAppDslInput): Promise<ExportWorkflowAppDslResult> => {
try {
return await mutateAsync(input)
} catch {
return { status: 'failed' }
}
},
[mutateAsync],
)
return {
exportWorkflowAppDsl,
isExporting: isPending,
}
}

View File

@ -6,7 +6,6 @@ import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/ta
import { AccessMode } from '@/models/access-control'
import * as appsService from '@/service/apps'
import * as exploreService from '@/service/explore'
import * as workflowService from '@/service/workflow'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
@ -19,6 +18,19 @@ const mockUserCanAccessApp = vi.hoisted(() => ({
result: true as boolean | undefined,
isLoading: false,
}))
const mockAppDslExport = vi.hoisted(() => ({
exportAppDsl: vi.fn(),
isExporting: false,
}))
const mockWorkflowAppDslExport = vi.hoisted(() => ({
exportWorkflowAppDsl: vi.fn(),
isExporting: false,
}))
vi.mock('@/app/components/app/use-export-app-dsl', () => ({
useExportAppDsl: () => mockAppDslExport,
useExportWorkflowAppDsl: () => mockWorkflowAppDslExport,
}))
const render = (ui: React.ReactElement) =>
renderWithConsoleQuery(ui, {
@ -116,7 +128,6 @@ vi.mock('@/service/apps', () => ({
deleteApp: vi.fn(() => Promise.resolve()),
updateAppInfo: vi.fn(() => Promise.resolve()),
copyApp: vi.fn(() => Promise.resolve({ id: 'new-app-id' })),
exportAppConfig: vi.fn(() => Promise.resolve({ data: 'yaml: content' })),
}))
const mockDeleteAppMutation = vi.fn(() => Promise.resolve())
@ -134,10 +145,6 @@ vi.mock('@/service/use-apps', () => ({
}),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn(() => Promise.resolve({ environment_variables: [] })),
}))
vi.mock('@/service/explore', () => ({
fetchInstalledAppList: vi.fn(() => Promise.resolve({ installed_apps: [{ id: 'installed-1' }] })),
}))
@ -438,11 +445,13 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => {
className,
onClick,
destructive,
disabled,
}: {
children: React.ReactNode
className?: string
onClick?: React.MouseEventHandler<HTMLButtonElement>
destructive?: boolean
disabled?: boolean
}) => {
const { setOpen } = useDropdownMenuContext()
return (
@ -452,6 +461,7 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => {
type="button"
className={className}
data-destructive={destructive}
disabled={disabled}
onClick={(e) => {
onClick?.(e)
setOpen(false)
@ -533,6 +543,10 @@ describe('AppCard', () => {
mockUserCanAccessApp.isLoading = false
mockDeleteMutationPending = false
mockToggleStarMutationPending = false
mockAppDslExport.isExporting = false
mockAppDslExport.exportAppDsl.mockResolvedValue({ status: 'downloaded' })
mockWorkflowAppDslExport.isExporting = false
mockWorkflowAppDslExport.exportWorkflowAppDsl.mockResolvedValue({ status: 'downloaded' })
mockConsoleState.isCurrentWorkspaceEditor = true
mockConsoleState.userProfile = { id: 'user-1' }
mockConsoleState.workspacePermissionKeys = ['app.create_and_management']
@ -1310,7 +1324,7 @@ describe('AppCard', () => {
})
})
it('should call exportAppConfig API when exporting', async () => {
it('should export the app DSL when exporting', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
@ -1318,28 +1332,18 @@ describe('AppCard', () => {
fireEvent.click(screen.getByText('app.export'))
})
await waitFor(() => {
expect(appsService.exportAppConfig).toHaveBeenCalled()
expect(mockAppDslExport.exportAppDsl).toHaveBeenCalledWith({
appId: mockApp.id,
appName: mockApp.name,
})
})
it('should handle export failure', async () => {
;(appsService.exportAppConfig as Mock).mockRejectedValueOnce(new Error('Export failed'))
it('should prevent duplicate exports while an app DSL export is pending', () => {
mockAppDslExport.isExporting = true
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
await waitFor(() => {
expect(appsService.exportAppConfig).toHaveBeenCalled()
expect(toastMocks.record).toHaveBeenCalledWith({
type: 'error',
message: 'app.exportFailed',
})
})
const trigger = screen.getByRole('button', { name: 'common.operation.exporting' })
expect(trigger).toBeDisabled()
})
})
@ -1446,7 +1450,7 @@ describe('AppCard', () => {
})
describe('Workflow Export with Environment Variables', () => {
it('should check for secret environment variables in workflow apps', async () => {
it('should use the workflow export command for workflow apps', async () => {
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
@ -1456,13 +1460,18 @@ describe('AppCard', () => {
})
await waitFor(() => {
expect(workflowService.fetchWorkflowDraft).toHaveBeenCalled()
expect(mockWorkflowAppDslExport.exportWorkflowAppDsl).toHaveBeenCalledWith({
appId: workflowApp.id,
appName: workflowApp.name,
})
})
expect(mockAppDslExport.exportAppDsl).not.toHaveBeenCalled()
})
it('should show DSL export modal when workflow has secret variables', async () => {
;(workflowService.fetchWorkflowDraft as Mock).mockResolvedValueOnce({
environment_variables: [{ value_type: 'secret', name: 'API_KEY' }],
mockWorkflowAppDslExport.exportWorkflowAppDsl.mockResolvedValueOnce({
status: 'confirmation-required',
secretEnvList: [{ value_type: 'secret', name: 'API_KEY', value: 'secret' }],
})
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
@ -1478,9 +1487,7 @@ describe('AppCard', () => {
})
})
it('should export workflow directly when environment_variables is undefined', async () => {
;(workflowService.fetchWorkflowDraft as Mock).mockResolvedValueOnce({})
it('should not open a modal when the workflow command downloads directly', async () => {
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
@ -1490,19 +1497,16 @@ describe('AppCard', () => {
})
await waitFor(() => {
expect(workflowService.fetchWorkflowDraft).toHaveBeenCalledWith(
`/apps/${workflowApp.id}/workflows/draft`,
)
expect(appsService.exportAppConfig).toHaveBeenCalledWith({
appID: workflowApp.id,
include: false,
expect(mockWorkflowAppDslExport.exportWorkflowAppDsl).toHaveBeenCalledWith({
appId: workflowApp.id,
appName: workflowApp.name,
})
})
expect(screen.queryByTestId('dsl-export-modal')).not.toBeInTheDocument()
})
it('should check for secret environment variables in advanced chat apps', async () => {
it('should use the workflow export command for advanced chat apps', async () => {
const advancedChatApp = { ...mockApp, mode: AppModeEnum.ADVANCED_CHAT }
render(<AppCard app={advancedChatApp} />)
@ -1512,13 +1516,17 @@ describe('AppCard', () => {
})
await waitFor(() => {
expect(workflowService.fetchWorkflowDraft).toHaveBeenCalled()
expect(mockWorkflowAppDslExport.exportWorkflowAppDsl).toHaveBeenCalledWith({
appId: advancedChatApp.id,
appName: advancedChatApp.name,
})
})
})
it('should close DSL export modal when onClose is called', async () => {
;(workflowService.fetchWorkflowDraft as Mock).mockResolvedValueOnce({
environment_variables: [{ value_type: 'secret', name: 'API_KEY' }],
mockWorkflowAppDslExport.exportWorkflowAppDsl.mockResolvedValueOnce({
status: 'confirmation-required',
secretEnvList: [{ value_type: 'secret', name: 'API_KEY', value: 'secret' }],
})
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
@ -1692,26 +1700,6 @@ describe('AppCard', () => {
unmount()
})
})
it('should handle workflow draft fetch failure during export', async () => {
;(workflowService.fetchWorkflowDraft as Mock).mockRejectedValueOnce(new Error('Fetch failed'))
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
await waitFor(() => {
expect(workflowService.fetchWorkflowDraft).toHaveBeenCalled()
expect(toastMocks.record).toHaveBeenCalledWith({
type: 'error',
message: 'app.exportFailed',
})
})
})
})
// --------------------------------------------------------------------------

View File

@ -1,9 +1,9 @@
'use client'
import type { EnvironmentVariableItemResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { FormEvent, FormEventHandler, KeyboardEvent, MouseEvent } from 'react'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import type { WorkflowOnlineUser } from '@/models/app'
import type { App } from '@/types/app'
import {
@ -31,6 +31,7 @@ import { useAtomValue } from 'jotai'
import { useCallback, useId, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/use-export-app-dsl'
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
import AppIcon from '@/app/components/base/app-icon'
import StarIcon from '@/app/components/base/icons/src/vender/Star'
@ -51,13 +52,11 @@ import dynamic from '@/next/dynamic'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { copyApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { copyApp, updateAppInfo } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { useDeleteAppMutation, useToggleAppStarMutation } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection, getRedirectionPath } from '@/utils/app-redirection'
import { downloadBlob } from '@/utils/download'
import {
getAppACLCapabilities,
hasOnlyAppPreviewPermission,
@ -103,6 +102,7 @@ const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set<AppModeEnum>([
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.WORKFLOW,
])
const OPERATIONS_MENU_POPUP_CLASS_NAME = 'min-w-[216px]'
type AppCardProps = {
app: App
@ -165,6 +165,7 @@ type AppCardOperationsMenuProps = {
shouldShowAccessControlOption: boolean
shouldShowAccessConfigOption: boolean
shouldShowDeleteOption: boolean
isExporting: boolean
onEdit: () => void
onDuplicate: () => void
onExport: () => void
@ -184,6 +185,7 @@ function AppCardOperationsMenu({
shouldShowAccessControlOption,
shouldShowAccessConfigOption,
shouldShowDeleteOption,
isExporting,
onEdit,
onDuplicate,
onExport,
@ -255,7 +257,11 @@ function AppCardOperationsMenu({
</DropdownMenuItem>
)}
{shouldShowExportOption && (
<DropdownMenuItem className="gap-2 px-3" onClick={(e) => handleMenuAction(e, onExport)}>
<DropdownMenuItem
className="gap-2 px-3"
disabled={isExporting}
onClick={(e) => handleMenuAction(e, onExport)}
>
<span className="system-sm-regular text-text-secondary">
{t(($) => $.export, { ns: 'app' })}
</span>
@ -363,9 +369,12 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
const [showAccessControl, setShowAccessControl] = useState(false)
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariableItemResponse[]>([])
const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation()
const { mutateAsync: mutateToggleAppStar, isPending: isTogglingStar } = useToggleAppStarMutation()
const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl()
const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } = useExportWorkflowAppDsl()
const isExporting = isAppDslExporting || isWorkflowAppDslExporting
const setNeedRefresh = useSetNeedRefreshAppList()
const resourceMaintainer = getAppResourceMaintainer(app)
const maintainerPermissionOptions = useMemo(
@ -522,37 +531,19 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
}
const onExport = async (include = false) => {
try {
const { data } = await exportAppConfig({
appID: app.id,
include,
})
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${app.name}.yml` })
} catch {
toast.error(t(($) => $.exportFailed, { ns: 'app' }))
}
await exportAppDsl({ appId: app.id, appName: app.name, includeSecret: include })
}
const exportCheck = async () => {
if (isExporting) return
setIsOperationsMenuOpen(false)
if (app.mode !== AppModeEnum.WORKFLOW && app.mode !== AppModeEnum.ADVANCED_CHAT) {
onExport()
return
}
try {
const workflowDraft = await fetchWorkflowDraft(`/apps/${app.id}/workflows/draft`)
const list = (workflowDraft.environment_variables || []).filter(
(env) => env.value_type === 'secret',
)
if (list.length === 0) {
onExport()
return
}
setSecretEnvList(list)
} catch {
toast.error(t(($) => $.exportFailed, { ns: 'app' }))
}
const isWorkflowApp =
app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT
const result = isWorkflowApp
? await exportWorkflowAppDsl({ appId: app.id, appName: app.name })
: await exportAppDsl({ appId: app.id, appName: app.name })
if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList)
}
const onSwitch = () => {
@ -606,7 +597,6 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
shouldShowAccessControlOption ||
shouldShowAccessConfigOption ||
shouldShowDeleteOption
const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]'
const starActionLabel = app.is_starred
? t(($) => $['studio.unstarApp'], { ns: 'app' })
: t(($) => $['studio.starApp'], { ns: 'app' })
@ -617,7 +607,7 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
<div
className={cn(
'absolute top-2 right-2 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs transition-opacity',
isOperationsMenuOpen
isOperationsMenuOpen || isExporting
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
@ -651,12 +641,17 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
onOpenChange={setIsOperationsMenuOpen}
>
<DropdownMenuTrigger
aria-label={t(($) => $['operation.moreActionsFor'], {
ns: 'common',
name: app.name,
})}
aria-label={
isExporting
? t(($) => $['operation.exporting'], { ns: 'common' })
: t(($) => $['operation.moreActionsFor'], {
ns: 'common',
name: app.name,
})
}
disabled={isExporting}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed',
isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
onClick={(e) => {
@ -664,13 +659,20 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
e.preventDefault()
}}
>
<span className="sr-only">{t(($) => $['operation.more'], { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-[18px] w-[18px] text-text-tertiary" />
<span
aria-hidden
className={cn(
'h-[18px] w-[18px] text-text-tertiary',
isExporting
? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none'
: 'i-ri-more-fill',
)}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
popupClassName={OPERATIONS_MENU_POPUP_CLASS_NAME}
>
{systemFeatures.webapp_auth.enabled ? (
<AppCardOperationsMenuContent
@ -682,6 +684,7 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
shouldShowAccessControlOption={shouldShowAccessControlOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
isExporting={isExporting}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
@ -701,6 +704,7 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
shouldShowAccessControlOption={shouldShowAccessControlOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
isExporting={isExporting}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
@ -849,9 +853,12 @@ export function AppCard({
})
const isOperationsMenuOpen = operationsMenu.open
const setIsOperationsMenuOpen = operationsMenu.onOpenChange
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariableItemResponse[]>([])
const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation()
const { mutateAsync: mutateToggleAppStar, isPending: isTogglingStar } = useToggleAppStarMutation()
const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl()
const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } = useExportWorkflowAppDsl()
const isExporting = isAppDslExporting || isWorkflowAppDslExporting
const setNeedRefresh = useSetNeedRefreshAppList()
const resourceMaintainer = getAppResourceMaintainer(app)
const maintainerPermissionOptions = useMemo(
@ -1000,37 +1007,19 @@ export function AppCard({
}
const onExport = async (include = false) => {
try {
const { data } = await exportAppConfig({
appID: app.id,
include,
})
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${app.name}.yml` })
} catch {
toast.error(t(($) => $.exportFailed, { ns: 'app' }))
}
await exportAppDsl({ appId: app.id, appName: app.name, includeSecret: include })
}
const exportCheck = async () => {
if (isExporting) return
setIsOperationsMenuOpen(false)
if (app.mode !== AppModeEnum.WORKFLOW && app.mode !== AppModeEnum.ADVANCED_CHAT) {
onExport()
return
}
try {
const workflowDraft = await fetchWorkflowDraft(`/apps/${app.id}/workflows/draft`)
const list = (workflowDraft.environment_variables || []).filter(
(env) => env.value_type === 'secret',
)
if (list.length === 0) {
onExport()
return
}
setSecretEnvList(list)
} catch {
toast.error(t(($) => $.exportFailed, { ns: 'app' }))
}
const isWorkflowApp =
app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT
const result = isWorkflowApp
? await exportWorkflowAppDsl({ appId: app.id, appName: app.name })
: await exportAppDsl({ appId: app.id, appName: app.name })
if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList)
}
const onSwitch = () => {
@ -1084,8 +1073,6 @@ export function AppCard({
shouldShowAccessConfigOption ||
shouldShowDeleteOption
const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || appACLCapabilities.canEdit)
const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]'
const editTimeText = useMemo(() => {
const timeText = formatTime({
date: (app.updated_at || app.created_at) * 1000,
@ -1261,7 +1248,7 @@ export function AppCard({
<div
className={cn(
'absolute top-2 right-2 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs transition-opacity',
isOperationsMenuOpen
isOperationsMenuOpen || isExporting
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
@ -1295,12 +1282,17 @@ export function AppCard({
onOpenChange={setIsOperationsMenuOpen}
>
<DropdownMenuTrigger
aria-label={t(($) => $['operation.moreActionsFor'], {
ns: 'common',
name: app.name,
})}
aria-label={
isExporting
? t(($) => $['operation.exporting'], { ns: 'common' })
: t(($) => $['operation.moreActionsFor'], {
ns: 'common',
name: app.name,
})
}
disabled={isExporting}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed',
isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
onClick={(e) => {
@ -1308,19 +1300,23 @@ export function AppCard({
e.preventDefault()
}}
>
<span className="sr-only">{t(($) => $['operation.more'], { ns: 'common' })}</span>
<span
aria-hidden
className="i-ri-more-fill h-[18px] w-[18px] text-text-tertiary"
className={cn(
'h-[18px] w-[18px] text-text-tertiary',
isExporting
? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none'
: 'i-ri-more-fill',
)}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
{...getStepByStepTourDropdownMenuContentProps({
highlightPart: stepByStepTourActionMenuHighlightPart,
interactionMode: operationsMenu.controlled ? 'presentation' : 'interactive',
popupClassName: OPERATIONS_MENU_POPUP_CLASS_NAME,
})}
>
{systemFeatures.webapp_auth.enabled ? (
@ -1333,6 +1329,7 @@ export function AppCard({
shouldShowAccessControlOption={shouldShowAccessControlOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
isExporting={isExporting}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
@ -1352,6 +1349,7 @@ export function AppCard({
shouldShowAccessControlOption={shouldShowAccessControlOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
isExporting={isExporting}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}

View File

@ -15,7 +15,7 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
export type DSLExportConfirmModalProps = {
envList: EnvironmentVariable[]
envList: Pick<EnvironmentVariable, 'name' | 'value'>[]
onConfirm: (state: boolean) => void | Promise<void>
onClose: () => void
}

View File

@ -1,22 +1,20 @@
import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AgentDetailSection, AgentDetailTop } from '../navigation'
const mocks = vi.hoisted(() => ({
downloadBlob: vi.fn(),
exportAppConfig: vi.fn(),
exportAppDsl: vi.fn(),
pathname: '/agents/agent-1/configure',
queryData: undefined as AgentAppDetailWithSite | undefined,
}))
vi.mock('@/service/apps', () => ({
exportAppConfig: mocks.exportAppConfig,
}))
vi.mock('@/utils/download', () => ({
downloadBlob: mocks.downloadBlob,
vi.mock('@/app/components/app/use-export-app-dsl', () => ({
useExportAppDsl: () => ({
exportAppDsl: mocks.exportAppDsl,
isExporting: false,
}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
@ -108,7 +106,7 @@ function renderAgentDetailSection(expand = true) {
describe('AgentDetailSection', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.exportAppConfig.mockResolvedValue({ data: 'kind: app\napp:\n mode: agent\n' })
mocks.exportAppDsl.mockResolvedValue(undefined)
mocks.pathname = '/agents/agent-1/configure'
mocks.queryData = createAgent()
})
@ -159,15 +157,9 @@ describe('AgentDetailSection', () => {
await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ }))
await user.click(screen.getByRole('menuitem', { name: 'app.export' }))
await waitFor(() => {
expect(mocks.exportAppConfig).toHaveBeenCalledWith({
appID: 'app-1',
include: false,
})
})
expect(mocks.downloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: 'Research Agent.yml',
expect(mocks.exportAppDsl).toHaveBeenCalledWith({
appId: 'app-1',
appName: 'Research Agent',
})
})

View File

@ -11,11 +11,10 @@ import {
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useExportAppDsl } from '@/app/components/app/use-export-app-dsl'
import { DeleteAgentDialog } from '@/features/agent-v2/roster/components/delete-agent-dialog'
import { DuplicateAgentDialog } from '@/features/agent-v2/roster/components/duplicate-agent-dialog'
import { EditAgentDialog } from '@/features/agent-v2/roster/components/edit-agent-dialog'
import { exportAppConfig } from '@/service/apps'
import { downloadBlob } from '@/utils/download'
type AgentDetailSidebarActionAgent = Pick<
AgentAppPartial,
@ -40,6 +39,7 @@ export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebar
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false)
const [duplicateSessionKey, setDuplicateSessionKey] = useState(0)
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
const { exportAppDsl, isExporting } = useExportAppDsl()
const dialogAgent: AgentAppPartial = {
description: agent.description,
icon: agent.icon,
@ -62,24 +62,16 @@ export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebar
setIsDuplicateOpen(true)
}
const handleExport = async () => {
const handleExport = () => {
if (!agent.app_id) {
toast.error(tApp(($) => $.exportFailed))
return
}
try {
const { data } = await exportAppConfig({
appID: agent.app_id,
include: false,
})
downloadBlob({
data: new Blob([data], { type: 'application/yaml' }),
fileName: `${agent.name}.yml`,
})
} catch {
toast.error(tApp(($) => $.exportFailed))
}
return exportAppDsl({
appId: agent.app_id,
appName: agent.name,
})
}
return (
@ -100,7 +92,7 @@ export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebar
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
<span>{tCommon(($) => $['operation.duplicate'])}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2" onClick={handleExport}>
<DropdownMenuItem className="gap-2" disabled={isExporting} onClick={handleExport}>
<span
aria-hidden
className="i-ri-file-download-line size-4 shrink-0 text-text-tertiary"

View File

@ -9,15 +9,14 @@ import { AgentRosterList } from '../agent-roster-list'
const { duplicateAgentMutationFn } = vi.hoisted(() => ({
duplicateAgentMutationFn: vi.fn(),
}))
const exportAppConfigMock = vi.hoisted(() => vi.fn())
const downloadBlobMock = vi.hoisted(() => vi.fn())
const exportAppDslMock = vi.hoisted(() => vi.fn())
const exportAppDslState = vi.hoisted(() => ({ isExporting: false }))
vi.mock('@/service/apps', () => ({
exportAppConfig: exportAppConfigMock,
}))
vi.mock('@/utils/download', () => ({
downloadBlob: downloadBlobMock,
vi.mock('@/app/components/app/use-export-app-dsl', () => ({
useExportAppDsl: () => ({
exportAppDsl: exportAppDslMock,
isExporting: exportAppDslState.isExporting,
}),
}))
vi.mock('@/hooks/use-timestamp', () => ({
@ -115,7 +114,8 @@ describe('AgentRosterList', () => {
name: 'Research Agent copy',
}),
)
exportAppConfigMock.mockResolvedValue({ data: 'kind: app\napp:\n mode: agent\n' })
exportAppDslMock.mockResolvedValue(undefined)
exportAppDslState.isExporting = false
})
afterEach(() => {
@ -279,18 +279,23 @@ describe('AgentRosterList', () => {
await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ }))
await user.click(screen.getByRole('menuitem', { name: 'app.export' }))
await waitFor(() => {
expect(exportAppConfigMock).toHaveBeenCalledWith({
appID: 'app-1',
include: false,
})
expect(exportAppDslMock).toHaveBeenCalledWith({
appId: 'app-1',
appName: 'Research Agent',
})
expect(downloadBlobMock).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: 'Research Agent.yml',
})
const [{ data }] = downloadBlobMock.mock.calls[0] as [{ data: Blob }]
expect(await data.text()).toBe('kind: app\napp:\n mode: agent\n')
})
it('disables export while an Agent App DSL export is pending', async () => {
const user = userEvent.setup()
exportAppDslState.isExporting = true
renderList([createAgent()])
await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ }))
expect(screen.getByRole('menuitem', { name: 'app.export' })).toHaveAttribute(
'aria-disabled',
'true',
)
})
it('uses the latest cached agent detail when opening the duplicate dialog', async () => {

View File

@ -12,12 +12,11 @@ import {
import { toast } from '@langgenius/dify-ui/toast'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useExportAppDsl } from '@/app/components/app/use-export-app-dsl'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import useTimestamp from '@/hooks/use-timestamp'
import Link from '@/next/link'
import { exportAppConfig } from '@/service/apps'
import { downloadBlob } from '@/utils/download'
import { AgentWorkflowReferencesDropdown } from './agent-workflow-references-dropdown'
import { DeleteAgentDialog } from './delete-agent-dialog'
import { DuplicateAgentDialog } from './duplicate-agent-dialog'
@ -115,6 +114,7 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false)
const [duplicateSessionKey, setDuplicateSessionKey] = useState(0)
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
const { exportAppDsl, isExporting } = useExportAppDsl()
const updatedAt =
agent.updated_at != null
? formatTime(
@ -140,24 +140,16 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
setIsDuplicateOpen(true)
}
const handleExport = async () => {
const handleExport = () => {
if (!agent.app_id) {
toast.error(tApp(($) => $.exportFailed))
return
}
try {
const { data } = await exportAppConfig({
appID: agent.app_id,
include: false,
})
downloadBlob({
data: new Blob([data], { type: 'application/yaml' }),
fileName: `${agent.name}.yml`,
})
} catch {
toast.error(tApp(($) => $.exportFailed))
}
return exportAppDsl({
appId: agent.app_id,
appName: agent.name,
})
}
return (
@ -255,7 +247,7 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
/>
<span>{tCommon(($) => $['operation.duplicate'])}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2" onClick={handleExport}>
<DropdownMenuItem className="gap-2" disabled={isExporting} onClick={handleExport}>
<span aria-hidden className="i-ri-download-line size-4 shrink-0 text-text-tertiary" />
<span>{tApp(($) => $.export)}</span>
</DropdownMenuItem>