From b9ac7af9c5246f84bf86efd87207ac0e26ae8a9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9B=90=E7=B2=92=20Yanli?= Date: Thu, 29 Jan 2026 16:02:49 +0800 Subject: [PATCH] refactor(web): consolidate download helpers (#31664) --- web/app/components/app-sidebar/app-info.tsx | 8 +- .../app-sidebar/dataset-info/dropdown.tsx | 8 +- .../app/annotation/header-opts/index.tsx | 16 ++-- .../configuration/config-var/index.spec.tsx | 6 +- web/app/components/apps/app-card.tsx | 10 +-- .../file-uploader-in-attachment/file-item.tsx | 4 +- .../file-image-item.tsx | 4 +- .../file-uploader-in-chat-input/file-item.tsx | 4 +- .../base/file-uploader/utils.spec.ts | 72 ------------------ .../components/base/file-uploader/utils.ts | 12 --- .../base/image-uploader/image-preview.tsx | 36 +++------ web/app/components/base/qrcode/index.tsx | 10 +-- .../index-failed.spec.tsx | 11 ++- .../list/template-card/index.spec.tsx | 13 ++-- .../list/template-card/index.tsx | 7 +- .../create/website/watercrawl/index.tsx | 20 ++++- .../hooks/use-dataset-card-state.ts | 8 +- .../header/account-dropdown/compliance.tsx | 5 +- .../components/rag-pipeline/hooks/use-DSL.ts | 8 +- .../components/workflow-app/hooks/use-DSL.ts | 8 +- .../market-place-plugin/action.tsx | 4 +- .../workflow/operator/more-actions.tsx | 23 ++---- web/eslint-suppressions.json | 9 +-- web/utils/download.spec.ts | 75 +++++++++++++++++++ web/utils/format.spec.ts | 45 +---------- web/utils/format.ts | 11 --- 26 files changed, 167 insertions(+), 270 deletions(-) create mode 100644 web/utils/download.spec.ts diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index 255feaccdf..aa31f0201f 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -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' }) }) diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index 4d7c832e04..96127c4210 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -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' }) }) diff --git a/web/app/components/app/annotation/header-opts/index.tsx b/web/app/components/app/annotation/header-opts/index.tsx index 5add1aed32..4fc1e26007 100644 --- a/web/app/components/app/annotation/header-opts/index.tsx +++ b/web/app/components/app/annotation/header-opts/index.tsx @@ -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 = ({ ) 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) diff --git a/web/app/components/app/configuration/config-var/index.spec.tsx b/web/app/components/app/configuration/config-var/index.spec.tsx index b5015ed079..490d7b4410 100644 --- a/web/app/components/app/configuration/config-var/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/index.spec.tsx @@ -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 () => { diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index f1eadb9d05..730a39b68d 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -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 ( <> diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx index 6ef5bcb308..f8015aa7c7 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx @@ -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 && ( { e.stopPropagation() - downloadFile(url || base64Url || '', name) + downloadUrl({ url: url || base64Url || '', fileName: name, target: '_blank' }) }} > diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx index 77dc3e35b8..d9118aac4f 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.tsx @@ -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' }) }} > diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx index 828864239a..af32f917b9 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx @@ -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' }) }} > diff --git a/web/app/components/base/file-uploader/utils.spec.ts b/web/app/components/base/file-uploader/utils.spec.ts index de167a8c25..f69b3c27f5 100644 --- a/web/app/components/base/file-uploader/utils.spec.ts +++ b/web/app/components/base/file-uploader/utils.spec.ts @@ -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('') - }) - }) }) diff --git a/web/app/components/base/file-uploader/utils.ts b/web/app/components/base/file-uploader/utils.ts index 5d5754b8fe..23e460db51 100644 --- a/web/app/components/base/file-uploader/utils.ts +++ b/web/app/components/base/file-uploader/utils.ts @@ -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) -} diff --git a/web/app/components/base/image-uploader/image-preview.tsx b/web/app/components/base/image-uploader/image-preview.tsx index b6a07c60aa..0641af3d79 100644 --- a/web/app/components/base/image-uploader/image-preview.tsx +++ b/web/app/components/base/image-uploader/image-preview.tsx @@ -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 = ({ 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 = ({ 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 = ({ tabIndex={-1} > { } + {/* eslint-disable-next-line next/no-img-element */} {title} { }, [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) => { diff --git a/web/app/components/datasets/common/document-status-with-action/index-failed.spec.tsx b/web/app/components/datasets/common/document-status-with-action/index-failed.spec.tsx index 43255ce908..27070aaaed 100644 --- a/web/app/components/datasets/common/document-status-with-action/index-failed.spec.tsx +++ b/web/app/components/datasets/common/document-status-with-action/index-failed.spec.tsx @@ -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() @@ -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() + }) }) }) diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/index.spec.tsx index 290f7af99b..036370abd3 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/index.spec.tsx @@ -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', })) }) diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx index 662ca72080..b3395a83d5 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx @@ -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' }), diff --git a/web/app/components/datasets/create/website/watercrawl/index.tsx b/web/app/components/datasets/create/website/watercrawl/index.tsx index 0df2dbe8a1..e68a89ae5a 100644 --- a/web/app/components/datasets/create/website/watercrawl/index.tsx +++ b/web/app/components/datasets/create/website/watercrawl/index.tsx @@ -125,11 +125,25 @@ const WaterCrawl: FC = ({ 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, 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: [], }, diff --git a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts index ad68a1df1c..4bd8357f1c 100644 --- a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts +++ b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts @@ -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' }) }) diff --git a/web/app/components/header/account-dropdown/compliance.tsx b/web/app/components/header/account-dropdown/compliance.tsx index 562914dd07..6bc5b5c3f1 100644 --- a/web/app/components/header/account-dropdown/compliance.tsx +++ b/web/app/components/header/account-dropdown/compliance.tsx @@ -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 = ({ 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' }), diff --git a/web/app/components/rag-pipeline/hooks/use-DSL.ts b/web/app/components/rag-pipeline/hooks/use-DSL.ts index 1660d555eb..5c0f9def1c 100644 --- a/web/app/components/rag-pipeline/hooks/use-DSL.ts +++ b/web/app/components/rag-pipeline/hooks/use-DSL.ts @@ -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' }) }) diff --git a/web/app/components/workflow-app/hooks/use-DSL.ts b/web/app/components/workflow-app/hooks/use-DSL.ts index 6c01509bc5..939e43b554 100644 --- a/web/app/components/workflow-app/hooks/use-DSL.ts +++ b/web/app/components/workflow-app/hooks/use-DSL.ts @@ -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' }) }) diff --git a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx index b8300d6f2b..abdbae1b4c 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx @@ -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 = ({ 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], diff --git a/web/app/components/workflow/operator/more-actions.tsx b/web/app/components/workflow/operator/more-actions.tsx index e9fc1ea87d..7e6617e84b 100644 --- a/web/app/components/workflow/operator/more-actions.tsx +++ b/web/app/components/workflow/operator/more-actions.tsx @@ -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) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index abee200f66..6193a8ad4e 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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 diff --git a/web/utils/download.spec.ts b/web/utils/download.spec.ts new file mode 100644 index 0000000000..ff41ddfff7 --- /dev/null +++ b/web/utils/download.spec.ts @@ -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() + }) +}) diff --git a/web/utils/format.spec.ts b/web/utils/format.spec.ts index 3a1709dbdc..2796854e34 100644 --- a/web/utils/format.spec.ts +++ b/web/utils/format.spec.ts @@ -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') diff --git a/web/utils/format.ts b/web/utils/format.ts index ce813d3999..d6968e0ef1 100644 --- a/web/utils/format.ts +++ b/web/utils/format.ts @@ -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