refactor(web): localize dataset external API state (#39791)

This commit is contained in:
yyh 2026-07-30 14:11:23 +08:00 committed by GitHub
parent 3d01e8389a
commit 71ae1d5511
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 308 additions and 360 deletions

View File

@ -5974,16 +5974,6 @@
"count": 1
}
},
"web/context/external-api-panel-context.tsx": {
"react/only-export-components": {
"count": 1
}
},
"web/context/external-knowledge-api-context.tsx": {
"react/only-export-components": {
"count": 1
}
},
"web/context/hooks/use-trigger-events-limit-modal.ts": {
"eslint-react/set-state-in-effect": {
"count": 3

View File

@ -1,4 +1,3 @@
import type { ReactNode } from 'react'
import { screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from '@/test/console/render'
@ -7,7 +6,6 @@ import DatasetsLayout from './layout'
const mockReplace = vi.fn()
const mockConsoleStateReader = vi.fn()
let mockPathname = '/datasets'
let mockExternalKnowledgeApiProviderEnabled: boolean | undefined
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
@ -27,23 +25,6 @@ vi.mock('@/context/permission-state', async () => {
return createPermissionStateModuleMock(() => mockConsoleStateReader())
})
vi.mock('@/context/external-api-panel-context', () => ({
ExternalApiPanelProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
ExternalKnowledgeApiProvider: ({
children,
enabled,
}: {
children: ReactNode
enabled?: boolean
}) => {
mockExternalKnowledgeApiProviderEnabled = enabled
return <>{children}</>
},
}))
type ConsoleStateFixture = {
isCurrentWorkspaceEditor: boolean
isCurrentWorkspaceDatasetOperator: boolean
@ -77,7 +58,6 @@ describe('DatasetsLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPathname = '/datasets'
mockExternalKnowledgeApiProviderEnabled = undefined
setConsoleState()
})
@ -241,32 +221,4 @@ describe('DatasetsLayout', () => {
expect(mockReplace).not.toHaveBeenCalled()
},
)
it('should disable external knowledge API queries without dataset.external.connect', () => {
setConsoleState({
workspacePermissionKeys: [],
})
render(
<DatasetsLayout>
<div>datasets</div>
</DatasetsLayout>,
)
expect(mockExternalKnowledgeApiProviderEnabled).toBe(false)
})
it('should enable external knowledge API queries with dataset.external.connect', () => {
setConsoleState({
workspacePermissionKeys: ['dataset.external.connect'],
})
render(
<DatasetsLayout>
<div>datasets</div>
</DatasetsLayout>,
)
expect(mockExternalKnowledgeApiProviderEnabled).toBe(true)
})
})

View File

@ -3,8 +3,6 @@
import { useAtomValue } from 'jotai'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { ExternalApiPanelProvider } from '@/context/external-api-panel-context'
import { ExternalKnowledgeApiProvider } from '@/context/external-knowledge-api-context'
import {
workspacePermissionKeysAtom,
workspacePermissionKeysLoadingAtom,
@ -60,9 +58,5 @@ export default function DatasetsLayout({ children }: { children: React.ReactNode
return null
}
return (
<ExternalKnowledgeApiProvider enabled={canConnectExternalDataset}>
<ExternalApiPanelProvider>{children}</ExternalApiPanelProvider>
</ExternalKnowledgeApiProvider>
)
return children
}

View File

@ -1,4 +1,4 @@
import type { ExternalAPIItem } from '@/models/datasets'
import type { ExternalKnowledgeApiResponse } from '@dify/contracts/api/console/datasets/types.gen'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ExternalAPIPanel from '../index'
@ -7,11 +7,11 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
// Mock external contexts (only mock context providers, not base components)
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
const mockMutateExternalKnowledgeApis = vi.fn()
const mockInvalidateQueries = vi.fn()
const externalKnowledgeApiQueryKey = ['console', 'datasets', 'externalKnowledgeApi', 'get']
let mockIsLoading = false
let mockExternalKnowledgeApiList: ExternalAPIItem[] = []
let mockExternalKnowledgeApiList: ExternalKnowledgeApiResponse[] = []
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
@ -19,12 +19,30 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
externalKnowledgeApiList: mockExternalKnowledgeApiList,
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
isLoading: mockIsLoading,
}),
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useQuery: () => ({
data: { data: mockExternalKnowledgeApiList },
isLoading: mockIsLoading,
}),
useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
datasets: {
externalKnowledgeApi: {
get: {
queryOptions: () => ({
queryKey: ['console', 'datasets', 'externalKnowledgeApi', 'get'],
}),
},
},
},
},
}))
// Mock the ExternalKnowledgeAPICard to avoid mocking its internal dependencies
@ -33,7 +51,7 @@ vi.mock('../../external-knowledge-api-card', () => ({
api,
canManageExternalKnowledgeApi,
}: {
api: ExternalAPIItem
api: ExternalKnowledgeApiResponse
canManageExternalKnowledgeApi: boolean
}) => (
<div
@ -178,26 +196,28 @@ describe('ExternalAPIPanel', () => {
})
})
it('should call mutateExternalKnowledgeApis in onSaveCallback', async () => {
it('should invalidate the generated external API query after creation', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0]![0]
callArgs.onSaveCallback()
await callArgs.onSaveCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: externalKnowledgeApiQueryKey,
})
})
it('should call mutateExternalKnowledgeApis in onCancelCallback', async () => {
it('should not refresh the query when creation is canceled', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0]![0]
callArgs.onCancelCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
expect(callArgs.onCancelCallback).toBeUndefined()
expect(mockInvalidateQueries).not.toHaveBeenCalled()
})
})

