mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 00:33:37 +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({
|
mockImportDSL.mockResolvedValueOnce({
|
||||||
id: 'import-failed',
|
id: 'import-failed',
|
||||||
status: DSLImportStatus.FAILED,
|
status: DSLImportStatus.FAILED,
|
||||||
|
error: 'Invalid YAML format',
|
||||||
})
|
})
|
||||||
mockImportDSL.mockRejectedValueOnce(new Error('boom'))
|
mockImportDSL.mockRejectedValueOnce(new Error('boom'))
|
||||||
|
|
||||||
@ -412,7 +413,7 @@ describe('CreateFromDSLModal', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(getCreateButton())
|
fireEvent.click(getCreateButton())
|
||||||
})
|
})
|
||||||
expect(toastMocks.error).toHaveBeenCalledWith('newApp.appCreateFailed')
|
expect(toastMocks.error).toHaveBeenCalledWith('Invalid YAML format')
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<CreateFromDSLModal
|
<CreateFromDSLModal
|
||||||
@ -427,6 +428,7 @@ describe('CreateFromDSLModal', () => {
|
|||||||
fireEvent.click(getCreateButton())
|
fireEvent.click(getCreateButton())
|
||||||
})
|
})
|
||||||
expect(toastMocks.error).toHaveBeenCalledTimes(2)
|
expect(toastMocks.error).toHaveBeenCalledTimes(2)
|
||||||
|
expect(toastMocks.error).toHaveBeenLastCalledWith('newApp.appCreateFailed')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle pending import confirmation failures and cancellation', async () => {
|
it('should handle pending import confirmation failures and cancellation', async () => {
|
||||||
@ -438,7 +440,7 @@ describe('CreateFromDSLModal', () => {
|
|||||||
current_dsl_version: '2.0.0',
|
current_dsl_version: '2.0.0',
|
||||||
})
|
})
|
||||||
mockImportDSLConfirm
|
mockImportDSLConfirm
|
||||||
.mockResolvedValueOnce({ status: DSLImportStatus.FAILED })
|
.mockResolvedValueOnce({ status: DSLImportStatus.FAILED, error: 'Confirm failed' })
|
||||||
.mockRejectedValueOnce(new Error('boom'))
|
.mockRejectedValueOnce(new Error('boom'))
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@ -465,11 +467,12 @@ describe('CreateFromDSLModal', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: 'newApp.Confirm' })[0]!)
|
fireEvent.click(screen.getAllByRole('button', { name: 'newApp.Confirm' })[0]!)
|
||||||
})
|
})
|
||||||
expect(toastMocks.error).toHaveBeenCalledWith('newApp.appCreateFailed')
|
expect(toastMocks.error).toHaveBeenCalledWith('Confirm failed')
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: 'newApp.Confirm' })[0]!)
|
fireEvent.click(screen.getAllByRole('button', { name: 'newApp.Confirm' })[0]!)
|
||||||
})
|
})
|
||||||
expect(toastMocks.error).toHaveBeenCalledTimes(2)
|
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 { cn } from '@langgenius/dify-ui/cn'
|
||||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||||
import { toast } from '@langgenius/dify-ui/toast'
|
import { toast } from '@langgenius/dify-ui/toast'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
|
||||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
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 CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', droppedFile }: CreateFromDSLModalProps) => {
|
||||||
const { push } = useRouter()
|
const { push } = useRouter()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [currentFile, setDSLFile] = useState<File | undefined>(droppedFile)
|
const [currentFile, setCurrentFile] = useState<File | undefined>(droppedFile)
|
||||||
const [fileContent, setFileContent] = useState<string>()
|
const [fileContent, setFileContent] = useState<string>()
|
||||||
const [currentTab, setCurrentTab] = useState(activeTab)
|
const [currentTab, setCurrentTab] = useState(activeTab)
|
||||||
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
|
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
|
||||||
@ -55,22 +54,22 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
|||||||
const [importId, setImportId] = useState<string>()
|
const [importId, setImportId] = useState<string>()
|
||||||
const { handleCheckPluginDependencies } = usePluginDependencies()
|
const { handleCheckPluginDependencies } = usePluginDependencies()
|
||||||
|
|
||||||
const readFile = (file: File) => {
|
const readFile = useCallback((file: File) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = function (event) {
|
reader.onload = function (event) {
|
||||||
const content = event.target?.result
|
const content = event.target?.result
|
||||||
setFileContent(content as string)
|
setFileContent(content as string)
|
||||||
}
|
}
|
||||||
reader.readAsText(file)
|
reader.readAsText(file)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleFile = (file?: File) => {
|
const handleFile = useCallback((file?: File) => {
|
||||||
setDSLFile(file)
|
setCurrentFile(file)
|
||||||
if (file)
|
if (file)
|
||||||
readFile(file)
|
readFile(file)
|
||||||
if (!file)
|
if (!file)
|
||||||
setFileContent('')
|
setFileContent('')
|
||||||
}
|
}, [readFile])
|
||||||
|
|
||||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||||
const { plan, enableBilling } = useProviderContext()
|
const { plan, enableBilling } = useProviderContext()
|
||||||
@ -81,7 +80,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (droppedFile)
|
if (droppedFile)
|
||||||
handleFile(droppedFile)
|
handleFile(droppedFile)
|
||||||
}, [droppedFile])
|
}, [droppedFile, handleFile])
|
||||||
|
|
||||||
const onCreate = async (_e?: React.MouseEvent) => {
|
const onCreate = async (_e?: React.MouseEvent) => {
|
||||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
|
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
|
||||||
@ -140,11 +139,10 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
|||||||
setImportId(id)
|
setImportId(id)
|
||||||
}
|
}
|
||||||
else {
|
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 {
|
||||||
catch (e) {
|
|
||||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||||
}
|
}
|
||||||
isCreatingRef.current = false
|
isCreatingRef.current = false
|
||||||
@ -186,11 +184,10 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
|||||||
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
|
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
|
||||||
}
|
}
|
||||||
else if (status === DSLImportStatus.FAILED) {
|
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 {
|
||||||
catch (e) {
|
|
||||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
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"
|
className="flex h-8 w-8 cursor-pointer items-center"
|
||||||
onClick={() => onClose()}
|
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>
|
</div>
|
||||||
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary">
|
<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])
|
}, [handleSave])
|
||||||
|
|
||||||
return (
|
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="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="system-xs-medium text-text-secondary">
|
<div className="system-xs-medium text-text-secondary">
|
||||||
|
|||||||
@ -34,8 +34,8 @@ const DSLConfirmModal = ({
|
|||||||
onCancel()
|
onCancel()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AlertDialogContent className="w-[480px] overflow-hidden! border-none text-left align-middle shadow-xl">
|
<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 p-6 pb-4">
|
<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>
|
<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">
|
<AlertDialogDescription render={<div />} className="flex grow flex-col system-md-regular text-text-secondary">
|
||||||
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
||||||
@ -51,7 +51,7 @@ const DSLConfirmModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogActions>
|
<AlertDialogActions className="items-start p-0 pt-6">
|
||||||
<AlertDialogCancelButton variant="secondary">{t('newApp.Cancel', { ns: 'app' })}</AlertDialogCancelButton>
|
<AlertDialogCancelButton variant="secondary">{t('newApp.Cancel', { ns: 'app' })}</AlertDialogCancelButton>
|
||||||
<AlertDialogConfirmButton onClick={onConfirm} disabled={confirmDisabled}>{t('newApp.Confirm', { ns: 'app' })}</AlertDialogConfirmButton>
|
<AlertDialogConfirmButton onClick={onConfirm} disabled={confirmDisabled}>{t('newApp.Confirm', { ns: 'app' })}</AlertDialogConfirmButton>
|
||||||
</AlertDialogActions>
|
</AlertDialogActions>
|
||||||
|
|||||||
@ -418,7 +418,10 @@ describe('useDSLImport', () => {
|
|||||||
|
|
||||||
it('should handle FAILED status', async () => {
|
it('should handle FAILED status', async () => {
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
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(
|
const { result } = renderHook(
|
||||||
() => useDSLImport({
|
() => useDSLImport({
|
||||||
@ -434,9 +437,42 @@ describe('useDSLImport', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({
|
expect(toastMocks.record).toHaveBeenCalledWith({
|
||||||
type: 'error',
|
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()
|
vi.useRealTimers()
|
||||||
@ -692,6 +728,7 @@ describe('useDSLImport', () => {
|
|||||||
status: 'failed',
|
status: 'failed',
|
||||||
pipeline_id: 'pipeline-456',
|
pipeline_id: 'pipeline-456',
|
||||||
dataset_id: 'dataset-789',
|
dataset_id: 'dataset-789',
|
||||||
|
error: 'Import information expired or does not exist',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
@ -718,9 +755,56 @@ describe('useDSLImport', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(toastMocks.record).toHaveBeenCalledWith(expect.objectContaining({
|
expect(toastMocks.record).toHaveBeenCalledWith({
|
||||||
type: 'error',
|
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()
|
vi.useRealTimers()
|
||||||
|
|||||||
@ -22,6 +22,31 @@ type DSLVersions = {
|
|||||||
importedVersion: string
|
importedVersion: string
|
||||||
systemVersion: 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) => {
|
export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', onSuccess, onClose }: UseDSLImportOptions) => {
|
||||||
const { push } = useRouter()
|
const { push } = useRouter()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@ -37,6 +62,9 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU
|
|||||||
const isCreatingRef = useRef(false)
|
const isCreatingRef = useRef(false)
|
||||||
const { mutateAsync: importDSL } = useImportPipelineDSL()
|
const { mutateAsync: importDSL } = useImportPipelineDSL()
|
||||||
const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm()
|
const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm()
|
||||||
|
const notifyError = useCallback((message?: string) => {
|
||||||
|
toast.error(message || t('creation.errorTip', { ns: 'datasetPipeline' }))
|
||||||
|
}, [t])
|
||||||
const readFile = useCallback((file: File) => {
|
const readFile = useCallback((file: File) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
@ -60,51 +88,55 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU
|
|||||||
if (isCreatingRef.current)
|
if (isCreatingRef.current)
|
||||||
return
|
return
|
||||||
isCreatingRef.current = true
|
isCreatingRef.current = true
|
||||||
let response
|
try {
|
||||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
|
let response
|
||||||
response = await importDSL({
|
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
|
||||||
mode: DSLImportMode.YAML_CONTENT,
|
response = await importDSL({
|
||||||
yaml_content: fileContent || '',
|
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) {
|
catch (error) {
|
||||||
response = await importDSL({
|
notifyError(await getImportErrorMessage(error))
|
||||||
mode: DSLImportMode.YAML_URL,
|
|
||||||
yaml_url: dslUrlValue || '',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if (!response) {
|
finally {
|
||||||
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' }))
|
|
||||||
isCreatingRef.current = false
|
isCreatingRef.current = false
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@ -114,6 +146,7 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU
|
|||||||
fileContent,
|
fileContent,
|
||||||
importDSL,
|
importDSL,
|
||||||
t,
|
t,
|
||||||
|
notifyError,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onClose,
|
onClose,
|
||||||
handleCheckPluginDependencies,
|
handleCheckPluginDependencies,
|
||||||
@ -124,25 +157,32 @@ export const useDSLImport = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslU
|
|||||||
if (!importId)
|
if (!importId)
|
||||||
return
|
return
|
||||||
setIsConfirming(true)
|
setIsConfirming(true)
|
||||||
const response = await importDSLConfirm(importId)
|
try {
|
||||||
setIsConfirming(false)
|
const response = await importDSLConfirm(importId)
|
||||||
if (!response) {
|
if (!response) {
|
||||||
toast.error(t('creation.errorTip', { ns: 'datasetPipeline' }))
|
notifyError()
|
||||||
return
|
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
|
catch (error) {
|
||||||
if (status === DSLImportStatus.COMPLETED) {
|
notifyError(await getImportErrorMessage(error))
|
||||||
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) {
|
finally {
|
||||||
toast.error(t('creation.errorTip', { ns: 'datasetPipeline' }))
|
setIsConfirming(false)
|
||||||
}
|
}
|
||||||
}, [importId, importDSLConfirm, t, onSuccess, handleCheckPluginDependencies, push])
|
}, [importId, importDSLConfirm, notifyError, t, onSuccess, handleCheckPluginDependencies, push])
|
||||||
const handleCancelConfirm = useCallback(() => {
|
const handleCancelConfirm = useCallback(() => {
|
||||||
setShowConfirmModal(false)
|
setShowConfirmModal(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@ -160,7 +160,7 @@ const TemplateCard = ({
|
|||||||
closeEditModal()
|
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
|
<EditPipelineInfo
|
||||||
pipeline={pipeline}
|
pipeline={pipeline}
|
||||||
@ -195,7 +195,7 @@ const TemplateCard = ({
|
|||||||
closeDetailsModal()
|
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
|
<Details
|
||||||
id={pipeline.id}
|
id={pipeline.id}
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogActions,
|
|
||||||
AlertDialogCancelButton,
|
|
||||||
AlertDialogConfirmButton,
|
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
@ -29,18 +26,18 @@ const DefaultContent: FC<IDefaultContentProps> = React.memo(({
|
|||||||
|
|
||||||
return (
|
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>
|
<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>
|
<AlertDialogDescription className="system-md-regular text-text-secondary">{t('segment.regenerationConfirmMessage', { ns: 'datasetDocuments' })}</AlertDialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogActions>
|
<div className="flex justify-end gap-x-2 pt-6">
|
||||||
<AlertDialogCancelButton variant="secondary" onClick={onCancel}>
|
<Button onClick={onCancel}>
|
||||||
{t('operation.cancel', { ns: 'common' })}
|
{t('operation.cancel', { ns: 'common' })}
|
||||||
</AlertDialogCancelButton>
|
</Button>
|
||||||
<AlertDialogConfirmButton onClick={onConfirm}>
|
<Button variant="primary" tone="destructive" onClick={onConfirm}>
|
||||||
{t('operation.regenerate', { ns: 'common' })}
|
{t('operation.regenerate', { ns: 'common' })}
|
||||||
</AlertDialogConfirmButton>
|
</Button>
|
||||||
</AlertDialogActions>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -52,11 +49,11 @@ const RegeneratingContent: FC = React.memo(() => {
|
|||||||
|
|
||||||
return (
|
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>
|
<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>
|
<p className="system-md-regular text-text-secondary">{t('segment.regeneratingMessage', { ns: 'datasetDocuments' })}</p>
|
||||||
</div>
|
</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">
|
<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" />
|
<RiLoader2Line className="h-4 w-4 animate-spin text-components-button-destructive-primary-text-disabled" />
|
||||||
<span>{t('operation.regenerate', { ns: 'common' })}</span>
|
<span>{t('operation.regenerate', { ns: 'common' })}</span>
|
||||||
@ -86,11 +83,11 @@ const RegenerationCompletedContent: FC<IRegenerationCompletedContentProps> = Rea
|
|||||||
|
|
||||||
return (
|
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>
|
<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>
|
<p className="system-md-regular text-text-secondary">{t('segment.regenerationSuccessMessage', { ns: 'datasetDocuments' })}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end p-6">
|
<div className="flex justify-end pt-6">
|
||||||
<Button variant="primary" onClick={onClose}>
|
<Button variant="primary" onClick={onClose}>
|
||||||
{`${t('operation.close', { ns: 'common' })}${countdown === 0 ? '' : `(${Math.round(countdown / 1000)})`}`}
|
{`${t('operation.close', { ns: 'common' })}${countdown === 0 ? '' : `(${Math.round(countdown / 1000)})`}`}
|
||||||
</Button>
|
</Button>
|
||||||
@ -131,7 +128,7 @@ const RegenerationModal: FC<IRegenerationModalProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={isShow}>
|
<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 && <DefaultContent onCancel={onCancel} onConfirm={onConfirm} />}
|
||||||
{loading && !updateSucceeded && <RegeneratingContent />}
|
{loading && !updateSucceeded && <RegeneratingContent />}
|
||||||
{!loading && updateSucceeded && <RegenerationCompletedContent onClose={onClose} />}
|
{!loading && updateSucceeded && <RegenerationCompletedContent onClose={onClose} />}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { HitTesting } from '@/models/datasets'
|
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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import ChunkDetailModal from '../chunk-detail-modal'
|
import ChunkDetailModal from '../chunk-detail-modal'
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <div data-testid="modal-title">{children}</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', () => ({
|
vi.mock('../../../common/image-list', () => ({
|
||||||
@ -136,4 +136,10 @@ describe('ChunkDetailModal', () => {
|
|||||||
render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />)
|
render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />)
|
||||||
expect(screen.getByTestId('mask')).toBeInTheDocument()
|
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()
|
onHide()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', cn(isParentChildRetrieval ? 'min-w-[1200px]!' : 'min-w-[800px]!'))}>
|
<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" />
|
<DialogCloseButton
|
||||||
|
data-testid="modal-close-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onHide()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||||
{t(`${i18nPrefix}chunkDetail`, { ns: 'datasetHitTesting' })}
|
{t(`${i18nPrefix}chunkDetail`, { ns: 'datasetHitTesting' })}
|
||||||
</DialogTitle>
|
</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 () => {
|
it('should invalidate app API keys after creating', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||||
|
|||||||
@ -104,90 +104,99 @@ const SecretKeyModal = ({
|
|||||||
setShowConfirmDelete(false)
|
setShowConfirmDelete(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const handleClose = () => {
|
||||||
<Dialog
|
setVisible(false)
|
||||||
open={isShow}
|
onClose()
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="-mt-6 -mr-2 mb-4 flex justify-end">
|
return (
|
||||||
<XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} />
|
<>
|
||||||
</div>
|
<Dialog
|
||||||
<p className="mt-1 shrink-0 text-[13px] leading-5 font-normal text-text-tertiary">{t('apiKeyModal.apiSecretKeyTips', { ns: 'appApi' })}</p>
|
open={isShow}
|
||||||
{isApiKeysLoading && <div className="mt-4"><Loading /></div>}
|
onOpenChange={(open) => {
|
||||||
{
|
if (!open)
|
||||||
!!apiKeysList?.data?.length && (
|
handleClose()
|
||||||
<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>
|
<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`)}>
|
||||||
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.created', { ns: 'appApi' })}</div>
|
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||||
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.lastUsed', { ns: 'appApi' })}</div>
|
{`${t('apiKeyModal.apiSecretKey', { ns: 'appApi' })}`}
|
||||||
<div className="grow px-3"></div>
|
</DialogTitle>
|
||||||
</div>
|
|
||||||
<div className="grow overflow-auto">
|
<div className="-mt-6 -mr-2 mb-4 flex justify-end">
|
||||||
{apiKeysList.data.map(api => (
|
<XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={handleClose} />
|
||||||
<div className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary" key={api.id}>
|
</div>
|
||||||
<div className="w-64 shrink-0 truncate px-3 font-mono">{generateToken(api.token)}</div>
|
<p className="mt-1 shrink-0 text-[13px] leading-5 font-normal text-text-tertiary">{t('apiKeyModal.apiSecretKeyTips', { ns: 'appApi' })}</p>
|
||||||
<div className="w-[200px] shrink-0 truncate px-3">{formatTime(Number(api.created_at), t('dateTimeFormat', { ns: 'appLog' }) as string)}</div>
|
{isApiKeysLoading && <div className="mt-4"><Loading /></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">
|
!!apiKeysList?.data?.length && (
|
||||||
<CopyFeedback content={api.token} />
|
<div className="mt-4 flex grow flex-col overflow-hidden">
|
||||||
{isCurrentWorkspaceManager && (
|
<div className="flex h-9 shrink-0 items-center border-b border-divider-regular text-xs font-semibold text-text-tertiary">
|
||||||
<ActionButton
|
<div className="w-64 shrink-0 px-3">{t('apiKeyModal.secretKey', { ns: 'appApi' })}</div>
|
||||||
onClick={() => {
|
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.created', { ns: 'appApi' })}</div>
|
||||||
setDelKeyId(api.id)
|
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.lastUsed', { ns: 'appApi' })}</div>
|
||||||
setShowConfirmDelete(true)
|
<div className="grow px-3"></div>
|
||||||
}}
|
</div>
|
||||||
>
|
<div className="grow overflow-auto">
|
||||||
<RiDeleteBinLine className="h-4 w-4" />
|
{apiKeysList.data.map(api => (
|
||||||
</ActionButton>
|
<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>
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
}
|
<div className="flex">
|
||||||
<div className="flex">
|
<Button className={`mt-4 flex shrink-0 ${s.autoWidth}`} onClick={onCreate} disabled={!currentWorkspace || !isCurrentWorkspaceEditor}>
|
||||||
<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" />
|
||||||
<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>
|
||||||
<div className="text-xs font-medium text-text-secondary">{t('apiKeyModal.createNewSecretKey', { ns: 'appApi' })}</div>
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</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} />
|
<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.
|
// NOTE: Some upload endpoints have historically returned successful plugin upload
|
||||||
// Success vs failure is determined by whether response.message exists:
|
// payloads through rejected XHR objects, so the component accepts both resolved
|
||||||
// - If response.message exists → treated as failure (calls onFailed)
|
// responses and rejected responses without an error message.
|
||||||
// - If response.message is absent → treated as success (calls onPackageUploaded/onBundleUploaded)
|
|
||||||
// This explains why we use mockRejectedValue for "success" scenarios below.
|
|
||||||
|
|
||||||
it('should call onPackageUploaded when upload rejects without error message (success case)', async () => {
|
it('should call onPackageUploaded when upload rejects without error message (success case)', async () => {
|
||||||
const mockResult = {
|
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 () => {
|
it('should call onBundleUploaded when upload rejects without error message (success case)', async () => {
|
||||||
const mockDependencies = createMockDependencies()
|
const mockDependencies = createMockDependencies()
|
||||||
mockUploadFile.mockRejectedValue({
|
mockUploadFile.mockRejectedValue({
|
||||||
@ -212,6 +234,24 @@ describe('Uploading', () => {
|
|||||||
expect(onBundleUploaded).toHaveBeenCalledWith(mockDependencies)
|
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
|
// Edge Cases Tests
|
||||||
// ================================
|
// ================================
|
||||||
describe('Edge Cases', () => {
|
describe('Edge Cases', () => {
|
||||||
it('should handle empty response gracefully', async () => {
|
it('should fail gracefully when upload response is empty', async () => {
|
||||||
mockUploadFile.mockRejectedValue({
|
mockUploadFile.mockRejectedValue({
|
||||||
response: {},
|
response: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const onPackageUploaded = vi.fn()
|
const onPackageUploaded = vi.fn()
|
||||||
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} />)
|
const onFailed = vi.fn()
|
||||||
|
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onPackageUploaded).toHaveBeenCalledWith({
|
expect(onPackageUploaded).not.toHaveBeenCalled()
|
||||||
uniqueIdentifier: undefined,
|
expect(onFailed).toHaveBeenCalledWith('plugin.installModal.uploadFailed')
|
||||||
manifest: undefined,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle response with only unique_identifier', async () => {
|
it('should fail gracefully when upload response has no manifest', async () => {
|
||||||
mockUploadFile.mockRejectedValue({
|
mockUploadFile.mockRejectedValue({
|
||||||
response: { unique_identifier: 'only-uid' },
|
response: { unique_identifier: 'only-uid' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const onPackageUploaded = vi.fn()
|
const onPackageUploaded = vi.fn()
|
||||||
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} />)
|
const onFailed = vi.fn()
|
||||||
|
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} onFailed={onFailed} />)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onPackageUploaded).toHaveBeenCalledWith({
|
expect(onPackageUploaded).not.toHaveBeenCalled()
|
||||||
uniqueIdentifier: 'only-uid',
|
expect(onFailed).toHaveBeenCalledWith('plugin.installModal.uploadFailed')
|
||||||
manifest: undefined,
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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'
|
'use client'
|
||||||
import type { FC } from 'react'
|
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 { Button } from '@langgenius/dify-ui/button'
|
||||||
import { RiLoader2Line } from '@remixicon/react'
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { uploadFile } from '@/service/plugins'
|
import { uploadFile } from '@/service/plugins'
|
||||||
@ -10,6 +9,40 @@ import Card from '../../../card'
|
|||||||
|
|
||||||
const i18nPrefix = 'installModal'
|
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 = {
|
type Props = {
|
||||||
isBundle: boolean
|
isBundle: boolean
|
||||||
file: File
|
file: File
|
||||||
@ -32,36 +65,50 @@ const Uploading: FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const fileName = file.name
|
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 {
|
try {
|
||||||
await uploadFile(file, isBundle)
|
handleUploadedResponse(await uploadFile(file, isBundle))
|
||||||
}
|
}
|
||||||
catch (e: any) {
|
catch (error: unknown) {
|
||||||
if (e.response?.message) {
|
const response = getRejectedResponse(error)
|
||||||
onFailed(e.response?.message)
|
const message = getUploadFailureMessage(response)
|
||||||
}
|
if (message) {
|
||||||
else { // Why it would into this branch?
|
onFailed(message)
|
||||||
const res = e.response
|
return
|
||||||
if (isBundle) {
|
|
||||||
onBundleUploaded(res)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onPackageUploaded({
|
|
||||||
uniqueIdentifier: res.unique_identifier,
|
|
||||||
manifest: res.manifest,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
handleUploadedResponse(response)
|
||||||
}
|
}
|
||||||
}
|
}, [file, handleUploadedResponse, isBundle, onFailed])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
handleUpload()
|
handleUpload()
|
||||||
}, [])
|
}, [handleUpload])
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
<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">
|
<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">
|
<div className="system-md-regular text-text-secondary">
|
||||||
{t(`${i18nPrefix}.uploadingPackage`, {
|
{t(`${i18nPrefix}.uploadingPackage`, {
|
||||||
ns: 'plugin',
|
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">
|
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">
|
||||||
<Card
|
<Card
|
||||||
className="w-full"
|
className="w-full"
|
||||||
payload={{ name: fileName } as any}
|
payload={{ name: fileName } as Plugin}
|
||||||
isLoading
|
isLoading
|
||||||
loadingFileName={fileName}
|
loadingFileName={fileName}
|
||||||
installed={false}
|
installed={false}
|
||||||
|
|||||||
@ -36,8 +36,8 @@ const VersionMismatchModal = ({
|
|||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AlertDialogContent className="w-[480px] overflow-hidden! border-none text-left align-middle shadow-xl">
|
<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 p-6 pb-4">
|
<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>
|
<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">
|
<AlertDialogDescription render={<div />} className="flex grow flex-col system-md-regular text-text-secondary">
|
||||||
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
||||||
@ -53,7 +53,7 @@ const VersionMismatchModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogActions>
|
<AlertDialogActions className="items-start p-0 pt-6">
|
||||||
<AlertDialogCancelButton variant="secondary">{t('newApp.Cancel', { ns: 'app' })}</AlertDialogCancelButton>
|
<AlertDialogCancelButton variant="secondary">{t('newApp.Cancel', { ns: 'app' })}</AlertDialogCancelButton>
|
||||||
<AlertDialogConfirmButton onClick={onConfirm}>{t('newApp.Confirm', { ns: 'app' })}</AlertDialogConfirmButton>
|
<AlertDialogConfirmButton onClick={onConfirm}>{t('newApp.Confirm', { ns: 'app' })}</AlertDialogConfirmButton>
|
||||||
</AlertDialogActions>
|
</AlertDialogActions>
|
||||||
|
|||||||
@ -340,6 +340,7 @@ describe('useUpdateDSLModal', () => {
|
|||||||
id: 'import-id',
|
id: 'import-id',
|
||||||
status: DSLImportStatus.FAILED,
|
status: DSLImportStatus.FAILED,
|
||||||
pipeline_id: 'test-pipeline-id',
|
pipeline_id: 'test-pipeline-id',
|
||||||
|
error: 'Missing rag_pipeline data in YAML content',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { result } = renderUpdateDSLModal()
|
const { result } = renderUpdateDSLModal()
|
||||||
@ -351,7 +352,10 @@ describe('useUpdateDSLModal', () => {
|
|||||||
await (result.current.handleImport as unknown as AsyncFn)()
|
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 () => {
|
it('should notify error when importDSL throws', async () => {
|
||||||
@ -369,6 +373,29 @@ describe('useUpdateDSLModal', () => {
|
|||||||
expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
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 () => {
|
it('should notify error when pipeline_id is missing on success', async () => {
|
||||||
mockImportDSL.mockResolvedValue({
|
mockImportDSL.mockResolvedValue({
|
||||||
id: 'import-id',
|
id: 'import-id',
|
||||||
@ -468,6 +495,7 @@ describe('useUpdateDSLModal', () => {
|
|||||||
mockImportDSLConfirm.mockResolvedValue({
|
mockImportDSLConfirm.mockResolvedValue({
|
||||||
status: DSLImportStatus.FAILED,
|
status: DSLImportStatus.FAILED,
|
||||||
pipeline_id: 'test-pipeline-id',
|
pipeline_id: 'test-pipeline-id',
|
||||||
|
error: 'Import information expired or does not exist',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { result } = renderUpdateDSLModal()
|
const { result } = renderUpdateDSLModal()
|
||||||
@ -477,7 +505,10 @@ describe('useUpdateDSLModal', () => {
|
|||||||
await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)()
|
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 () => {
|
it('should notify error when confirm throws exception', async () => {
|
||||||
@ -493,6 +524,27 @@ describe('useUpdateDSLModal', () => {
|
|||||||
expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
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 () => {
|
it('should notify error when confirm succeeds but pipeline_id is missing', async () => {
|
||||||
mockImportDSLConfirm.mockResolvedValue({
|
mockImportDSLConfirm.mockResolvedValue({
|
||||||
status: DSLImportStatus.COMPLETED,
|
status: DSLImportStatus.COMPLETED,
|
||||||
|
|||||||
@ -15,11 +15,36 @@ type VersionInfo = {
|
|||||||
importedVersion: string
|
importedVersion: string
|
||||||
systemVersion: string
|
systemVersion: string
|
||||||
}
|
}
|
||||||
|
type ImportErrorResponse = {
|
||||||
|
message?: unknown
|
||||||
|
error?: unknown
|
||||||
|
}
|
||||||
type UseUpdateDSLModalParams = {
|
type UseUpdateDSLModalParams = {
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onImport?: () => void
|
onImport?: () => void
|
||||||
}
|
}
|
||||||
const isCompletedStatus = (status: DSLImportStatus): boolean => status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS
|
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) => {
|
export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParams) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { eventEmitter } = useEventEmitterContextContext()
|
const { eventEmitter } = useEventEmitterContextContext()
|
||||||
@ -52,9 +77,9 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam
|
|||||||
if (!file)
|
if (!file)
|
||||||
setFileContent('')
|
setFileContent('')
|
||||||
}
|
}
|
||||||
const notifyError = useCallback(() => {
|
const notifyError = useCallback((message?: string) => {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
toast.error(t('common.importFailure', { ns: 'workflow' }))
|
toast.error(message || t('common.importFailure', { ns: 'workflow' }))
|
||||||
}, [t])
|
}, [t])
|
||||||
const updateWorkflow = useCallback(async (pipelineId: string) => {
|
const updateWorkflow = useCallback(async (pipelineId: string) => {
|
||||||
const { graph, hash, rag_pipeline_variables } = await fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`)
|
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)
|
else if (status === DSLImportStatus.PENDING)
|
||||||
showVersionMismatch(id, imported_dsl_version, current_dsl_version)
|
showVersionMismatch(id, imported_dsl_version, current_dsl_version)
|
||||||
else
|
else
|
||||||
notifyError()
|
notifyError(response.error)
|
||||||
}
|
}
|
||||||
catch {
|
catch (error) {
|
||||||
notifyError()
|
notifyError(await getImportErrorMessage(error))
|
||||||
}
|
}
|
||||||
isCreatingRef.current = false
|
isCreatingRef.current = false
|
||||||
}, [currentFile, fileContent, workflowStore, importDSL, completeImport, showVersionMismatch, notifyError])
|
}, [currentFile, fileContent, workflowStore, importDSL, completeImport, showVersionMismatch, notifyError])
|
||||||
@ -128,16 +153,16 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam
|
|||||||
if (!importId)
|
if (!importId)
|
||||||
return
|
return
|
||||||
try {
|
try {
|
||||||
const { status, pipeline_id } = await importDSLConfirm(importId)
|
const { status, pipeline_id, error } = await importDSLConfirm(importId)
|
||||||
if (status === DSLImportStatus.COMPLETED) {
|
if (status === DSLImportStatus.COMPLETED) {
|
||||||
await completeImport(pipeline_id)
|
await completeImport(pipeline_id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (status === DSLImportStatus.FAILED)
|
if (status === DSLImportStatus.FAILED)
|
||||||
notifyError()
|
notifyError(error)
|
||||||
}
|
}
|
||||||
catch {
|
catch (error) {
|
||||||
notifyError()
|
notifyError(await getImportErrorMessage(error))
|
||||||
}
|
}
|
||||||
}, [importId, importDSLConfirm, completeImport, notifyError])
|
}, [importId, importDSLConfirm, completeImport, notifyError])
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -89,6 +89,7 @@ export type ImportPipelineDSLResponse = {
|
|||||||
dataset_id: string
|
dataset_id: string
|
||||||
current_dsl_version: string
|
current_dsl_version: string
|
||||||
imported_dsl_version: string
|
imported_dsl_version: string
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImportPipelineDSLConfirmResponse = {
|
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({
|
return useMutation({
|
||||||
mutationKey: [NAME_SPACE, 'dsl-import'],
|
mutationKey: [NAME_SPACE, 'dsl-import'],
|
||||||
mutationFn: (request: ImportPipelineDSLRequest) => {
|
mutationFn: (request: ImportPipelineDSLRequest) => {
|
||||||
return post<ImportPipelineDSLResponse>('/rag/pipelines/imports', { body: request })
|
return post<ImportPipelineDSLResponse>('/rag/pipelines/imports', { body: request }, { silent: true })
|
||||||
},
|
},
|
||||||
...mutationOptions,
|
...mutationOptions,
|
||||||
})
|
})
|
||||||
@ -125,7 +125,7 @@ export const useImportPipelineDSLConfirm = (
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey: [NAME_SPACE, 'dsl-import-confirm'],
|
mutationKey: [NAME_SPACE, 'dsl-import-confirm'],
|
||||||
mutationFn: (importId: string) => {
|
mutationFn: (importId: string) => {
|
||||||
return post<ImportPipelineDSLConfirmResponse>(`/rag/pipelines/imports/${importId}/confirm`)
|
return post<ImportPipelineDSLConfirmResponse>(`/rag/pipelines/imports/${importId}/confirm`, {}, { silent: true })
|
||||||
},
|
},
|
||||||
...mutationOptions,
|
...mutationOptions,
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user