mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 21:28:25 +08:00
refactor: enhance CreateFromDSLModal and related components for improved error handling and code clarity
- Updated CreateFromDSLModal to use useCallback for file handling functions, improving performance. - Enhanced error handling in DSL import processes to provide more informative feedback to users. - Refactored toast notifications to display specific error messages based on responses. - Adjusted test cases to validate new error handling logic and ensure robustness. - Minor UI adjustments in related components for consistency and better user experience.
This commit is contained in:
parent
97e99eb986
commit
bbc4c8398b
@ -397,6 +397,7 @@ describe('CreateFromDSLModal', () => {
|
||||
mockImportDSL.mockResolvedValueOnce({
|
||||
id: 'import-failed',
|
||||
status: DSLImportStatus.FAILED,
|
||||
error: 'Invalid YAML format',
|
||||
})
|
||||
mockImportDSL.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
@ -412,7 +413,7 @@ describe('CreateFromDSLModal', () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(getCreateButton())
|
||||
})
|
||||
expect(toastMocks.error).toHaveBeenCalledWith('newApp.appCreateFailed')
|
||||
expect(toastMocks.error).toHaveBeenCalledWith('Invalid YAML format')
|
||||
|
||||
rerender(
|
||||
<CreateFromDSLModal
|
||||
@ -427,6 +428,7 @@ describe('CreateFromDSLModal', () => {
|
||||
fireEvent.click(getCreateButton())
|
||||
})
|
||||
expect(toastMocks.error).toHaveBeenCalledTimes(2)
|
||||
expect(toastMocks.error).toHaveBeenLastCalledWith('newApp.appCreateFailed')
|
||||
})
|
||||
|
||||
it('should handle pending import confirmation failures and cancellation', async () => {
|
||||
@ -438,7 +440,7 @@ describe('CreateFromDSLModal', () => {
|
||||
current_dsl_version: '2.0.0',
|
||||
})
|
||||
mockImportDSLConfirm
|
||||
.mockResolvedValueOnce({ status: DSLImportStatus.FAILED })
|
||||
.mockResolvedValueOnce({ status: DSLImportStatus.FAILED, error: 'Confirm failed' })
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
render(
|
||||
@ -465,11 +467,12 @@ describe('CreateFromDSLModal', () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'newApp.Confirm' })[0]!)
|
||||
})
|
||||
expect(toastMocks.error).toHaveBeenCalledWith('newApp.appCreateFailed')
|
||||
expect(toastMocks.error).toHaveBeenCalledWith('Confirm failed')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'newApp.Confirm' })[0]!)
|
||||
})
|
||||
expect(toastMocks.error).toHaveBeenCalledTimes(2)
|
||||
expect(toastMocks.error).toHaveBeenLastCalledWith('newApp.appCreateFailed')
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,9 +5,8 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
@ -46,7 +45,7 @@ export enum CreateFromDSLModalTab {
|
||||
const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', droppedFile }: CreateFromDSLModalProps) => {
|
||||
const { push } = useRouter()
|
||||
const { t } = useTranslation()
|
||||
const [currentFile, setDSLFile] = useState<File | undefined>(droppedFile)
|
||||
const [currentFile, setCurrentFile] = useState<File | undefined>(droppedFile)
|
||||
const [fileContent, setFileContent] = useState<string>()
|
||||
const [currentTab, setCurrentTab] = useState(activeTab)
|
||||
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
|
||||
@ -55,22 +54,22 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
const [importId, setImportId] = useState<string>()
|
||||
const { handleCheckPluginDependencies } = usePluginDependencies()
|
||||
|
||||
const readFile = (file: File) => {
|
||||
const readFile = useCallback((file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = function (event) {
|
||||
const content = event.target?.result
|
||||
setFileContent(content as string)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFile = (file?: File) => {
|
||||
setDSLFile(file)
|
||||
const handleFile = useCallback((file?: File) => {
|
||||
setCurrentFile(file)
|
||||
if (file)
|
||||
readFile(file)
|
||||
if (!file)
|
||||
setFileContent('')
|
||||
}
|
||||
}, [readFile])
|
||||
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
@ -81,7 +80,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
useEffect(() => {
|
||||
if (droppedFile)
|
||||
handleFile(droppedFile)
|
||||
}, [droppedFile])
|
||||
}, [droppedFile, handleFile])
|
||||
|
||||
const onCreate = async (_e?: React.MouseEvent) => {
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
|
||||
@ -140,11 +139,10 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
setImportId(id)
|
||||
}
|
||||
else {
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
toast.error(response.error || t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
catch {
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
isCreatingRef.current = false
|
||||
@ -186,11 +184,10 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
|
||||
}
|
||||
else if (status === DSLImportStatus.FAILED) {
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
toast.error(response.error || t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
catch {
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
}
|
||||
}
|
||||
@ -227,7 +224,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
className="flex h-8 w-8 cursor-pointer items-center"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
|
||||
<span className="i-ri-close-line h-5 w-5 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary">
|
||||
|
||||
@ -82,7 +82,9 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
}, [handleSave])
|
||||
|
||||
return (
|
||||
<div className="w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]">
|
||||
<div className="w-[372px]
|
||||
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
|
||||
<div className="mt-3">
|
||||
<div className="system-xs-medium text-text-secondary">
|
||||
|
||||
@ -34,8 +34,8 @@ const DSLConfirmModal = ({
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="w-[480px] overflow-hidden! border-none text-left align-middle shadow-xl">
|
||||
<div className="flex flex-col items-start gap-2 self-stretch p-6 pb-4">
|
||||
<AlertDialogContent className="w-[480px] max-w-none! overflow-hidden! border-none p-6 text-left align-middle shadow-xl">
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</AlertDialogTitle>
|
||||
<AlertDialogDescription render={<div />} className="flex grow flex-col system-md-regular text-text-secondary">
|
||||
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
||||
@ -51,7 +51,7 @@ const DSLConfirmModal = ({
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogActions className="items-start p-0 pt-6">
|
||||
<AlertDialogCancelButton variant="secondary">{t('newApp.Cancel', { ns: 'app' })}</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={onConfirm} disabled={confirmDisabled}>{t('newApp.Confirm', { ns: 'app' })}</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
|
||||
@ -418,7 +418,10 @@ describe('useDSLImport', () => {
|
||||
|
||||
it('should handle FAILED status', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'failed' }))
|
||||
mockImportDSL.mockResolvedValue(createImportDSLResponse({
|
||||
status: 'failed',
|
||||
error: 'Missing rag_pipeline data in YAML content',
|
||||
}))
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDSLImport({
|
||||
@ -434,9 +437,42 @@ describe('useDSLImport', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(toastMocks.record).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
}))
|
||||
message: 'Missing rag_pipeline data in YAML content',
|
||||
})
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should show response error when import request rejects with a response body', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
mockImportDSL.mockRejectedValue(new Response(JSON.stringify({
|
||||
error: 'Missing rag_pipeline data in YAML content',
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDSLImport({
|
||||
activeTab: CreateFromDSLModalTab.FROM_URL,
|
||||
dslUrl: 'https://example.com/test.pipeline',
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleCreateApp()
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastMocks.record).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Missing rag_pipeline data in YAML content',
|
||||
})
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
@ -692,6 +728,7 @@ describe('useDSLImport', () => {
|
||||
status: 'failed',
|
||||
pipeline_id: 'pipeline-456',
|
||||
dataset_id: 'dataset-789',
|
||||
error: 'Import information expired or does not exist',
|
||||
})
|
||||
|
||||
const { result } = renderHook(
|
||||
@ -718,9 +755,56 @@ describe('useDSLImport', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(toastMocks.record).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
}))
|
||||
message: 'Import information expired or does not exist',
|
||||
})
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should show response error when confirm request rejects with a response body', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
mockImportDSL.mockResolvedValue(createImportDSLResponse({
|
||||
id: 'import-123',
|
||||
status: 'pending',
|
||||
}))
|
||||
|
||||
mockImportDSLConfirm.mockRejectedValue(new Response(JSON.stringify({
|
||||
error: 'Import information expired or does not exist',
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDSLImport({
|
||||
activeTab: CreateFromDSLModalTab.FROM_URL,
|
||||
dslUrl: 'https://example.com/test.pipeline',
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleCreateApp()
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.onDSLConfirm()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastMocks.record).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Import information expired or does not exist',
|
||||
})
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
|
||||
@ -22,6 +22,31 @@ type DSLVersions = {
|
||||
importedVersion: string
|
||||
systemVersion: string
|
||||
}
|
||||
type ImportErrorResponse = {
|
||||
message?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
const getNonEmptyString = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string')
|
||||
return undefined
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
return trimmedValue || undefined
|
||||
}
|
||||
const getImportErrorMessage = async (error: unknown): Promise<string | undefined> => {
|
||||
if (error instanceof Response && !error.bodyUsed) {
|
||||
try {
|
||||
const errorData = await error.clone().json() as ImportErrorResponse
|
||||
return getNonEmptyString(errorData.message) ?? getNonEmptyString(errorData.error)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
if (error instanceof Error)
|
||||
return getNonEmptyString(error.message)
|
||||
|
||||
return undefined
|
||||
}
|
||||
export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', onSuccess, onClose }: UseDSLImportOptions) => {
|
||||
const { push } = useRouter()
|
||||
const { t } = useTranslation()
|
||||
@ -37,6 +62,9 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU
|
||||
const isCreatingRef = useRef(false)
|
||||
const { mutateAsync: importDSL } = useImportPipelineDSL()
|
||||
const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm()
|
||||
const notifyError = useCallback((message?: string) => {
|
||||
toast.error(message || t('creation.errorTip', { ns: 'datasetPipeline' }))
|
||||
}, [t])
|
||||
const readFile = useCallback((file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
@ -60,51 +88,55 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU
|
||||
if (isCreatingRef.current)
|
||||
return
|
||||
isCreatingRef.current = true
|
||||
let response
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
|
||||
response = await importDSL({
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: fileContent || '',
|
||||
})
|
||||
try {
|
||||
let response
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
|
||||
response = await importDSL({
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: fileContent || '',
|
||||
})
|
||||
}
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_URL) {
|
||||
response = await importDSL({
|
||||
mode: DSLImportMode.YAML_URL,
|
||||
yaml_url: dslUrlValue || '',
|
||||
})
|
||||
}
|
||||
if (!response) {
|
||||
notifyError()
|
||||
return
|
||||
}
|
||||
const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
onSuccess?.()
|
||||
onClose?.()
|
||||
toast(t(status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution', { ns: 'datasetPipeline' }), {
|
||||
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
|
||||
description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }),
|
||||
})
|
||||
if (pipeline_id)
|
||||
await handleCheckPluginDependencies(pipeline_id, true)
|
||||
push(`/datasets/${dataset_id}/pipeline`)
|
||||
}
|
||||
else if (status === DSLImportStatus.PENDING) {
|
||||
setVersions({
|
||||
importedVersion: imported_dsl_version ?? '',
|
||||
systemVersion: current_dsl_version ?? '',
|
||||
})
|
||||
onClose?.()
|
||||
setTimeout(() => {
|
||||
setShowConfirmModal(true)
|
||||
}, 300)
|
||||
setImportId(id)
|
||||
}
|
||||
else {
|
||||
notifyError(response.error)
|
||||
}
|
||||
}
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_URL) {
|
||||
response = await importDSL({
|
||||
mode: DSLImportMode.YAML_URL,
|
||||
yaml_url: dslUrlValue || '',
|
||||
})
|
||||
catch (error) {
|
||||
notifyError(await getImportErrorMessage(error))
|
||||
}
|
||||
if (!response) {
|
||||
toast.error(t('creation.errorTip', { ns: 'datasetPipeline' }))
|
||||
isCreatingRef.current = false
|
||||
return
|
||||
}
|
||||
const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
onSuccess?.()
|
||||
onClose?.()
|
||||
toast(t(status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution', { ns: 'datasetPipeline' }), {
|
||||
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
|
||||
description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }),
|
||||
})
|
||||
if (pipeline_id)
|
||||
await handleCheckPluginDependencies(pipeline_id, true)
|
||||
push(`/datasets/${dataset_id}/pipeline`)
|
||||
isCreatingRef.current = false
|
||||
}
|
||||
else if (status === DSLImportStatus.PENDING) {
|
||||
setVersions({
|
||||
importedVersion: imported_dsl_version ?? '',
|
||||
systemVersion: current_dsl_version ?? '',
|
||||
})
|
||||
onClose?.()
|
||||
setTimeout(() => {
|
||||
setShowConfirmModal(true)
|
||||
}, 300)
|
||||
setImportId(id)
|
||||
isCreatingRef.current = false
|
||||
}
|
||||
else {
|
||||
toast.error(t('creation.errorTip', { ns: 'datasetPipeline' }))
|
||||
finally {
|
||||
isCreatingRef.current = false
|
||||
}
|
||||
}, [
|
||||
@ -114,6 +146,7 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU
|
||||
fileContent,
|
||||
importDSL,
|
||||
t,
|
||||
notifyError,
|
||||
onSuccess,
|
||||
onClose,
|
||||
handleCheckPluginDependencies,
|
||||
@ -124,25 +157,32 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU
|
||||
if (!importId)
|
||||
return
|
||||
setIsConfirming(true)
|
||||
const response = await importDSLConfirm(importId)
|
||||
setIsConfirming(false)
|
||||
if (!response) {
|
||||
toast.error(t('creation.errorTip', { ns: 'datasetPipeline' }))
|
||||
return
|
||||
try {
|
||||
const response = await importDSLConfirm(importId)
|
||||
if (!response) {
|
||||
notifyError()
|
||||
return
|
||||
}
|
||||
const { status, pipeline_id, dataset_id, error } = response
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
onSuccess?.()
|
||||
setShowConfirmModal(false)
|
||||
toast.success(t('creation.successTip', { ns: 'datasetPipeline' }))
|
||||
if (pipeline_id)
|
||||
await handleCheckPluginDependencies(pipeline_id, true)
|
||||
push(`/datasets/${dataset_id}/pipeline`)
|
||||
}
|
||||
else if (status === DSLImportStatus.FAILED) {
|
||||
notifyError(error)
|
||||
}
|
||||
}
|
||||
const { status, pipeline_id, dataset_id } = response
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
onSuccess?.()
|
||||
setShowConfirmModal(false)
|
||||
toast.success(t('creation.successTip', { ns: 'datasetPipeline' }))
|
||||
if (pipeline_id)
|
||||
await handleCheckPluginDependencies(pipeline_id, true)
|
||||
push(`/datasets/${dataset_id}/pipeline`)
|
||||
catch (error) {
|
||||
notifyError(await getImportErrorMessage(error))
|
||||
}
|
||||
else if (status === DSLImportStatus.FAILED) {
|
||||
toast.error(t('creation.errorTip', { ns: 'datasetPipeline' }))
|
||||
finally {
|
||||
setIsConfirming(false)
|
||||
}
|
||||
}, [importId, importDSLConfirm, t, onSuccess, handleCheckPluginDependencies, push])
|
||||
}, [importId, importDSLConfirm, notifyError, t, onSuccess, handleCheckPluginDependencies, push])
|
||||
const handleCancelConfirm = useCallback(() => {
|
||||
setShowConfirmModal(false)
|
||||
}, [])
|
||||
|
||||
@ -160,7 +160,7 @@ const TemplateCard = ({
|
||||
closeEditModal()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-full max-w-[520px] overflow-hidden! border-none p-0 text-left align-middle">
|
||||
<DialogContent className="w-[calc(100vw-2rem)] max-w-[520px]! overflow-hidden! border-none p-0 text-left align-middle">
|
||||
|
||||
<EditPipelineInfo
|
||||
pipeline={pipeline}
|
||||
@ -195,7 +195,7 @@ const TemplateCard = ({
|
||||
closeDetailsModal()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="h-[calc(100vh-64px)] max-h-none w-full max-w-[1680px] overflow-hidden! rounded-3xl border-none p-0 text-left align-middle">
|
||||
<DialogContent className="h-[calc(100vh-64px)] max-h-none w-[calc(100vw-2rem)] max-w-[1680px]! overflow-hidden! rounded-3xl border-none p-0 text-left align-middle">
|
||||
|
||||
<Details
|
||||
id={pipeline.id}
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
@ -29,18 +26,18 @@ const DefaultContent: FC<IDefaultContentProps> = React.memo(({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-6 pb-4">
|
||||
<div className="pb-4">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">{t('segment.regenerationConfirmTitle', { ns: 'datasetDocuments' })}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-md-regular text-text-secondary">{t('segment.regenerationConfirmMessage', { ns: 'datasetDocuments' })}</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton variant="secondary" onClick={onCancel}>
|
||||
<div className="flex justify-end gap-x-2 pt-6">
|
||||
<Button onClick={onCancel}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={onConfirm}>
|
||||
</Button>
|
||||
<Button variant="primary" tone="destructive" onClick={onConfirm}>
|
||||
{t('operation.regenerate', { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@ -52,11 +49,11 @@ const RegeneratingContent: FC = React.memo(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-6 pb-4">
|
||||
<div className="pb-4">
|
||||
<span className="title-2xl-semi-bold text-text-primary">{t('segment.regeneratingTitle', { ns: 'datasetDocuments' })}</span>
|
||||
<p className="system-md-regular text-text-secondary">{t('segment.regeneratingMessage', { ns: 'datasetDocuments' })}</p>
|
||||
</div>
|
||||
<div className="flex justify-end p-6">
|
||||
<div className="flex justify-end pt-6">
|
||||
<Button variant="primary" tone="destructive" disabled className="inline-flex items-center gap-x-0.5">
|
||||
<RiLoader2Line className="h-4 w-4 animate-spin text-components-button-destructive-primary-text-disabled" />
|
||||
<span>{t('operation.regenerate', { ns: 'common' })}</span>
|
||||
@ -86,11 +83,11 @@ const RegenerationCompletedContent: FC<IRegenerationCompletedContentProps> = Rea
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-6 pb-4">
|
||||
<div className="pb-4">
|
||||
<span className="title-2xl-semi-bold text-text-primary">{t('segment.regenerationSuccessTitle', { ns: 'datasetDocuments' })}</span>
|
||||
<p className="system-md-regular text-text-secondary">{t('segment.regenerationSuccessMessage', { ns: 'datasetDocuments' })}</p>
|
||||
</div>
|
||||
<div className="flex justify-end p-6">
|
||||
<div className="flex justify-end pt-6">
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{`${t('operation.close', { ns: 'common' })}${countdown === 0 ? '' : `(${Math.round(countdown / 1000)})`}`}
|
||||
</Button>
|
||||
@ -131,7 +128,7 @@ const RegenerationModal: FC<IRegenerationModalProps> = ({
|
||||
|
||||
return (
|
||||
<AlertDialog open={isShow}>
|
||||
<AlertDialogContent className="max-w-[480px]! overflow-hidden! rounded-2xl border-none text-left align-middle shadow-xl">
|
||||
<AlertDialogContent className="w-[calc(100vw-2rem)] max-w-[480px]! overflow-hidden! rounded-2xl! border-none p-6 text-left align-middle shadow-xl">
|
||||
{!loading && !updateSucceeded && <DefaultContent onCancel={onCancel} onConfirm={onConfirm} />}
|
||||
{loading && !updateSucceeded && <RegeneratingContent />}
|
||||
{!loading && updateSucceeded && <RegenerationCompletedContent onClose={onClose} />}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { HitTesting } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ChunkDetailModal from '../chunk-detail-modal'
|
||||
|
||||
@ -20,7 +20,7 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({
|
||||
</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <div data-testid="modal-title">{children}</div>,
|
||||
DialogCloseButton: () => <button data-testid="modal-close">close</button>,
|
||||
DialogCloseButton: (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>close</button>,
|
||||
}))
|
||||
|
||||
vi.mock('../../../common/image-list', () => ({
|
||||
@ -136,4 +136,10 @@ describe('ChunkDetailModal', () => {
|
||||
render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />)
|
||||
expect(screen.getByTestId('mask')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onHide when close button is clicked', () => {
|
||||
render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />)
|
||||
fireEvent.click(screen.getByTestId('modal-close-button'))
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -59,8 +59,14 @@ const ChunkDetailModal = ({
|
||||
onHide()
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', cn(isParentChildRetrieval ? 'min-w-[1200px]!' : 'min-w-[800px]!'))}>
|
||||
<DialogCloseButton data-testid="modal-close-button" />
|
||||
<DialogContent className={cn('max-h-none overflow-hidden! border-none p-6 text-left align-middle', isParentChildRetrieval ? 'w-[1200px] max-w-none! min-w-[1200px]!' : 'w-[800px] max-w-none! min-w-[800px]!')}>
|
||||
<DialogCloseButton
|
||||
data-testid="modal-close-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onHide()
|
||||
}}
|
||||
/>
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(`${i18nPrefix}chunkDetail`, { ns: 'datasetHitTesting' })}
|
||||
</DialogTitle>
|
||||
|
||||
@ -290,6 +290,36 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should place the generated key backdrop above the API keys modal', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
mockAppApiKeysData.mockReturnValue({
|
||||
data: [
|
||||
{ id: 'key-1', token: 'sk-abc123def456ghi789', created_at: 1700000000, last_used_at: null },
|
||||
],
|
||||
})
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
await user.click(createButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const parentDialog = screen.getByText('appApi.apiKeyModal.apiSecretKeyTips').closest('[role="dialog"]')
|
||||
const generatedKeyDialog = screen.getByText('appApi.apiKeyModal.generateTips').closest('[role="dialog"]')
|
||||
const backdrops = document.body.querySelectorAll('.bg-background-overlay')
|
||||
const generatedKeyBackdrop = backdrops[1]
|
||||
|
||||
expect(parentDialog).toBeInTheDocument()
|
||||
expect(generatedKeyDialog).toBeInTheDocument()
|
||||
expect(backdrops).toHaveLength(2)
|
||||
expect(parentDialog!.compareDocumentPosition(generatedKeyBackdrop!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(generatedKeyBackdrop!.compareDocumentPosition(generatedKeyDialog!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should invalidate app API keys after creating', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
@ -104,90 +104,99 @@ const SecretKeyModal = ({
|
||||
setShowConfirmDelete(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isShow}
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn('max-h-[calc(100vh-80px)]! w-full max-w-[800px]! overflow-hidden! border-none text-left align-middle', `${s.customModal} flex flex-col px-8`)}>
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{`${t('apiKeyModal.apiSecretKey', { ns: 'appApi' })}`}
|
||||
</DialogTitle>
|
||||
const handleClose = () => {
|
||||
setVisible(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
<div className="-mt-6 -mr-2 mb-4 flex justify-end">
|
||||
<XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} />
|
||||
</div>
|
||||
<p className="mt-1 shrink-0 text-[13px] leading-5 font-normal text-text-tertiary">{t('apiKeyModal.apiSecretKeyTips', { ns: 'appApi' })}</p>
|
||||
{isApiKeysLoading && <div className="mt-4"><Loading /></div>}
|
||||
{
|
||||
!!apiKeysList?.data?.length && (
|
||||
<div className="mt-4 flex grow flex-col overflow-hidden">
|
||||
<div className="flex h-9 shrink-0 items-center border-b border-divider-regular text-xs font-semibold text-text-tertiary">
|
||||
<div className="w-64 shrink-0 px-3">{t('apiKeyModal.secretKey', { ns: 'appApi' })}</div>
|
||||
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.created', { ns: 'appApi' })}</div>
|
||||
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.lastUsed', { ns: 'appApi' })}</div>
|
||||
<div className="grow px-3"></div>
|
||||
</div>
|
||||
<div className="grow overflow-auto">
|
||||
{apiKeysList.data.map(api => (
|
||||
<div className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary" key={api.id}>
|
||||
<div className="w-64 shrink-0 truncate px-3 font-mono">{generateToken(api.token)}</div>
|
||||
<div className="w-[200px] shrink-0 truncate px-3">{formatTime(Number(api.created_at), t('dateTimeFormat', { ns: 'appLog' }) as string)}</div>
|
||||
<div className="w-[200px] shrink-0 truncate px-3">{api.last_used_at ? formatTime(Number(api.last_used_at), t('dateTimeFormat', { ns: 'appLog' }) as string) : t('never', { ns: 'appApi' })}</div>
|
||||
<div className="flex grow space-x-2 px-3">
|
||||
<CopyFeedback content={api.token} />
|
||||
{isCurrentWorkspaceManager && (
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
setDelKeyId(api.id)
|
||||
setShowConfirmDelete(true)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={isShow}
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
handleClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn('max-h-[calc(100vh-80px)]! w-full max-w-[800px]! overflow-hidden! border-none text-left align-middle', `${s.customModal} flex flex-col px-8`)}>
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{`${t('apiKeyModal.apiSecretKey', { ns: 'appApi' })}`}
|
||||
</DialogTitle>
|
||||
|
||||
<div className="-mt-6 -mr-2 mb-4 flex justify-end">
|
||||
<XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={handleClose} />
|
||||
</div>
|
||||
<p className="mt-1 shrink-0 text-[13px] leading-5 font-normal text-text-tertiary">{t('apiKeyModal.apiSecretKeyTips', { ns: 'appApi' })}</p>
|
||||
{isApiKeysLoading && <div className="mt-4"><Loading /></div>}
|
||||
{
|
||||
!!apiKeysList?.data?.length && (
|
||||
<div className="mt-4 flex grow flex-col overflow-hidden">
|
||||
<div className="flex h-9 shrink-0 items-center border-b border-divider-regular text-xs font-semibold text-text-tertiary">
|
||||
<div className="w-64 shrink-0 px-3">{t('apiKeyModal.secretKey', { ns: 'appApi' })}</div>
|
||||
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.created', { ns: 'appApi' })}</div>
|
||||
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.lastUsed', { ns: 'appApi' })}</div>
|
||||
<div className="grow px-3"></div>
|
||||
</div>
|
||||
<div className="grow overflow-auto">
|
||||
{apiKeysList.data.map(api => (
|
||||
<div className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary" key={api.id}>
|
||||
<div className="w-64 shrink-0 truncate px-3 font-mono">{generateToken(api.token)}</div>
|
||||
<div className="w-[200px] shrink-0 truncate px-3">{formatTime(Number(api.created_at), t('dateTimeFormat', { ns: 'appLog' }) as string)}</div>
|
||||
<div className="w-[200px] shrink-0 truncate px-3">{api.last_used_at ? formatTime(Number(api.last_used_at), t('dateTimeFormat', { ns: 'appLog' }) as string) : t('never', { ns: 'appApi' })}</div>
|
||||
<div className="flex grow space-x-2 px-3">
|
||||
<CopyFeedback content={api.token} />
|
||||
{isCurrentWorkspaceManager && (
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
setDelKeyId(api.id)
|
||||
setShowConfirmDelete(true)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="flex">
|
||||
<Button className={`mt-4 flex shrink-0 ${s.autoWidth}`} onClick={onCreate} disabled={!currentWorkspace || !isCurrentWorkspaceEditor}>
|
||||
<PlusIcon className="mr-1 flex h-4 w-4 shrink-0" />
|
||||
<div className="text-xs font-medium text-text-secondary">{t('apiKeyModal.createNewSecretKey', { ns: 'appApi' })}</div>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="flex">
|
||||
<Button className={`mt-4 flex shrink-0 ${s.autoWidth}`} onClick={onCreate} disabled={!currentWorkspace || !isCurrentWorkspaceEditor}>
|
||||
<PlusIcon className="mr-1 flex h-4 w-4 shrink-0" />
|
||||
<div className="text-xs font-medium text-text-secondary">{t('apiKeyModal.createNewSecretKey', { ns: 'appApi' })}</div>
|
||||
</Button>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={showConfirmDelete}
|
||||
onOpenChange={handleDeleteConfirmOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
{t('actionMsg.deleteConfirmTitle', { ns: 'appApi' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{t('actionMsg.deleteConfirmTips', { ns: 'appApi' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={onDel}>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isShow && (
|
||||
<SecretKeyGenerateModal className="shrink-0" isShow={isVisible} onClose={() => setVisible(false)} newKey={newKey} />
|
||||
<AlertDialog
|
||||
open={showConfirmDelete}
|
||||
onOpenChange={handleDeleteConfirmOpenChange}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
{t('actionMsg.deleteConfirmTitle', { ns: 'appApi' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{t('actionMsg.deleteConfirmTips', { ns: 'appApi' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={onDel}>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -161,11 +161,9 @@ describe('Uploading', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// NOTE: The uploadFile API has an unconventional contract where it always rejects.
|
||||
// Success vs failure is determined by whether response.message exists:
|
||||
// - If response.message exists → treated as failure (calls onFailed)
|
||||
// - If response.message is absent → treated as success (calls onPackageUploaded/onBundleUploaded)
|
||||
// This explains why we use mockRejectedValue for "success" scenarios below.
|
||||
// NOTE: Some upload endpoints have historically returned successful plugin upload
|
||||
// payloads through rejected XHR objects, so the component accepts both resolved
|
||||
// responses and rejected responses without an error message.
|
||||
|
||||
it('should call onPackageUploaded when upload rejects without error message (success case)', async () => {
|
||||
const mockResult = {
|
||||
@ -193,6 +191,30 @@ describe('Uploading', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onPackageUploaded when upload resolves with package response', async () => {
|
||||
const mockResult = {
|
||||
unique_identifier: 'test-uid',
|
||||
manifest: createMockManifest(),
|
||||
}
|
||||
mockUploadFile.mockResolvedValue(mockResult)
|
||||
|
||||
const onPackageUploaded = vi.fn()
|
||||
render(
|
||||
<Uploading
|
||||
{...defaultProps}
|
||||
isBundle={false}
|
||||
onPackageUploaded={onPackageUploaded}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPackageUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: mockResult.unique_identifier,
|
||||
manifest: mockResult.manifest,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onBundleUploaded when upload rejects without error message (success case)', async () => {
|
||||
const mockDependencies = createMockDependencies()
|
||||
mockUploadFile.mockRejectedValue({
|
||||
@ -212,6 +234,24 @@ describe('Uploading', () => {
|
||||
expect(onBundleUploaded).toHaveBeenCalledWith(mockDependencies)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onBundleUploaded when upload resolves with bundle response', async () => {
|
||||
const mockDependencies = createMockDependencies()
|
||||
mockUploadFile.mockResolvedValue(mockDependencies)
|
||||
|
||||
const onBundleUploaded = vi.fn()
|
||||
render(
|
||||
<Uploading
|
||||
{...defaultProps}
|
||||
isBundle
|
||||
onBundleUploaded={onBundleUploaded}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onBundleUploaded).toHaveBeenCalledWith(mockDependencies)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
@ -260,35 +300,48 @@ describe('Uploading', () => {
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty response gracefully', async () => {
|
||||
it('should fail gracefully when upload response is empty', async () => {
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: {},
|
||||
})
|
||||
|
||||
const onPackageUploaded = vi.fn()
|
||||
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} />)
|
||||
const onFailed = vi.fn()
|
||||
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPackageUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: undefined,
|
||||
manifest: undefined,
|
||||
})
|
||||
expect(onPackageUploaded).not.toHaveBeenCalled()
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installModal.uploadFailed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle response with only unique_identifier', async () => {
|
||||
it('should fail gracefully when upload response has no manifest', async () => {
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: { unique_identifier: 'only-uid' },
|
||||
})
|
||||
|
||||
const onPackageUploaded = vi.fn()
|
||||
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} />)
|
||||
const onFailed = vi.fn()
|
||||
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPackageUploaded).toHaveBeenCalledWith({
|
||||
uniqueIdentifier: 'only-uid',
|
||||
manifest: undefined,
|
||||
})
|
||||
expect(onPackageUploaded).not.toHaveBeenCalled()
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installModal.uploadFailed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should fail gracefully when upload response is null', async () => {
|
||||
mockUploadFile.mockRejectedValue({
|
||||
response: null,
|
||||
})
|
||||
|
||||
const onPackageUploaded = vi.fn()
|
||||
const onFailed = vi.fn()
|
||||
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPackageUploaded).not.toHaveBeenCalled()
|
||||
expect(onFailed).toHaveBeenCalledWith('plugin.installModal.uploadFailed')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { Dependency, PluginDeclaration } from '../../../types'
|
||||
import type { Dependency, Plugin, PluginDeclaration } from '../../../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { uploadFile } from '@/service/plugins'
|
||||
@ -10,6 +9,40 @@ import Card from '../../../card'
|
||||
|
||||
const i18nPrefix = 'installModal'
|
||||
|
||||
type PackageUploadResponse = {
|
||||
unique_identifier: string
|
||||
manifest: PluginDeclaration
|
||||
}
|
||||
|
||||
type UploadFailureResponse = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function isPackageUploadResponse(value: unknown): value is PackageUploadResponse {
|
||||
if (!isObject(value))
|
||||
return false
|
||||
|
||||
return typeof value.unique_identifier === 'string' && isObject(value.manifest)
|
||||
}
|
||||
|
||||
function getRejectedResponse(error: unknown): unknown {
|
||||
if (!isObject(error) || !('response' in error))
|
||||
return undefined
|
||||
|
||||
return error.response
|
||||
}
|
||||
|
||||
function getUploadFailureMessage(response: unknown): string | undefined {
|
||||
if (!isObject(response))
|
||||
return undefined
|
||||
|
||||
return (response as UploadFailureResponse).message
|
||||
}
|
||||
|
||||
type Props = {
|
||||
isBundle: boolean
|
||||
file: File
|
||||
@ -32,36 +65,50 @@ const Uploading: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const fileName = file.name
|
||||
const handleUpload = async () => {
|
||||
const handleUploadedResponse = React.useCallback((response: unknown) => {
|
||||
if (isBundle) {
|
||||
if (Array.isArray(response)) {
|
||||
onBundleUploaded(response as Dependency[])
|
||||
return
|
||||
}
|
||||
onFailed(t(`${i18nPrefix}.uploadFailed`, { ns: 'plugin' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPackageUploadResponse(response)) {
|
||||
onFailed(t(`${i18nPrefix}.uploadFailed`, { ns: 'plugin' }))
|
||||
return
|
||||
}
|
||||
|
||||
onPackageUploaded({
|
||||
uniqueIdentifier: response.unique_identifier,
|
||||
manifest: response.manifest,
|
||||
})
|
||||
}, [isBundle, onBundleUploaded, onFailed, onPackageUploaded, t])
|
||||
|
||||
const handleUpload = React.useCallback(async () => {
|
||||
try {
|
||||
await uploadFile(file, isBundle)
|
||||
handleUploadedResponse(await uploadFile(file, isBundle))
|
||||
}
|
||||
catch (e: any) {
|
||||
if (e.response?.message) {
|
||||
onFailed(e.response?.message)
|
||||
}
|
||||
else { // Why it would into this branch?
|
||||
const res = e.response
|
||||
if (isBundle) {
|
||||
onBundleUploaded(res)
|
||||
return
|
||||
}
|
||||
onPackageUploaded({
|
||||
uniqueIdentifier: res.unique_identifier,
|
||||
manifest: res.manifest,
|
||||
})
|
||||
catch (error: unknown) {
|
||||
const response = getRejectedResponse(error)
|
||||
const message = getUploadFailureMessage(response)
|
||||
if (message) {
|
||||
onFailed(message)
|
||||
return
|
||||
}
|
||||
handleUploadedResponse(response)
|
||||
}
|
||||
}
|
||||
}, [file, handleUploadedResponse, isBundle, onFailed])
|
||||
|
||||
React.useEffect(() => {
|
||||
handleUpload()
|
||||
}, [])
|
||||
}, [handleUpload])
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<div className="flex items-center gap-1 self-stretch">
|
||||
<RiLoader2Line className="h-4 w-4 animate-spin-slow text-text-accent" />
|
||||
<span className="i-ri-loader-2-line h-4 w-4 animate-spin-slow text-text-accent" />
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
{t(`${i18nPrefix}.uploadingPackage`, {
|
||||
ns: 'plugin',
|
||||
@ -72,7 +119,7 @@ const Uploading: FC<Props> = ({
|
||||
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">
|
||||
<Card
|
||||
className="w-full"
|
||||
payload={{ name: fileName } as any}
|
||||
payload={{ name: fileName } as Plugin}
|
||||
isLoading
|
||||
loadingFileName={fileName}
|
||||
installed={false}
|
||||
|
||||
@ -36,8 +36,8 @@ const VersionMismatchModal = ({
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="w-[480px] overflow-hidden! border-none text-left align-middle shadow-xl">
|
||||
<div className="flex flex-col items-start gap-2 self-stretch p-6 pb-4">
|
||||
<AlertDialogContent className="w-[480px] max-w-none! overflow-hidden! border-none p-6 text-left align-middle shadow-xl">
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</AlertDialogTitle>
|
||||
<AlertDialogDescription render={<div />} className="flex grow flex-col system-md-regular text-text-secondary">
|
||||
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
||||
@ -53,7 +53,7 @@ const VersionMismatchModal = ({
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogActions className="items-start p-0 pt-6">
|
||||
<AlertDialogCancelButton variant="secondary">{t('newApp.Cancel', { ns: 'app' })}</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={onConfirm}>{t('newApp.Confirm', { ns: 'app' })}</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
|
||||
@ -340,6 +340,7 @@ describe('useUpdateDSLModal', () => {
|
||||
id: 'import-id',
|
||||
status: DSLImportStatus.FAILED,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
error: 'Missing rag_pipeline data in YAML content',
|
||||
})
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
@ -351,7 +352,10 @@ describe('useUpdateDSLModal', () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
expect(toastMocks.call).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Missing rag_pipeline data in YAML content',
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify error when importDSL throws', async () => {
|
||||
@ -369,6 +373,29 @@ describe('useUpdateDSLModal', () => {
|
||||
expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should notify response error when importDSL rejects with a response body', async () => {
|
||||
mockImportDSL.mockRejectedValue(new Response(JSON.stringify({
|
||||
error: 'Missing rag_pipeline data in YAML content',
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(toastMocks.call).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Missing rag_pipeline data in YAML content',
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify error when pipeline_id is missing on success', async () => {
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-id',
|
||||
@ -468,6 +495,7 @@ describe('useUpdateDSLModal', () => {
|
||||
mockImportDSLConfirm.mockResolvedValue({
|
||||
status: DSLImportStatus.FAILED,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
error: 'Import information expired or does not exist',
|
||||
})
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
@ -477,7 +505,10 @@ describe('useUpdateDSLModal', () => {
|
||||
await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
expect(toastMocks.call).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Import information expired or does not exist',
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify error when confirm throws exception', async () => {
|
||||
@ -493,6 +524,27 @@ describe('useUpdateDSLModal', () => {
|
||||
expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should notify response error when confirm rejects with a response body', async () => {
|
||||
mockImportDSLConfirm.mockRejectedValue(new Response(JSON.stringify({
|
||||
error: 'Import information expired or does not exist',
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
await setupPendingState(result)
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(toastMocks.call).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Import information expired or does not exist',
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify error when confirm succeeds but pipeline_id is missing', async () => {
|
||||
mockImportDSLConfirm.mockResolvedValue({
|
||||
status: DSLImportStatus.COMPLETED,
|
||||
|
||||
@ -15,11 +15,36 @@ type VersionInfo = {
|
||||
importedVersion: string
|
||||
systemVersion: string
|
||||
}
|
||||
type ImportErrorResponse = {
|
||||
message?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
type UseUpdateDSLModalParams = {
|
||||
onCancel: () => void
|
||||
onImport?: () => void
|
||||
}
|
||||
const isCompletedStatus = (status: DSLImportStatus): boolean => status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS
|
||||
const getNonEmptyString = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string')
|
||||
return undefined
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
return trimmedValue || undefined
|
||||
}
|
||||
const getImportErrorMessage = async (error: unknown): Promise<string | undefined> => {
|
||||
if (error instanceof Response && !error.bodyUsed) {
|
||||
try {
|
||||
const errorData = await error.clone().json() as ImportErrorResponse
|
||||
return getNonEmptyString(errorData.message) ?? getNonEmptyString(errorData.error)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
if (error instanceof Error)
|
||||
return getNonEmptyString(error.message)
|
||||
|
||||
return undefined
|
||||
}
|
||||
export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParams) => {
|
||||
const { t } = useTranslation()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
@ -52,9 +77,9 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam
|
||||
if (!file)
|
||||
setFileContent('')
|
||||
}
|
||||
const notifyError = useCallback(() => {
|
||||
const notifyError = useCallback((message?: string) => {
|
||||
setLoading(false)
|
||||
toast.error(t('common.importFailure', { ns: 'workflow' }))
|
||||
toast.error(message || t('common.importFailure', { ns: 'workflow' }))
|
||||
}, [t])
|
||||
const updateWorkflow = useCallback(async (pipelineId: string) => {
|
||||
const { graph, hash, rag_pipeline_variables } = await fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`)
|
||||
@ -117,10 +142,10 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam
|
||||
else if (status === DSLImportStatus.PENDING)
|
||||
showVersionMismatch(id, imported_dsl_version, current_dsl_version)
|
||||
else
|
||||
notifyError()
|
||||
notifyError(response.error)
|
||||
}
|
||||
catch {
|
||||
notifyError()
|
||||
catch (error) {
|
||||
notifyError(await getImportErrorMessage(error))
|
||||
}
|
||||
isCreatingRef.current = false
|
||||
}, [currentFile, fileContent, workflowStore, importDSL, completeImport, showVersionMismatch, notifyError])
|
||||
@ -128,16 +153,16 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam
|
||||
if (!importId)
|
||||
return
|
||||
try {
|
||||
const { status, pipeline_id } = await importDSLConfirm(importId)
|
||||
const { status, pipeline_id, error } = await importDSLConfirm(importId)
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
await completeImport(pipeline_id)
|
||||
return
|
||||
}
|
||||
if (status === DSLImportStatus.FAILED)
|
||||
notifyError()
|
||||
notifyError(error)
|
||||
}
|
||||
catch {
|
||||
notifyError()
|
||||
catch (error) {
|
||||
notifyError(await getImportErrorMessage(error))
|
||||
}
|
||||
}, [importId, importDSLConfirm, completeImport, notifyError])
|
||||
return {
|
||||
|
||||
@ -89,6 +89,7 @@ export type ImportPipelineDSLResponse = {
|
||||
dataset_id: string
|
||||
current_dsl_version: string
|
||||
imported_dsl_version: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ImportPipelineDSLConfirmResponse = {
|
||||
|
||||
81
web/service/__tests__/use-pipeline.spec.tsx
Normal file
81
web/service/__tests__/use-pipeline.spec.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DSLImportMode, DSLImportStatus } from '@/models/app'
|
||||
import { post } from '../base'
|
||||
import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '../use-pipeline'
|
||||
|
||||
vi.mock('../base', () => ({
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
del: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('use-pipeline imports', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(post).mockResolvedValue({
|
||||
id: 'import-id',
|
||||
status: DSLImportStatus.COMPLETED,
|
||||
pipeline_id: 'pipeline-id',
|
||||
dataset_id: 'dataset-id',
|
||||
current_dsl_version: '0.1.0',
|
||||
imported_dsl_version: '0.1.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('should import pipeline DSL silently so callers can own error toasts', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useImportPipelineDSL(),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
const request = {
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: 'rag_pipeline: {}',
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(request)
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/rag/pipelines/imports',
|
||||
{ body: request },
|
||||
{ silent: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('should confirm pipeline DSL import silently so callers can own error toasts', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useImportPipelineDSLConfirm(),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync('import-id')
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/rag/pipelines/imports/import-id/confirm',
|
||||
{},
|
||||
{ silent: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -113,7 +113,7 @@ export const useImportPipelineDSL = (
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'dsl-import'],
|
||||
mutationFn: (request: ImportPipelineDSLRequest) => {
|
||||
return post<ImportPipelineDSLResponse>('/rag/pipelines/imports', { body: request })
|
||||
return post<ImportPipelineDSLResponse>('/rag/pipelines/imports', { body: request }, { silent: true })
|
||||
},
|
||||
...mutationOptions,
|
||||
})
|
||||
@ -125,7 +125,7 @@ export const useImportPipelineDSLConfirm = (
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'dsl-import-confirm'],
|
||||
mutationFn: (importId: string) => {
|
||||
return post<ImportPipelineDSLConfirmResponse>(`/rag/pipelines/imports/${importId}/confirm`)
|
||||
return post<ImportPipelineDSLConfirmResponse>(`/rag/pipelines/imports/${importId}/confirm`, {}, { silent: true })
|
||||
},
|
||||
...mutationOptions,
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user