View File

@ -1,13 +1,14 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiAddLine, RiBookOpenLine, RiCloseLine } from '@remixicon/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Loading from '@/app/components/base/loading'
import { useExternalKnowledgeApi } from '@/context/external-knowledge-api-context'
import { useDocLink } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
import { consoleQuery } from '@/service/client'
import ExternalKnowledgeAPICard from '../external-knowledge-api-card'
type ExternalAPIPanelProps = {
@ -22,8 +23,11 @@ const ExternalAPIPanel: React.FC<ExternalAPIPanelProps> = ({
const { t } = useTranslation()
const docLink = useDocLink()
const { setShowExternalKnowledgeAPIModal } = useModalContext()
const { externalKnowledgeApiList, mutateExternalKnowledgeApis, isLoading } =
useExternalKnowledgeApi()
const queryClient = useQueryClient()
const externalKnowledgeApiQueryOptions =
consoleQuery.datasets.externalKnowledgeApi.get.queryOptions({ input: {} })
const { data, isLoading } = useQuery(externalKnowledgeApiQueryOptions)
const externalKnowledgeApiList = data?.data ?? []
const handleOpenExternalAPIModal = () => {
if (!canManageExternalKnowledgeApi) return
@ -32,10 +36,9 @@ const ExternalAPIPanel: React.FC<ExternalAPIPanelProps> = ({
payload: { name: '', settings: { endpoint: '', api_key: '' } },
datasetBindings: [],
onSaveCallback: () => {
mutateExternalKnowledgeApis()
},
onCancelCallback: () => {
mutateExternalKnowledgeApis()
void queryClient.invalidateQueries({
queryKey: externalKnowledgeApiQueryOptions.queryKey,
})
},
isEditMode: false,
})

View File

@ -1,8 +1,14 @@
import type { ExternalKnowledgeApiResponse } from '@dify/contracts/api/console/datasets/types.gen'
import type { ExternalAPIItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocked services
import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI } from '@/service/datasets'
import {
checkUsageExternalAPI,
deleteExternalAPI,
fetchExternalAPI,
updateExternalAPI,
} from '@/service/datasets'
import ExternalKnowledgeAPICard from '../index'
// Mock API services
@ -15,7 +21,8 @@ vi.mock('@/service/datasets', () => ({
// Mock contexts
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
const mockMutateExternalKnowledgeApis = vi.fn()
const mockInvalidateQueries = vi.fn()
const externalKnowledgeApiQueryKey = ['console', 'datasets', 'externalKnowledgeApi', 'get']
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
@ -23,14 +30,30 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
}),
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
datasets: {
externalKnowledgeApi: {
get: {
queryOptions: () => ({
queryKey: ['console', 'datasets', 'externalKnowledgeApi', 'get'],
}),
},
},
},
},
}))
describe('ExternalKnowledgeAPICard', () => {
const mockApi: ExternalAPIItem = {
const mockApi: ExternalKnowledgeApiResponse = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
@ -146,7 +169,7 @@ describe('ExternalKnowledgeAPICard', () => {
consoleSpy.mockRestore()
})
it('should call mutate on save callback', async () => {
it('should invalidate the generated list query after editing', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
@ -171,14 +194,31 @@ describe('ExternalKnowledgeAPICard', () => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
})
// Simulate save callback
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0]![0]
modalCall.onSaveCallback()
await modalCall.onEditCallback({
name: 'Updated External API',
settings: {
endpoint: 'https://updated.example.com/knowledge',
api_key: 'updated-secret-key',
},
})
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
expect(updateExternalAPI).toHaveBeenCalledWith({
apiTemplateId: 'api-123',
body: expect.objectContaining({
name: 'Updated External API',
settings: {
endpoint: 'https://updated.example.com/knowledge',
api_key: 'updated-secret-key',
},
}),
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: externalKnowledgeApiQueryKey,
})
})
it('should call mutate on cancel callback', async () => {
it('should not refresh the list query when editing is canceled', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
@ -203,11 +243,10 @@ describe('ExternalKnowledgeAPICard', () => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
})
// Simulate cancel callback
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0]![0]
modalCall.onCancelCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
expect(modalCall.onCancelCallback).toBeUndefined()
expect(mockInvalidateQueries).not.toHaveBeenCalled()
})
})
@ -262,7 +301,9 @@ describe('ExternalKnowledgeAPICard', () => {
await waitFor(() => {
expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: externalKnowledgeApiQueryKey,
})
})
})
@ -335,7 +376,7 @@ describe('ExternalKnowledgeAPICard', () => {
describe('Edge Cases', () => {
it('should handle API with empty endpoint', () => {
const apiWithEmptyEndpoint: ExternalAPIItem = {
const apiWithEmptyEndpoint: ExternalKnowledgeApiResponse = {
...mockApi,
settings: { endpoint: '', api_key: 'key' },
}

View File

@ -1,5 +1,5 @@
import type { ExternalKnowledgeApiResponse } from '@dify/contracts/api/console/datasets/types.gen'
import type { CreateExternalAPIReq } from '../declarations'
import type { ExternalAPIItem } from '@/models/datasets'
import {
AlertDialog,
AlertDialogActions,
@ -10,13 +10,14 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
import { useQueryClient } from '@tanstack/react-query'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import { useExternalKnowledgeApi } from '@/context/external-knowledge-api-context'
import { useModalContext } from '@/context/modal-context'
import { consoleQuery } from '@/service/client'
import {
checkUsageExternalAPI,
deleteExternalAPI,
@ -25,7 +26,7 @@ import {
} from '@/service/datasets'
type ExternalKnowledgeAPICardProps = {
api: ExternalAPIItem
api: ExternalKnowledgeApiResponse
canManageExternalKnowledgeApi: boolean
}
@ -37,7 +38,12 @@ const ExternalKnowledgeAPICard: React.FC<ExternalKnowledgeAPICardProps> = ({
const [showConfirm, setShowConfirm] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [usageCount, setUsageCount] = useState(0)
const { mutateExternalKnowledgeApis } = useExternalKnowledgeApi()
const queryClient = useQueryClient()
const externalKnowledgeApiQueryKey = consoleQuery.datasets.externalKnowledgeApi.get.queryOptions({
input: {},
}).queryKey
const endpoint =
api.settings && typeof api.settings.endpoint === 'string' ? api.settings.endpoint : ''
const { t } = useTranslation()
@ -56,12 +62,6 @@ const ExternalKnowledgeAPICard: React.FC<ExternalKnowledgeAPICardProps> = ({
setShowExternalKnowledgeAPIModal({
payload: formValue,
onSaveCallback: () => {
mutateExternalKnowledgeApis()
},
onCancelCallback: () => {
mutateExternalKnowledgeApis()
},
isEditMode: true,
datasetBindings: response.dataset_bindings,
onEditCallback: async (updatedData: CreateExternalAPIReq) => {
@ -78,7 +78,7 @@ const ExternalKnowledgeAPICard: React.FC<ExternalKnowledgeAPICardProps> = ({
},
},
})
mutateExternalKnowledgeApis()
await queryClient.invalidateQueries({ queryKey: externalKnowledgeApiQueryKey })
} catch (error) {
console.error('Error updating external knowledge API:', error)
}
@ -109,7 +109,7 @@ const ExternalKnowledgeAPICard: React.FC<ExternalKnowledgeAPICardProps> = ({
const response = await deleteExternalAPI({ apiTemplateId: api.id })
if (response && response.result === 'success') {
setShowConfirm(false)
mutateExternalKnowledgeApis()
await queryClient.invalidateQueries({ queryKey: externalKnowledgeApiQueryKey })
} else {
console.error('Failed to delete external API')
}
@ -128,9 +128,7 @@ const ExternalKnowledgeAPICard: React.FC<ExternalKnowledgeAPICardProps> = ({
<ApiConnectionMod className="size-4" />
<div className="system-sm-medium">{api.name}</div>
</div>
<div className="self-stretch system-xs-regular text-text-tertiary">
{api.settings.endpoint}
</div>
<div className="self-stretch system-xs-regular text-text-tertiary">{endpoint}</div>
</div>
{canManageExternalKnowledgeApi && (
<div className="flex items-start gap-1">

View File

@ -79,12 +79,27 @@ const createDefaultMockApiList = (): ExternalAPIItem[] => [
let mockExternalKnowledgeApiList: ExternalAPIItem[] = createDefaultMockApiList()
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
externalKnowledgeApiList: mockExternalKnowledgeApiList,
mutateExternalKnowledgeApis: vi.fn(),
isLoading: false,
}),
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useQuery: () => ({ data: { data: mockExternalKnowledgeApiList } }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
datasets: {
externalKnowledgeApi: {
get: {
queryOptions: () => ({
queryKey: ['console', 'datasets', 'externalKnowledgeApi', 'get'],
}),
},
},
},
},
}))
// Suppress console.error helper

View File

@ -1,11 +1,12 @@
import { RiAddLine, RiArrowDownSLine } from '@remixicon/react'
import { useQueryClient } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import { useExternalKnowledgeApi } from '@/context/external-knowledge-api-context'
import { useModalContext } from '@/context/modal-context'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
type ApiItem = {
value: string
@ -26,7 +27,10 @@ const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onS
items.find((item) => item.value === value) || null,
)
const { setShowExternalKnowledgeAPIModal } = useModalContext()
const { mutateExternalKnowledgeApis } = useExternalKnowledgeApi()
const queryClient = useQueryClient()
const externalKnowledgeApiQueryKey = consoleQuery.datasets.externalKnowledgeApi.get.queryOptions({
input: {},
}).queryKey
const router = useRouter()
useEffect(() => {
@ -38,12 +42,9 @@ const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onS
setShowExternalKnowledgeAPIModal({
payload: { name: '', settings: { endpoint: '', api_key: '' } },
onSaveCallback: async () => {
mutateExternalKnowledgeApis()
await queryClient.invalidateQueries({ queryKey: externalKnowledgeApiQueryKey })
router.refresh()
},
onCancelCallback: () => {
mutateExternalKnowledgeApis()
},
isEditMode: false,
})
}

View File

@ -2,13 +2,14 @@
import { Button } from '@langgenius/dify-ui/button'
import { RiAddLine } from '@remixicon/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { useExternalKnowledgeApi } from '@/context/external-knowledge-api-context'
import { useModalContext } from '@/context/modal-context'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import ExternalApiSelect from './ExternalApiSelect'
type ExternalApiSelectionProps = {
@ -24,15 +25,18 @@ const ExternalApiSelection: React.FC<ExternalApiSelectionProps> = ({
}) => {
const { t } = useTranslation()
const router = useRouter()
const { externalKnowledgeApiList } = useExternalKnowledgeApi()
const queryClient = useQueryClient()
const externalKnowledgeApiQueryOptions =
consoleQuery.datasets.externalKnowledgeApi.get.queryOptions({ input: {} })
const { data } = useQuery(externalKnowledgeApiQueryOptions)
const externalKnowledgeApiList = data?.data ?? []
const [selectedApiId, setSelectedApiId] = useState(external_knowledge_api_id)
const { setShowExternalKnowledgeAPIModal } = useModalContext()
const { mutateExternalKnowledgeApis } = useExternalKnowledgeApi()
const apiItems = externalKnowledgeApiList.map((api) => ({
value: api.id,
name: api.name,
url: api.settings.endpoint,
url: api.settings && typeof api.settings.endpoint === 'string' ? api.settings.endpoint : '',
}))
useEffect(() => {
@ -48,12 +52,11 @@ const ExternalApiSelection: React.FC<ExternalApiSelectionProps> = ({
setShowExternalKnowledgeAPIModal({
payload: { name: '', settings: { endpoint: '', api_key: '' } },
onSaveCallback: async () => {
mutateExternalKnowledgeApis()
await queryClient.invalidateQueries({
queryKey: externalKnowledgeApiQueryOptions.queryKey,
})
router.refresh()
},
onCancelCallback: () => {
mutateExternalKnowledgeApis()
},
isEditMode: false,
})
}

View File

@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({
push: vi.fn(),
refresh: vi.fn(),
setShowExternalKnowledgeAPIModal: vi.fn(),
mutateExternalKnowledgeApis: vi.fn(),
invalidateQueries: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
@ -22,10 +22,28 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
mutateExternalKnowledgeApis: mocks.mutateExternalKnowledgeApis,
}),
const externalKnowledgeApiQueryKey = ['console', 'datasets', 'externalKnowledgeApi', 'get']
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
datasets: {
externalKnowledgeApi: {
get: {
queryOptions: () => ({
queryKey: ['console', 'datasets', 'externalKnowledgeApi', 'get'],
}),
},
},
},
},
}))
vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({
@ -87,11 +105,19 @@ describe('ExternalApiSelect', () => {
expect(screen.getByText('dataset.createNewExternalAPI')).toBeInTheDocument()
})
it('should call setShowExternalKnowledgeAPIModal when add new clicked', () => {
it('should invalidate the generated query after creating an external API', async () => {
render(<ExternalApiSelect items={items} onSelect={onSelect} />)
fireEvent.click(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder'))
fireEvent.click(screen.getByText('dataset.createNewExternalAPI'))
expect(mocks.setShowExternalKnowledgeAPIModal).toHaveBeenCalledOnce()
const modalConfig = mocks.setShowExternalKnowledgeAPIModal.mock.calls[0]![0]
await modalConfig.onSaveCallback()
expect(mocks.invalidateQueries).toHaveBeenCalledWith({
queryKey: externalKnowledgeApiQueryKey,
})
expect(mocks.refresh).toHaveBeenCalledOnce()
})
it('should show item URLs in dropdown', () => {

View File

@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
push: vi.fn(),
refresh: vi.fn(),
setShowExternalKnowledgeAPIModal: vi.fn(),
invalidateQueries: vi.fn(),
externalKnowledgeApiList: [] as Array<{
id: string
name: string
@ -23,10 +24,29 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
externalKnowledgeApiList: mocks.externalKnowledgeApiList,
}),
const externalKnowledgeApiQueryKey = ['console', 'datasets', 'externalKnowledgeApi', 'get']
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useQuery: () => ({ data: { data: mocks.externalKnowledgeApiList } }),
useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
datasets: {
externalKnowledgeApi: {
get: {
queryOptions: () => ({
queryKey: ['console', 'datasets', 'externalKnowledgeApi', 'get'],
}),
},
},
},
},
}))
vi.mock('../ExternalApiSelect', () => ({
@ -108,4 +128,19 @@ describe('ExternalApiSelection', () => {
expect(mocks.setShowExternalKnowledgeAPIModal).toHaveBeenCalledOnce()
})
it('invalidates the generated query after creating an external API', async () => {
const user = userEvent.setup()
mocks.externalKnowledgeApiList = []
render(<ExternalApiSelection {...defaultProps} />)
await user.click(screen.getByRole('button', { name: 'dataset.noExternalKnowledge' }))
const modalConfig = mocks.setShowExternalKnowledgeAPIModal.mock.calls[0]![0]
await modalConfig.onSaveCallback()
expect(mocks.invalidateQueries).toHaveBeenCalledWith({
queryKey: externalKnowledgeApiQueryKey,
})
expect(mocks.refresh).toHaveBeenCalledOnce()
})
})

View File

@ -21,7 +21,7 @@ vi.mock('@/context/i18n', () => ({
`https://docs.dify.ai/en${path?.startsWith('/use-dify/') ? `/cloud${path}` : path || ''}`,
}))
// Mock external context providers (these are external dependencies)
// Mock the shared modal owner.
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
@ -59,15 +59,31 @@ const createDefaultMockApiList = (): ExternalAPIItem[] => [
}),
]
const mockMutateExternalKnowledgeApis = vi.fn()
const mockInvalidateQueries = vi.fn()
const externalKnowledgeApiQueryKey = ['console', 'datasets', 'externalKnowledgeApi', 'get']
let mockExternalKnowledgeApiList: ExternalAPIItem[] = createDefaultMockApiList()
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
externalKnowledgeApiList: mockExternalKnowledgeApiList,
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
isLoading: false,
}),
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useQuery: () => ({ data: { data: mockExternalKnowledgeApiList } }),
useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
datasets: {
externalKnowledgeApi: {
get: {
queryOptions: () => ({
queryKey: ['console', 'datasets', 'externalKnowledgeApi', 'get'],
}),
},
},
},
},
}))
// Helper to render component with default props
@ -523,7 +539,7 @@ describe('ExternalKnowledgeBaseCreate', () => {
)
})
it('should call mutate and router.refresh on modal save callback', async () => {
it('should invalidate the generated query and refresh after modal save', async () => {
const user = userEvent.setup()
// Set empty API list
mockExternalKnowledgeApiList = []
@ -536,11 +552,13 @@ describe('ExternalKnowledgeBaseCreate', () => {
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0]![0]
await modalCall.onSaveCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: externalKnowledgeApiQueryKey,
})
expect(mockRefresh).toHaveBeenCalled()
})
it('should call mutate on modal cancel callback', async () => {
it('should not invalidate the generated query when the modal is canceled', async () => {
const user = userEvent.setup()
// Set empty API list
mockExternalKnowledgeApiList = []
@ -551,9 +569,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
// Get the callback and invoke it
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0]![0]
modalCall.onCancelCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
expect(modalCall.onCancelCallback).toBeUndefined()
expect(mockInvalidateQueries).not.toHaveBeenCalled()
})
it('should display API URL in dropdown', async () => {
@ -600,7 +617,7 @@ describe('ExternalKnowledgeBaseCreate', () => {
)
})
it('should call mutate and refresh on save callback from ExternalApiSelect dropdown', async () => {
it('should invalidate and refresh after saving from the API dropdown', async () => {
const user = userEvent.setup()
renderComponent()
@ -614,11 +631,13 @@ describe('ExternalKnowledgeBaseCreate', () => {
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0]![0]
await modalCall.onSaveCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: externalKnowledgeApiQueryKey,
})
expect(mockRefresh).toHaveBeenCalled()
})
it('should call mutate on cancel callback from ExternalApiSelect dropdown', async () => {
it('should not invalidate after canceling from the API dropdown', async () => {
const user = userEvent.setup()
renderComponent()
@ -630,9 +649,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
// Get the callback from the modal call and invoke it
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0]![0]
modalCall.onCancelCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
expect(modalCall.onCancelCallback).toBeUndefined()
expect(mockInvalidateQueries).not.toHaveBeenCalled()
})
it('should close dropdown after selecting an API', async () => {

View File

@ -126,15 +126,6 @@ const renderList = (
const render = (ui: ReactElement) => renderList(ui)
const renderWithNuqs = renderList
// Mock external api panel context
const mockSetShowExternalApiPanel = vi.fn()
vi.mock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: false,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
// Mock useDocumentTitle hook
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
@ -336,6 +327,24 @@ describe('List', () => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('view')).toBe('new')
})
it('should reset each view panel when its owning list unmounts', async () => {
const user = userEvent.setup()
mockConsoleState.knowledgeFsEnabled = true
renderWithNuqs(<List />)
await user.click(screen.getByRole('button', { name: 'dataset.externalAPIPanelTitle' }))
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.new' }))
expect(screen.queryByTestId('external-api-panel')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dataset.externalAPIPanelTitle' }))
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.legacy' }))
expect(screen.queryByTestId('external-api-panel')).not.toBeInTheDocument()
})
it('should restore the New Knowledge view from the URL', () => {
mockConsoleState.knowledgeFsEnabled = true
@ -435,6 +444,7 @@ describe('List', () => {
render(<List />)
expect(screen.queryByText(/externalAPIPanelTitle/)).not.toBeInTheDocument()
expect(screen.queryByTestId('external-api-panel')).not.toBeInTheDocument()
})
})
@ -483,7 +493,7 @@ describe('List', () => {
const button = screen.getByText(/externalAPIPanelTitle/)
fireEvent.click(button)
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(true)
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
})
it('should update search input value', () => {
@ -654,69 +664,15 @@ describe('List', () => {
}
})
it('should show ExternalAPIPanel when showExternalApiPanel is true', async () => {
// Re-mock to show external API panel
vi.doMock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: true,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
it('should close ExternalAPIPanel when onClose is called', () => {
render(<List />)
vi.resetModules()
const { default: ListComponent } = await import('../index')
render(<ListComponent />)
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
expect(screen.getByTestId('external-api-panel')).toHaveAttribute(
'data-can-manage-external-knowledge-api',
'true',
)
})
it('should not show ExternalAPIPanel without dataset.external.connect even when panel state is open', async () => {
mockConsoleState = {
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
knowledgeFsEnabled: false,
workspacePermissionKeys: ['dataset.create_and_management'],
}
vi.doMock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: true,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
vi.resetModules()
const { default: ListComponent } = await import('../index')
render(<ListComponent />)
fireEvent.click(screen.getByText(/externalAPIPanelTitle/))
fireEvent.click(screen.getByText('Close Panel'))
expect(screen.queryByTestId('external-api-panel')).not.toBeInTheDocument()
})
it('should close ExternalAPIPanel when onClose is called', async () => {
vi.doMock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: true,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
vi.resetModules()
const { default: ListComponent } = await import('../index')
render(<ListComponent />)
const closeButton = screen.getByText('Close Panel')
fireEvent.click(closeButton)
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(false)
})
it('should show TagManagementModal when tag management is opened', () => {
render(<List />)
fireEvent.click(screen.getByText('Manage Tags'))

View File

@ -18,7 +18,6 @@ import {
getStepByStepTourGuides,
STEP_BY_STEP_TOUR_TARGETS,
} from '@/app/components/step-by-step-tour/target-registry'
import { useExternalApiPanel } from '@/context/external-api-panel-context'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { isCurrentWorkspaceOwnerAtom } from '@/context/workspace-state'
import { NewKnowledgeList } from '@/features/new-rag/new-knowledge-list'
@ -50,7 +49,7 @@ function LegacyList({
const { push } = useRouter()
const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel()
const [showExternalApiPanel, setShowExternalApiPanel] = useState(false)
const [includeAll, { toggle: toggleIncludeAll }] = useBoolean(false)
const invalidDatasetList = useInvalidDatasetList()
useDocumentTitle(t(($) => $.knowledge, { ns: 'dataset' }))

View File

@ -1,29 +0,0 @@
'use client'
import * as React from 'react'
import { createContext, use, useState } from 'react'
type ExternalApiPanelContextType = {
showExternalApiPanel: boolean
setShowExternalApiPanel: (show: boolean) => void
}
const ExternalApiPanelContext = createContext<ExternalApiPanelContextType | undefined>(undefined)
export const ExternalApiPanelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [showExternalApiPanel, setShowExternalApiPanel] = useState(false)
return (
<ExternalApiPanelContext.Provider value={{ showExternalApiPanel, setShowExternalApiPanel }}>
{children}
</ExternalApiPanelContext.Provider>
)
}
export const useExternalApiPanel = () => {
const context = use(ExternalApiPanelContext)
if (context === undefined)
throw new Error('useExternalApiPanel must be used within an ExternalApiPanelProvider')
return context
}

View File

@ -1,57 +0,0 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { ExternalAPIItem, ExternalAPIListResponse } from '@/models/datasets'
import { createContext, use, useCallback, useMemo } from 'react'
import { useExternalKnowledgeApiList } from '@/service/knowledge/use-dataset'
type ExternalKnowledgeApiContextType = {
externalKnowledgeApiList: ExternalAPIItem[]
mutateExternalKnowledgeApis: () => Promise<ExternalAPIListResponse | undefined>
isLoading: boolean
}
const ExternalKnowledgeApiContext = createContext<ExternalKnowledgeApiContextType | undefined>(
undefined,
)
type ExternalKnowledgeApiProviderProps = {
children: ReactNode
enabled?: boolean
}
export const ExternalKnowledgeApiProvider: FC<ExternalKnowledgeApiProviderProps> = ({
children,
enabled = true,
}) => {
const { data, refetch, isLoading } = useExternalKnowledgeApiList({ enabled })
const mutateExternalKnowledgeApis = useCallback(() => {
if (!enabled) return Promise.resolve(undefined)
return refetch().then((res) => res.data)
}, [enabled, refetch])
const contextValue = useMemo<ExternalKnowledgeApiContextType>(
() => ({
externalKnowledgeApiList: data?.data || [],
mutateExternalKnowledgeApis,
isLoading,
}),
[data, mutateExternalKnowledgeApis, isLoading],
)
return (
<ExternalKnowledgeApiContext.Provider value={contextValue}>
{children}
</ExternalKnowledgeApiContext.Provider>
)
}
export const useExternalKnowledgeApi = () => {
const context = use(ExternalKnowledgeApiContext)
if (context === undefined)
throw new Error('useExternalKnowledgeApi must be used within a ExternalKnowledgeApiProvider')
return context
}

View File

@ -16,10 +16,6 @@ type ListKnowledgeSpacesInfiniteOptions = {
}
}
const externalApiPanelMock = vi.hoisted(() => ({
open: false,
setOpen: vi.fn(),
}))
const toastInfoMock = vi.hoisted(() => vi.fn())
vi.mock('@langgenius/dify-ui/toast', () => ({
@ -46,13 +42,6 @@ const permissionStateMock = vi.hoisted(() => ({
workspacePermissionKeysAtom: Symbol('workspacePermissionKeysAtom'),
}))
vi.mock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: externalApiPanelMock.open,
setShowExternalApiPanel: externalApiPanelMock.setOpen,
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetApiBaseUrl: () => ({ data: { api_base_url: 'https://api.example.com' } }),
}))
@ -62,7 +51,14 @@ vi.mock('@/app/components/datasets/extra-info/service-api', () => ({
}))
vi.mock('@/app/components/datasets/external-api/external-api-panel', () => ({
default: () => <div>external API panel</div>,
default: ({ onClose }: { onClose: () => void }) => (
<div>
external API panel
<button type="button" onClick={onClose}>
close external API panel
</button>
</div>
),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
@ -109,7 +105,6 @@ const setResolvedPage = (items: KnowledgeSpaceList['items'] = []) => {
describe('NewKnowledgeList', () => {
beforeEach(() => {
vi.clearAllMocks()
externalApiPanelMock.open = false
queryMock.data = undefined
queryMock.error = null
queryMock.hasNextPage = false
@ -219,7 +214,9 @@ describe('NewKnowledgeList', () => {
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'dataset.externalAPIPanelTitle' }))
expect(externalApiPanelMock.setOpen).toHaveBeenCalledWith(true)
expect(screen.getByText('external API panel')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'close external API panel' }))
expect(screen.queryByText('external API panel')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.serviceApi.title' })).toBeInTheDocument()
const tags = screen.getByRole('button', { name: 'dataset.newKnowledge.tags' })
@ -296,6 +293,10 @@ describe('NewKnowledgeList', () => {
expect(
screen.queryByRole('link', { name: /common\.operation\.create/ }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'dataset.externalAPIPanelTitle' }),
).not.toBeInTheDocument()
expect(screen.queryByText('external API panel')).not.toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.readOnlyEmpty')).toBeInTheDocument()
})

View File

@ -9,7 +9,6 @@ import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import ExternalAPIPanel from '@/app/components/datasets/external-api/external-api-panel'
import ServiceApi from '@/app/components/datasets/extra-info/service-api'
import { useExternalApiPanel } from '@/context/external-api-panel-context'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
@ -60,7 +59,7 @@ export function NewKnowledgeList({
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel()
const [showExternalApiPanel, setShowExternalApiPanel] = useState(false)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canCreate = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
const canConnect = hasPermission(workspacePermissionKeys, 'dataset.external.connect')

View File

@ -223,14 +223,6 @@ export type DataSetListResponse = {
total: number
}
export type ExternalAPIListResponse = {
data: ExternalAPIItem[]
has_more: boolean
limit: number
page: number
total: number
}
export type QA = {
question: string
answer: string

View File

@ -6,7 +6,6 @@ import type {
DatasetListRequest,
DataSetListResponse,
ErrorDocsResponse,
ExternalAPIListResponse,
FetchDatasetsParams,
HitTestingRecordsResponse,
IndexingStatusBatchRequest,
@ -189,14 +188,6 @@ export const useInvalidateDatasetApiKeys = () => {
}
}
export const useExternalKnowledgeApiList = (options?: { enabled?: boolean }) => {
return useQuery<ExternalAPIListResponse>({
queryKey: [NAME_SPACE, 'external-knowledge-api'],
queryFn: () => get<ExternalAPIListResponse>('/datasets/external-knowledge-api'),
enabled: options?.enabled ?? true,
})
}
export const useDatasetTestingRecords = (
datasetId?: string,
params?: { page: number; limit: number },