mirror of
https://github.com/langgenius/dify.git
synced 2026-07-30 00:39:34 +08:00
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:
parent
de07730548
commit
62db37c403
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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[]>(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
266
web/app/components/app/__tests__/use-export-app-dsl.spec.tsx
Normal file
266
web/app/components/app/__tests__/use-export-app-dsl.spec.tsx
Normal 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))
|
||||
})
|
||||
})
|
||||
148
web/app/components/app/use-export-app-dsl.ts
Normal file
148
web/app/components/app/use-export-app-dsl.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user