diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 0247d6beee9..97ca562d057 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4712,7 +4712,7 @@ }, "web/service/use-common.ts": { "@tanstack/query/prefer-query-options": { - "count": 10 + "count": 9 }, "dify/require-disable-directive-description": { "count": 1 diff --git a/web/__tests__/file-supported-formats.spec.tsx b/web/__tests__/file-supported-formats.spec.tsx new file mode 100644 index 00000000000..997e5c5a28e --- /dev/null +++ b/web/__tests__/file-supported-formats.spec.tsx @@ -0,0 +1,136 @@ +import type { DocExtractorNodeType } from '@/app/components/workflow/nodes/document-extractor/types' +import type { PanelProps } from '@/types/workflow' +import { screen, waitFor } from '@testing-library/react' +import FileUploader from '@/app/components/datasets/create/file-uploader' +import Panel from '@/app/components/workflow/nodes/document-extractor/panel' +import useConfig from '@/app/components/workflow/nodes/document-extractor/use-config' +import { BlockEnum } from '@/app/components/workflow/types' +import { LanguagesSupported } from '@/i18n/language' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' + +let mockLocale = 'en-US' +const request = vi.hoisted(() => vi.fn<(url: string) => Promise>()) + +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + request, +})) + +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: () => ({ + data: { file_size_limit: 15, batch_count_limit: 5, file_upload_limit: 10 }, + }), +})) + +vi.mock('#i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useLocale: () => mockLocale, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ + default: () => null, +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-help-link', () => ({ + useNodeHelpLink: () => 'https://docs.example.com/document-extractor', +})) + +vi.mock('@/app/components/workflow/nodes/document-extractor/use-config', () => ({ + default: vi.fn(), +})) + +const createData = (): DocExtractorNodeType => ({ + title: 'Document Extractor', + desc: '', + type: BlockEnum.DocExtractor, + variable_selector: ['node-1', 'files'], + is_array_file: false, +}) + +const panelProps: PanelProps = { + getInputVars: vi.fn(() => []), + toVarInputs: vi.fn(() => []), + runInputData: {}, + runInputDataRef: { current: {} }, + setRunInputData: vi.fn(), + runResult: null, +} + +beforeEach(() => { + vi.clearAllMocks() + mockLocale = 'en-US' + vi.mocked(useConfig).mockReturnValue({ + readOnly: false, + inputs: createData(), + handleVarChanges: vi.fn(), + filterVar: () => true, + }) +}) + +describe('file supported formats across upload and document extraction', () => { + it('shares the raw supported-formats response with the uploader across locale changes', async () => { + const queryClient = createConsoleQueryClient() + const response = { allowed_extensions: ['PDF', 'pdf', 'md', 'md', 'DOCX', 'docx'] } + request.mockImplementation(async () => Response.json(response)) + const consumers = () => ( + <> + + + + ) + const { container, rerender } = renderWithConsoleQuery(consumers(), { + queryClient, + systemFeatures: { deployment_edition: 'CLOUD' }, + }) + + expect(container.querySelector('input[type="file"]')).toHaveAttribute('accept', '') + expect( + screen.queryByRole('button', { name: 'datasetCreation.stepOne.uploader.browse' }), + ).not.toBeInTheDocument() + + await screen.findByText( + 'workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf, markdown, docx"}', + ) + expect(container.querySelector('input[type="file"]')).toHaveAttribute( + 'accept', + '.PDF,.pdf,.md,.md,.DOCX,.docx', + ) + expect( + screen.getByRole('button', { name: 'datasetCreation.stepOne.uploader.browse' }), + ).toBeInTheDocument() + expect(screen.getByText(/"supportTypes":"PDF, MARKDOWN, DOCX"/)).toBeInTheDocument() + expect(request).toHaveBeenCalledTimes(1) + expect(new URL(request.mock.calls[0]![0]).pathname).toBe('/console/api/files/support-type') + expect(request).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + fetchCompat: true, + request: expect.objectContaining({ method: 'GET' }), + silent: undefined, + }), + ) + + mockLocale = LanguagesSupported[1]! + rerender(consumers()) + + await waitFor(() => + expect( + screen.getByText( + 'workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf、 markdown、 docx"}', + ), + ).toBeInTheDocument(), + ) + expect(screen.getByText(/"supportTypes":"PDF、 MARKDOWN、 DOCX"/)).toBeInTheDocument() + expect(request).toHaveBeenCalledTimes(1) + expect( + queryClient.getQueryData(consoleQuery.files.supportType.get.queryOptions().queryKey), + ).toEqual(response) + }) +}) diff --git a/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx b/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx index c8b503ca8e3..58ccd362b54 100644 --- a/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx @@ -1,12 +1,21 @@ import type { CustomFile as File, FileItem } from '@/models/datasets' import { fireEvent, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { renderWithConsoleQuery } from '@/test/console/query-data' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { PROGRESS_NOT_STARTED } from '../constants' import FileUploader from '../index' -const render = (ui: React.ReactElement) => - renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } }) +const render = (ui: React.ReactElement) => { + const queryClient = createConsoleQueryClient() + queryClient.setQueryData(consoleQuery.files.supportType.get.queryOptions().queryKey, { + allowed_extensions: ['pdf', 'docx', 'txt'], + }) + return renderWithConsoleQuery(ui, { + queryClient, + systemFeatures: { deployment_edition: 'CLOUD' }, + }) +} const mockNotify = vi.fn() vi.mock('use-context-selector', async () => { @@ -27,9 +36,6 @@ vi.mock('@/service/use-common', () => ({ useFileUploadConfig: () => ({ data: { file_size_limit: 15, batch_count_limit: 5, file_upload_limit: 10 }, }), - useFileSupportTypes: () => ({ - data: { allowed_extensions: ['pdf', 'docx', 'txt'] }, - }), })) vi.mock('@/i18n/language', () => ({ LanguagesSupported: ['en-US', 'zh-Hans'], diff --git a/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx index aebf6f625b9..406d3a4f4ba 100644 --- a/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx @@ -1,8 +1,13 @@ import type { ReactElement } from 'react' import type { CustomFile, FileItem } from '@/models/datasets' -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, fireEvent, renderHook, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { createConsoleQueryWrapper, renderWithConsoleQuery } from '@/test/console/query-data' +import { consoleQuery } from '@/service/console' +import { + createConsoleQueryClient, + createConsoleQueryWrapper, + renderWithConsoleQuery, +} from '@/test/console/query-data' import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants' // Import after mocks import { useFileUpload } from '../use-file-upload' @@ -18,7 +23,9 @@ vi.mock('@/app/notifications', () => ({ // Mock upload service const mockUpload = vi.fn() +const request = vi.hoisted(() => vi.fn<(url: string) => Promise>()) vi.mock('@/service/base', () => ({ + request, upload: (...args: unknown[]) => mockUpload(...args), })) @@ -30,13 +37,12 @@ const mockFileUploadConfig = { file_upload_limit: 10, } -const mockSupportTypes = { +const supportedFormats = { allowed_extensions: ['pdf', 'docx', 'txt', 'md'], } vi.mock('@/service/use-common', () => ({ useFileUploadConfig: () => ({ data: mockFileUploadConfig }), - useFileSupportTypes: () => ({ data: mockSupportTypes }), })) vi.mock('@/i18n/language', () => ({ LanguagesSupported: ['en-US', 'zh-Hans'], @@ -47,10 +53,24 @@ vi.mock('@/app/components/base/file-uploader/utils', () => ({ getFileUploadErrorMessage: (_e: unknown, defaultMsg: string) => defaultMsg, })) +const createQueryClient = () => { + const queryClient = createConsoleQueryClient() + queryClient.setQueryData( + consoleQuery.files.supportType.get.queryOptions().queryKey, + supportedFormats, + ) + return queryClient +} const createWrapper = () => - createConsoleQueryWrapper({ systemFeatures: { deployment_edition: 'CLOUD' } }).wrapper + createConsoleQueryWrapper({ + queryClient: createQueryClient(), + systemFeatures: { deployment_edition: 'CLOUD' }, + }).wrapper const render = (ui: ReactElement) => - renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } }) + renderWithConsoleQuery(ui, { + queryClient: createQueryClient(), + systemFeatures: { deployment_edition: 'CLOUD' }, + }) describe('useFileUpload', () => { const defaultOptions = { @@ -133,6 +153,66 @@ describe('useFileUpload', () => { }) }) + it.each([ + { allowedExtensions: ['csv'], expectedAccept: '.csv', uploadCount: 1 }, + { allowedExtensions: [], expectedAccept: '', uploadCount: 0 }, + ])( + 'keeps the explicit $allowedExtensions override while subscribing to supported formats', + async ({ allowedExtensions, expectedAccept, uploadCount }) => { + const queryClient = createConsoleQueryClient() + request.mockImplementation(async () => Response.json(supportedFormats)) + const Uploader = () => { + const { fileUploaderRef, fileChangeHandle, acceptTypes } = useFileUpload({ + ...defaultOptions, + allowedExtensions, + }) + return ( + + ) + } + renderWithConsoleQuery(, { + queryClient, + systemFeatures: { deployment_edition: 'CLOUD' }, + }) + const input = screen.getByLabelText('Upload file') + expect(input).toHaveAttribute('accept', expectedAccept) + + await waitFor(() => + expect( + queryClient.getQueryData(consoleQuery.files.supportType.get.queryOptions().queryKey), + ).toEqual(supportedFormats), + ) + + expect(request).toHaveBeenCalledTimes(1) + expect(input).toHaveAttribute('accept', expectedAccept) + fireEvent.change(input, { + target: { files: [new File(['row'], 'data.csv', { type: 'text/csv' })] }, + }) + await waitFor(() => expect(mockUpload).toHaveBeenCalledTimes(uploadCount)) + if (uploadCount === 0) { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetCreation.stepOne.uploader.validation.typeError', + }) + expect(defaultOptions.prepareFileList).not.toHaveBeenCalled() + } else { + expect(defaultOptions.prepareFileList).toHaveBeenCalledOnce() + expect(defaultOptions.onFileUpdate).toHaveBeenCalledWith( + expect.objectContaining({ file: { id: 'default-id' } }), + PROGRESS_COMPLETE, + expect.any(Array), + ) + expect(mockNotify).not.toHaveBeenCalled() + } + }, + ) + describe('selectHandle', () => { it('should trigger click on file input', () => { const { result } = renderHook(() => useFileUpload(defaultOptions), { diff --git a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts b/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts index 5ff3a677a91..006cf6668d9 100644 --- a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts +++ b/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts @@ -1,7 +1,7 @@ 'use client' import type { RefObject } from 'react' import type { CustomFile as File, FileItem } from '@/models/datasets' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' @@ -10,7 +10,8 @@ import { toast } from '@/app/notifications' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { LanguagesSupported } from '@/i18n/language' import { upload } from '@/service/base' -import { useFileSupportTypes, useFileUploadConfig } from '@/service/use-common' +import { consoleQuery } from '@/service/console' +import { useFileUploadConfig } from '@/service/use-common' import { getFileExtension } from '@/utils/format' import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' @@ -28,8 +29,7 @@ type UseFileUploadOptions = { onPreview: (file: File) => void supportBatchUpload?: boolean /** - * Optional list of allowed file extensions. If not provided, fetches from API. - * Pass this when you need custom extension filtering instead of using the global config. + * Overrides the supported extensions from the API, including an empty list. */ allowedExtensions?: string[] } @@ -86,8 +86,9 @@ export const useFileUpload = ({ const hideUpload = !supportBatchUpload && fileList.length > 0 const { data: fileUploadConfigResponse } = useFileUploadConfig() - const { data: supportFileTypesResponse } = useFileSupportTypes() - // Use provided allowedExtensions or fetch from API + const { data: supportFileTypesResponse } = useQuery( + consoleQuery.files.supportType.get.queryOptions(), + ) const supportTypes = useMemo( () => allowedExtensions ?? supportFileTypesResponse?.allowed_extensions ?? [], [allowedExtensions, supportFileTypesResponse?.allowed_extensions], diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx index b098f25b348..c6a61851df3 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx @@ -2,7 +2,12 @@ import type { ReactElement } from 'react' import type { CustomFile, FileItem } from '@/models/datasets' import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { createConsoleQueryWrapper, renderWithConsoleQuery } from '@/test/console/query-data' +import { consoleQuery } from '@/service/console' +import { + createConsoleQueryClient, + createConsoleQueryWrapper, + renderWithConsoleQuery, +} from '@/test/console/query-data' import { PROGRESS_ERROR } from '../../constants' const { mockNotify, mockToast } = vi.hoisted(() => { @@ -66,12 +71,6 @@ vi.mock('@/service/use-common', () => ({ file_upload_limit: 10, }, })), - // Required by the shared useFileUpload hook - useFileSupportTypes: vi.fn(() => ({ - data: { - allowed_extensions: ['pdf', 'docx', 'txt'], - }, - })), })) // Mock upload service @@ -83,10 +82,23 @@ vi.mock('@/service/base', () => ({ // Import after all mocks are set up const { useLocalFileUpload } = await import('../use-local-file-upload') +const createQueryClient = () => { + const queryClient = createConsoleQueryClient() + queryClient.setQueryData(consoleQuery.files.supportType.get.queryOptions().queryKey, { + allowed_extensions: ['pdf', 'docx', 'txt'], + }) + return queryClient +} const createWrapper = () => - createConsoleQueryWrapper({ systemFeatures: { deployment_edition: 'CLOUD' } }).wrapper + createConsoleQueryWrapper({ + queryClient: createQueryClient(), + systemFeatures: { deployment_edition: 'CLOUD' }, + }).wrapper const render = (ui: ReactElement) => - renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } }) + renderWithConsoleQuery(ui, { + queryClient: createQueryClient(), + systemFeatures: { deployment_edition: 'CLOUD' }, + }) describe('useLocalFileUpload', () => { beforeEach(() => { diff --git a/web/app/components/workflow/nodes/document-extractor/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/document-extractor/__tests__/integration.spec.tsx index a0514acfdf0..d4487c9ea43 100644 --- a/web/app/components/workflow/nodes/document-extractor/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/document-extractor/__tests__/integration.spec.tsx @@ -1,14 +1,24 @@ -import type { ReactNode } from 'react' +import type { ReactElement, ReactNode } from 'react' import type { DocExtractorNodeType } from '../types' import type { PanelProps } from '@/types/workflow' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { LanguagesSupported } from '@/i18n/language' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { BlockEnum } from '../../../types' import Node from '../node' import Panel from '../panel' import useConfig from '../use-config' +const render = (ui: ReactElement) => { + const queryClient = createConsoleQueryClient() + queryClient.setQueryData(consoleQuery.files.supportType.get.queryOptions().queryKey, { + allowed_extensions: ['PDF', 'md', 'md', 'DOCX'], + }) + return renderWithConsoleQuery(ui, { queryClient }) +} + let mockLocale = 'en-US' vi.mock('reactflow', async () => { @@ -73,14 +83,6 @@ vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-help-link', () => useNodeHelpLink: () => 'https://docs.example.com/document-extractor', })) -vi.mock('@/service/use-common', () => ({ - useFileSupportTypes: () => ({ - data: { - allowed_extensions: ['PDF', 'md', 'md', 'DOCX'], - }, - }), -})) - vi.mock('#i18n', async (importOriginal) => ({ ...(await importOriginal()), useLocale: () => mockLocale, diff --git a/web/app/components/workflow/nodes/document-extractor/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/document-extractor/__tests__/panel.spec.tsx index 0f439ad7b88..83123678874 100644 --- a/web/app/components/workflow/nodes/document-extractor/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/document-extractor/__tests__/panel.spec.tsx @@ -1,13 +1,23 @@ -import type { ReactNode } from 'react' +import type { ReactElement, ReactNode } from 'react' import type { DocExtractorNodeType } from '../types' import type { PanelProps } from '@/types/workflow' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { LanguagesSupported } from '@/i18n/language' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { BlockEnum } from '../../../types' import Panel from '../panel' import useConfig from '../use-config' +const render = (ui: ReactElement) => { + const queryClient = createConsoleQueryClient() + queryClient.setQueryData(consoleQuery.files.supportType.get.queryOptions().queryKey, { + allowed_extensions: ['PDF', 'md', 'md', 'DOCX'], + }) + return renderWithConsoleQuery(ui, { queryClient }) +} + let mockLocale = 'en-US' vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ @@ -44,14 +54,6 @@ vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-help-link', () => useNodeHelpLink: () => 'https://docs.example.com/document-extractor', })) -vi.mock('@/service/use-common', () => ({ - useFileSupportTypes: () => ({ - data: { - allowed_extensions: ['PDF', 'md', 'md', 'DOCX'], - }, - }), -})) - vi.mock('#i18n', async (importOriginal) => ({ ...(await importOriginal()), useLocale: () => mockLocale, diff --git a/web/app/components/workflow/nodes/document-extractor/panel.tsx b/web/app/components/workflow/nodes/document-extractor/panel.tsx index fc3952703bb..eb67d072f05 100644 --- a/web/app/components/workflow/nodes/document-extractor/panel.tsx +++ b/web/app/components/workflow/nodes/document-extractor/panel.tsx @@ -1,13 +1,14 @@ import type { FC } from 'react' import type { DocExtractorNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' import Field from '@/app/components/workflow/nodes/_base/components/field' import { BlockEnum } from '@/app/components/workflow/types' import { LanguagesSupported } from '@/i18n/language' -import { useFileSupportTypes } from '@/service/use-common' +import { consoleQuery } from '@/service/console' import OutputVars, { VarItem } from '../_base/components/output-vars' import Split from '../_base/components/split' import VarReferencePicker from '../_base/components/variable/var-reference-picker' @@ -20,7 +21,9 @@ const Panel: FC> = ({ id, data }) => { const { t } = useTranslation() const locale = useLocale() const link = useNodeHelpLink(BlockEnum.DocExtractor) - const { data: supportFileTypesResponse } = useFileSupportTypes() + const { data: supportFileTypesResponse } = useQuery( + consoleQuery.files.supportType.get.queryOptions(), + ) const supportTypes = supportFileTypesResponse?.allowed_extensions || [] const supportTypesShowNames = (() => { const extensionMap: { [key: string]: string } = { diff --git a/web/service/datasets.ts b/web/service/datasets.ts index 6ac4f328267..c1ae7b8e646 100644 --- a/web/service/datasets.ts +++ b/web/service/datasets.ts @@ -325,10 +325,6 @@ export const checkWatercrawlTaskStatus = (jobId: string): Promise { }) } -export const useFileSupportTypes = () => { - return useQuery({ - queryKey: [NAME_SPACE, 'file-types'], - queryFn: () => get('/files/support-type'), - }) -} - type MemberResponse = { accounts: Member[] | null }