mirror of
https://github.com/langgenius/dify.git
synced 2026-01-29 16:26:12 +08:00
Compare commits
2 Commits
plugin-sch
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b9ac7af9c5 | |||
| 74cfe77674 |
@ -617,7 +617,6 @@ PLUGIN_DAEMON_URL=http://127.0.0.1:5002
|
||||
PLUGIN_REMOTE_INSTALL_PORT=5003
|
||||
PLUGIN_REMOTE_INSTALL_HOST=localhost
|
||||
PLUGIN_MAX_PACKAGE_SIZE=15728640
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
|
||||
|
||||
# Marketplace configuration
|
||||
@ -717,3 +716,4 @@ SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000
|
||||
|
||||
|
||||
@ -245,7 +245,7 @@ class PluginConfig(BaseSettings):
|
||||
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL: PositiveInt = Field(
|
||||
description="TTL in seconds for caching plugin model schemas in Redis",
|
||||
default=60 * 60,
|
||||
default=24 * 60 * 60,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1375,7 +1375,6 @@ PLUGIN_DAEMON_PORT=5002
|
||||
PLUGIN_DAEMON_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi
|
||||
PLUGIN_DAEMON_URL=http://plugin_daemon:5002
|
||||
PLUGIN_MAX_PACKAGE_SIZE=52428800
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
PLUGIN_PPROF_ENABLED=false
|
||||
|
||||
PLUGIN_DEBUGGING_HOST=0.0.0.0
|
||||
|
||||
@ -589,7 +589,6 @@ x-shared-env: &shared-api-worker-env
|
||||
PLUGIN_DAEMON_KEY: ${PLUGIN_DAEMON_KEY:-lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi}
|
||||
PLUGIN_DAEMON_URL: ${PLUGIN_DAEMON_URL:-http://plugin_daemon:5002}
|
||||
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL: ${PLUGIN_MODEL_SCHEMA_CACHE_TTL:-3600}
|
||||
PLUGIN_PPROF_ENABLED: ${PLUGIN_PPROF_ENABLED:-false}
|
||||
PLUGIN_DEBUGGING_HOST: ${PLUGIN_DEBUGGING_HOST:-0.0.0.0}
|
||||
PLUGIN_DEBUGGING_PORT: ${PLUGIN_DEBUGGING_PORT:-5003}
|
||||
|
||||
@ -31,6 +31,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import AppIcon from '../base/app-icon'
|
||||
import AppOperations from './app-operations'
|
||||
|
||||
@ -145,13 +146,8 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
appID: appDetail.id,
|
||||
include,
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${appDetail.name}.yml`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob({ data: file, fileName: `${appDetail.name}.yml` })
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
|
||||
|
||||
@ -11,6 +11,7 @@ import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/kn
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { useExportPipelineDSL } from '@/service/use-pipeline'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import ActionButton from '../../base/action-button'
|
||||
import Confirm from '../../base/confirm'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
||||
@ -64,13 +65,8 @@ const DropDown = ({
|
||||
pipelineId: pipeline_id,
|
||||
include,
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${name}.pipeline`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob({ data: file, fileName: `${name}.pipeline` })
|
||||
}
|
||||
catch {
|
||||
Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
|
||||
|
||||
@ -21,6 +21,7 @@ import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
|
||||
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import Button from '../../../base/button'
|
||||
import AddAnnotationModal from '../add-annotation-modal'
|
||||
import BatchAddModal from '../batch-add-annotation-modal'
|
||||
@ -56,28 +57,23 @@ const HeaderOptions: FC<Props> = ({
|
||||
)
|
||||
|
||||
const JSONLOutput = () => {
|
||||
const a = document.createElement('a')
|
||||
const content = listTransformer(list).join('\n')
|
||||
const file = new Blob([content], { type: 'application/jsonl' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `annotations-${locale}.jsonl`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob({ data: file, fileName: `annotations-${locale}.jsonl` })
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
const fetchList = React.useCallback(async () => {
|
||||
const { data }: any = await fetchExportAnnotationList(appId)
|
||||
setList(data as AnnotationItemBasic[])
|
||||
}
|
||||
}, [appId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchList()
|
||||
}, [])
|
||||
}, [fetchList])
|
||||
useEffect(() => {
|
||||
if (controlUpdateList)
|
||||
fetchList()
|
||||
}, [controlUpdateList])
|
||||
}, [controlUpdateList, fetchList])
|
||||
|
||||
const [showBulkImportModal, setShowBulkImportModal] = useState(false)
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
||||
|
||||
@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import type { IConfigVarProps } from './index'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
@ -240,7 +240,9 @@ describe('ConfigVar', () => {
|
||||
const saveButton = await screen.findByRole('button', { name: 'common.operation.save' })
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => {
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error when variable key is duplicated', async () => {
|
||||
|
||||
@ -33,6 +33,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { formatTime } from '@/utils/time'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
@ -161,13 +162,8 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
appID: app.id,
|
||||
include,
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${app.name}.yml`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob({ data: file, fileName: `${app.name}.yml` })
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
|
||||
@ -346,7 +342,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`,
|
||||
})
|
||||
return `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${timeText}`
|
||||
}, [app.updated_at, app.created_at])
|
||||
}, [app.updated_at, app.created_at, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -15,11 +15,11 @@ import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import FileImageRender from '../file-image-render'
|
||||
import FileTypeIcon from '../file-type-icon'
|
||||
import {
|
||||
downloadFile,
|
||||
fileIsUploaded,
|
||||
getFileAppearanceType,
|
||||
getFileExtension,
|
||||
@ -140,7 +140,7 @@ const FileInAttachmentItem = ({
|
||||
showDownloadAction && (
|
||||
<ActionButton onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadFile(url || base64Url || '', name)
|
||||
downloadUrl({ url: url || base64Url || '', fileName: name, target: '_blank' })
|
||||
}}
|
||||
>
|
||||
<RiDownloadLine className="h-4 w-4" />
|
||||
|
||||
@ -8,9 +8,9 @@ import Button from '@/app/components/base/button'
|
||||
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import FileImageRender from '../file-image-render'
|
||||
import {
|
||||
downloadFile,
|
||||
fileIsUploaded,
|
||||
} from '../utils'
|
||||
|
||||
@ -85,7 +85,7 @@ const FileImageItem = ({
|
||||
className="absolute bottom-0.5 right-0.5 flex h-6 w-6 items-center justify-center rounded-lg bg-components-actionbar-bg shadow-md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadFile(download_url || '', name)
|
||||
downloadUrl({ url: download_url || '', fileName: name, target: '_blank' })
|
||||
}}
|
||||
>
|
||||
<RiDownloadLine className="h-4 w-4 text-text-tertiary" />
|
||||
|
||||
@ -12,10 +12,10 @@ import VideoPreview from '@/app/components/base/file-uploader/video-preview'
|
||||
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import FileTypeIcon from '../file-type-icon'
|
||||
import {
|
||||
downloadFile,
|
||||
fileIsUploaded,
|
||||
getFileAppearanceType,
|
||||
getFileExtension,
|
||||
@ -100,7 +100,7 @@ const FileItem = ({
|
||||
className="absolute -right-1 -top-1 hidden group-hover/file-item:flex"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadFile(download_url || '', name)
|
||||
downloadUrl({ url: download_url || '', fileName: name, target: '_blank' })
|
||||
}}
|
||||
>
|
||||
<RiDownloadLine className="h-3.5 w-3.5 text-text-tertiary" />
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { MockInstance } from 'vitest'
|
||||
import mime from 'mime'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { upload } from '@/service/base'
|
||||
@ -6,7 +5,6 @@ import { TransferMethod } from '@/types/app'
|
||||
import { FILE_EXTS } from '../prompt-editor/constants'
|
||||
import { FileAppearanceTypeEnum } from './types'
|
||||
import {
|
||||
downloadFile,
|
||||
fileIsUploaded,
|
||||
fileUpload,
|
||||
getFileAppearanceType,
|
||||
@ -782,74 +780,4 @@ describe('file-uploader utils', () => {
|
||||
} as any)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadFile', () => {
|
||||
let mockAnchor: HTMLAnchorElement
|
||||
let createElementMock: MockInstance
|
||||
let appendChildMock: MockInstance
|
||||
let removeChildMock: MockInstance
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock createElement and appendChild
|
||||
mockAnchor = {
|
||||
href: '',
|
||||
download: '',
|
||||
style: { display: '' },
|
||||
target: '',
|
||||
title: '',
|
||||
click: vi.fn(),
|
||||
} as unknown as HTMLAnchorElement
|
||||
|
||||
createElementMock = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor as any)
|
||||
appendChildMock = vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||
return node
|
||||
})
|
||||
removeChildMock = vi.spyOn(document.body, 'removeChild').mockImplementation((node: Node) => {
|
||||
return node
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('should create and trigger download with correct attributes', () => {
|
||||
const url = 'https://example.com/test.pdf'
|
||||
const filename = 'test.pdf'
|
||||
|
||||
downloadFile(url, filename)
|
||||
|
||||
// Verify anchor element was created with correct properties
|
||||
expect(createElementMock).toHaveBeenCalledWith('a')
|
||||
expect(mockAnchor.href).toBe(url)
|
||||
expect(mockAnchor.download).toBe(filename)
|
||||
expect(mockAnchor.style.display).toBe('none')
|
||||
expect(mockAnchor.target).toBe('_blank')
|
||||
expect(mockAnchor.title).toBe(filename)
|
||||
|
||||
// Verify DOM operations
|
||||
expect(appendChildMock).toHaveBeenCalledWith(mockAnchor)
|
||||
expect(mockAnchor.click).toHaveBeenCalled()
|
||||
expect(removeChildMock).toHaveBeenCalledWith(mockAnchor)
|
||||
})
|
||||
|
||||
it('should handle empty filename', () => {
|
||||
const url = 'https://example.com/test.pdf'
|
||||
const filename = ''
|
||||
|
||||
downloadFile(url, filename)
|
||||
|
||||
expect(mockAnchor.download).toBe('')
|
||||
expect(mockAnchor.title).toBe('')
|
||||
})
|
||||
|
||||
it('should handle empty url', () => {
|
||||
const url = ''
|
||||
const filename = 'test.pdf'
|
||||
|
||||
downloadFile(url, filename)
|
||||
|
||||
expect(mockAnchor.href).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -249,15 +249,3 @@ export const fileIsUploaded = (file: FileEntity) => {
|
||||
if (file.transferMethod === TransferMethod.remote_url && file.progress === 100)
|
||||
return true
|
||||
}
|
||||
|
||||
export const downloadFile = (url: string, filename: string) => {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.style.display = 'none'
|
||||
anchor.target = '_blank'
|
||||
anchor.title = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import { createPortal } from 'react-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
|
||||
type ImagePreviewProps = {
|
||||
url: string
|
||||
@ -60,27 +61,14 @@ const ImagePreview: FC<ImagePreviewProps> = ({
|
||||
|
||||
const downloadImage = () => {
|
||||
// Open in a new window, considering the case when the page is inside an iframe
|
||||
if (url.startsWith('http') || url.startsWith('https')) {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.target = '_blank'
|
||||
a.download = title
|
||||
a.click()
|
||||
}
|
||||
else if (url.startsWith('data:image')) {
|
||||
// Base64 image
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.target = '_blank'
|
||||
a.download = title
|
||||
a.click()
|
||||
}
|
||||
else {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: `Unable to open image: ${url}`,
|
||||
})
|
||||
if (url.startsWith('http') || url.startsWith('https') || url.startsWith('data:image')) {
|
||||
downloadUrl({ url, fileName: title, target: '_blank' })
|
||||
return
|
||||
}
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: `Unable to open image: ${url}`,
|
||||
})
|
||||
}
|
||||
|
||||
const zoomIn = () => {
|
||||
@ -135,12 +123,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
|
||||
catch (err) {
|
||||
console.error('Failed to copy image:', err)
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${title}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
downloadUrl({ url, fileName: `${title}.png` })
|
||||
|
||||
Toast.notify({
|
||||
type: 'info',
|
||||
@ -215,6 +198,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
|
||||
tabIndex={-1}
|
||||
>
|
||||
{ }
|
||||
{/* eslint-disable-next-line next/no-img-element */}
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt={title}
|
||||
|
||||
@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
|
||||
type Props = {
|
||||
content: string
|
||||
@ -40,11 +41,10 @@ const ShareQRCode = ({ content }: Props) => {
|
||||
}, [isShow])
|
||||
|
||||
const downloadQR = () => {
|
||||
const canvas = document.getElementsByTagName('canvas')[0]
|
||||
const link = document.createElement('a')
|
||||
link.download = 'qrcode.png'
|
||||
link.href = canvas.toDataURL()
|
||||
link.click()
|
||||
const canvas = qrCodeRef.current?.querySelector('canvas')
|
||||
if (!(canvas instanceof HTMLCanvasElement))
|
||||
return
|
||||
downloadUrl({ url: canvas.toDataURL(), fileName: 'qrcode.png' })
|
||||
}
|
||||
|
||||
const handlePanelClick = (event: React.MouseEvent) => {
|
||||
|
||||
@ -179,8 +179,10 @@ describe('RetryButton (IndexFailed)', () => {
|
||||
}, false),
|
||||
)
|
||||
|
||||
// Delay the response to test loading state
|
||||
mockRetryErrorDocs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ result: 'success' }), 100)))
|
||||
let resolveRetry: ((value: { result: 'success' }) => void) | undefined
|
||||
mockRetryErrorDocs.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveRetry = resolve
|
||||
}))
|
||||
|
||||
render(<RetryButton datasetId="test-dataset" />)
|
||||
|
||||
@ -193,6 +195,11 @@ describe('RetryButton (IndexFailed)', () => {
|
||||
expect(button).toHaveClass('cursor-not-allowed')
|
||||
expect(button).toHaveClass('text-text-disabled')
|
||||
})
|
||||
|
||||
resolveRetry?.({ result: 'success' })
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -23,9 +23,10 @@ vi.mock('@/app/components/base/toast', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock downloadFile utility
|
||||
vi.mock('@/utils/format', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
// Mock download utilities
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: vi.fn(),
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
// Capture Confirm callbacks
|
||||
@ -502,8 +503,8 @@ describe('TemplateCard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should call downloadFile on successful export', async () => {
|
||||
const { downloadFile } = await import('@/utils/format')
|
||||
it('should call downloadBlob on successful export', async () => {
|
||||
const { downloadBlob } = await import('@/utils/download')
|
||||
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
|
||||
callbacks.onSuccess({ data: 'yaml_content' })
|
||||
return Promise.resolve()
|
||||
@ -514,7 +515,7 @@ describe('TemplateCard', () => {
|
||||
fireEvent.click(exportButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(downloadFile).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(downloadBlob).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fileName: 'Test Pipeline.pipeline',
|
||||
}))
|
||||
})
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
useInvalidCustomizedTemplateList,
|
||||
usePipelineTemplateById,
|
||||
} from '@/service/use-pipeline'
|
||||
import { downloadFile } from '@/utils/format'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import Actions from './actions'
|
||||
import Content from './content'
|
||||
import Details from './details'
|
||||
@ -108,10 +108,7 @@ const TemplateCard = ({
|
||||
await exportPipelineDSL(pipeline.id, {
|
||||
onSuccess: (res) => {
|
||||
const blob = new Blob([res.data], { type: 'application/yaml' })
|
||||
downloadFile({
|
||||
data: blob,
|
||||
fileName: `${pipeline.name}.pipeline`,
|
||||
})
|
||||
downloadBlob({ data: blob, fileName: `${pipeline.name}.pipeline` })
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('exportDSL.successTip', { ns: 'datasetPipeline' }),
|
||||
|
||||
@ -125,11 +125,25 @@ const WaterCrawl: FC<Props> = ({
|
||||
await sleep(2500)
|
||||
return await waitForCrawlFinished(jobId)
|
||||
}
|
||||
catch (e: any) {
|
||||
const errorBody = await e.json()
|
||||
catch (error: unknown) {
|
||||
let errorMessage = ''
|
||||
|
||||
const maybeErrorWithJson = error as { json?: () => Promise<unknown>, message?: unknown } | null
|
||||
if (maybeErrorWithJson?.json) {
|
||||
try {
|
||||
const errorBody = await maybeErrorWithJson.json() as { message?: unknown } | null
|
||||
if (typeof errorBody?.message === 'string')
|
||||
errorMessage = errorBody.message
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
if (!errorMessage && typeof maybeErrorWithJson?.message === 'string')
|
||||
errorMessage = maybeErrorWithJson.message
|
||||
|
||||
return {
|
||||
isError: true,
|
||||
errorMessage: errorBody.message,
|
||||
errorMessage,
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
|
||||
@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useCheckDatasetUsage, useDeleteDataset } from '@/service/use-dataset-card'
|
||||
import { useExportPipelineDSL } from '@/service/use-pipeline'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
|
||||
type ModalState = {
|
||||
showRenameModal: boolean
|
||||
@ -65,13 +66,8 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
|
||||
pipelineId: pipeline_id,
|
||||
include,
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${name}.pipeline`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob({ data: file, fileName: `${name}.pipeline` })
|
||||
}
|
||||
catch {
|
||||
Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
|
||||
|
||||
@ -10,6 +10,7 @@ import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { getDocDownloadUrl } from '@/service/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import Button from '../../base/button'
|
||||
import Gdpr from '../../base/icons/src/public/common/Gdpr'
|
||||
import Iso from '../../base/icons/src/public/common/Iso'
|
||||
@ -47,9 +48,7 @@ const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
|
||||
mutationFn: async () => {
|
||||
try {
|
||||
const ret = await getDocDownloadUrl(doc_name)
|
||||
const a = document.createElement('a')
|
||||
a.href = ret.url
|
||||
a.click()
|
||||
downloadUrl({ url: ret.url })
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('operation.downloadSuccess', { ns: 'common' }),
|
||||
|
||||
@ -11,6 +11,7 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useExportPipelineDSL } from '@/service/use-pipeline'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
|
||||
export const useDSL = () => {
|
||||
@ -37,13 +38,8 @@ export const useDSL = () => {
|
||||
pipelineId,
|
||||
include,
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${knowledgeName}.pipeline`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob({ data: file, fileName: `${knowledgeName}.pipeline` })
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { exportAppConfig } from '@/service/apps'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
|
||||
export const useDSL = () => {
|
||||
@ -37,13 +38,8 @@ export const useDSL = () => {
|
||||
include,
|
||||
workflowID: workflowId,
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${appDetail.name}.yml`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob({ data: file, fileName: `${appDetail.name}.yml` })
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useDownloadPlugin } from '@/service/use-plugins'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadFile } from '@/utils/format'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
type Props = {
|
||||
@ -67,7 +67,7 @@ const OperationDropdown: FC<Props> = ({
|
||||
if (!needDownload || !blob)
|
||||
return
|
||||
const fileName = `${author}-${name}_${version}.zip`
|
||||
downloadFile({ data: blob, fileName })
|
||||
downloadBlob({ data: blob, fileName })
|
||||
setNeedDownload(false)
|
||||
queryClient.removeQueries({
|
||||
queryKey: ['plugins', 'downloadPlugin', downloadInfo],
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import { useNodesReadOnly } from '../hooks'
|
||||
import TipPopup from './tip-popup'
|
||||
|
||||
@ -146,26 +147,14 @@ const MoreActions: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fileName = `${filename}.${type}`
|
||||
|
||||
if (currentWorkflow) {
|
||||
setPreviewUrl(dataUrl)
|
||||
setPreviewTitle(`${filename}.${type}`)
|
||||
setPreviewTitle(fileName)
|
||||
}
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = dataUrl
|
||||
link.download = `${filename}.${type}`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
else {
|
||||
// For current view, just download
|
||||
const link = document.createElement('a')
|
||||
link.href = dataUrl
|
||||
link.download = `${filename}.${type}`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
downloadUrl({ url: dataUrl, fileName })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Export image failed:', error)
|
||||
|
||||
@ -9,8 +9,7 @@ html[data-theme="dark"] .monaco-editor .sticky-line-content:hover {
|
||||
background-color: var(--color-components-sticky-header-bg-hover) !important;
|
||||
}
|
||||
|
||||
/* Fallback: any app sticky header using input-bg variables should use the sticky header bg when sticky */
|
||||
html[data-theme="dark"] .sticky, html[data-theme="dark"] .is-sticky {
|
||||
/* Monaco editor specific sticky scroll styles in dark mode */
|
||||
html[data-theme="dark"] .monaco-editor .sticky-line-root {
|
||||
background-color: var(--color-components-sticky-header-bg) !important;
|
||||
border-bottom: 1px solid var(--color-components-sticky-header-border) !important;
|
||||
}
|
||||
@ -994,7 +994,7 @@
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/file-uploader/utils.ts": {
|
||||
@ -1661,7 +1661,7 @@
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"app/components/datasets/create/website/watercrawl/options.tsx": {
|
||||
@ -4376,11 +4376,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"utils/format.spec.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"utils/get-icon.spec.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
|
||||
75
web/utils/download.spec.ts
Normal file
75
web/utils/download.spec.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { downloadBlob, downloadUrl } from './download'
|
||||
|
||||
describe('downloadUrl', () => {
|
||||
let mockAnchor: HTMLAnchorElement
|
||||
|
||||
beforeEach(() => {
|
||||
mockAnchor = {
|
||||
href: '',
|
||||
download: '',
|
||||
rel: '',
|
||||
target: '',
|
||||
style: { display: '' },
|
||||
click: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as unknown as HTMLAnchorElement
|
||||
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor)
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => node)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should create a link and trigger a download correctly', () => {
|
||||
downloadUrl({ url: 'https://example.com/file.txt', fileName: 'file.txt', target: '_blank' })
|
||||
|
||||
expect(mockAnchor.href).toBe('https://example.com/file.txt')
|
||||
expect(mockAnchor.download).toBe('file.txt')
|
||||
expect(mockAnchor.rel).toBe('noopener noreferrer')
|
||||
expect(mockAnchor.target).toBe('_blank')
|
||||
expect(mockAnchor.style.display).toBe('none')
|
||||
expect(mockAnchor.click).toHaveBeenCalled()
|
||||
expect(mockAnchor.remove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip when url is empty', () => {
|
||||
downloadUrl({ url: '' })
|
||||
expect(document.createElement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadBlob', () => {
|
||||
it('should create a blob url, trigger download, and revoke url', () => {
|
||||
const blob = new Blob(['test'], { type: 'text/plain' })
|
||||
const mockUrl = 'blob:mock-url'
|
||||
const createObjectURLMock = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue(mockUrl)
|
||||
const revokeObjectURLMock = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
|
||||
const mockAnchor = {
|
||||
href: '',
|
||||
download: '',
|
||||
rel: '',
|
||||
target: '',
|
||||
style: { display: '' },
|
||||
click: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as unknown as HTMLAnchorElement
|
||||
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor)
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => node)
|
||||
|
||||
downloadBlob({ data: blob, fileName: 'file.txt' })
|
||||
|
||||
expect(createObjectURLMock).toHaveBeenCalledWith(blob)
|
||||
expect(mockAnchor.href).toBe(mockUrl)
|
||||
expect(mockAnchor.download).toBe('file.txt')
|
||||
expect(mockAnchor.rel).toBe('noopener noreferrer')
|
||||
expect(mockAnchor.click).toHaveBeenCalled()
|
||||
expect(mockAnchor.remove).toHaveBeenCalled()
|
||||
expect(revokeObjectURLMock).toHaveBeenCalledWith(mockUrl)
|
||||
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import { downloadFile, formatFileSize, formatNumber, formatNumberAbbreviated, formatTime } from './format'
|
||||
import { formatFileSize, formatNumber, formatNumberAbbreviated, formatTime } from './format'
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('should correctly format integers', () => {
|
||||
@ -82,49 +82,6 @@ describe('formatTime', () => {
|
||||
expect(formatTime(7200)).toBe('2.00 h')
|
||||
})
|
||||
})
|
||||
describe('downloadFile', () => {
|
||||
it('should create a link and trigger a download correctly', () => {
|
||||
// Mock data
|
||||
const blob = new Blob(['test content'], { type: 'text/plain' })
|
||||
const fileName = 'test-file.txt'
|
||||
const mockUrl = 'blob:mockUrl'
|
||||
|
||||
// Mock URL.createObjectURL
|
||||
const createObjectURLMock = vi.fn().mockReturnValue(mockUrl)
|
||||
const revokeObjectURLMock = vi.fn()
|
||||
Object.defineProperty(window.URL, 'createObjectURL', { value: createObjectURLMock })
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', { value: revokeObjectURLMock })
|
||||
|
||||
// Mock createElement and appendChild
|
||||
const mockLink = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}
|
||||
const createElementMock = vi.spyOn(document, 'createElement').mockReturnValue(mockLink as any)
|
||||
const appendChildMock = vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||
return node
|
||||
})
|
||||
|
||||
// Call the function
|
||||
downloadFile({ data: blob, fileName })
|
||||
|
||||
// Assertions
|
||||
expect(createObjectURLMock).toHaveBeenCalledWith(blob)
|
||||
expect(createElementMock).toHaveBeenCalledWith('a')
|
||||
expect(mockLink.href).toBe(mockUrl)
|
||||
expect(mockLink.download).toBe(fileName)
|
||||
expect(appendChildMock).toHaveBeenCalledWith(mockLink)
|
||||
expect(mockLink.click).toHaveBeenCalled()
|
||||
expect(mockLink.remove).toHaveBeenCalled()
|
||||
expect(revokeObjectURLMock).toHaveBeenCalledWith(mockUrl)
|
||||
|
||||
// Clean up mocks
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatNumberAbbreviated', () => {
|
||||
it('should return number as string when less than 1000', () => {
|
||||
expect(formatNumberAbbreviated(0)).toBe('0')
|
||||
|
||||
@ -100,17 +100,6 @@ export const formatTime = (seconds: number) => {
|
||||
return `${seconds.toFixed(2)} ${units[index]}`
|
||||
}
|
||||
|
||||
export const downloadFile = ({ data, fileName }: { data: Blob, fileName: string }) => {
|
||||
const url = window.URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number into a readable string using "k", "M", or "B" suffix.
|
||||
* @example
|
||||
|
||||
Reference in New Issue
Block a user