mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into fix/upload-restrictions
This commit is contained in:
commit
0404007982
|
|
@ -422,7 +422,6 @@ class DatasetApi(Resource):
|
|||
raise NotFound("Dataset not found.")
|
||||
|
||||
payload = DatasetUpdatePayload.model_validate(console_ns.payload or {})
|
||||
payload_data = payload.model_dump(exclude_unset=True)
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
# check embedding model setting
|
||||
if (
|
||||
|
|
@ -434,6 +433,7 @@ class DatasetApi(Resource):
|
|||
dataset.tenant_id, payload.embedding_model_provider, payload.embedding_model
|
||||
)
|
||||
payload.is_multimodal = is_multimodal
|
||||
payload_data = payload.model_dump(exclude_unset=True)
|
||||
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
|
||||
DatasetPermissionService.check_permission(
|
||||
current_user, dataset, payload.permission, payload.partial_member_list
|
||||
|
|
|
|||
|
|
@ -554,11 +554,16 @@ class LLMGenerator:
|
|||
prompt_messages=list(prompt_messages), model_parameters=model_parameters, stream=False
|
||||
)
|
||||
|
||||
generated_raw = cast(str, response.message.content)
|
||||
generated_raw = response.message.get_text_content()
|
||||
first_brace = generated_raw.find("{")
|
||||
last_brace = generated_raw.rfind("}")
|
||||
return {**json.loads(generated_raw[first_brace : last_brace + 1])}
|
||||
|
||||
if first_brace == -1 or last_brace == -1 or last_brace < first_brace:
|
||||
raise ValueError(f"Could not find a valid JSON object in response: {generated_raw}")
|
||||
json_str = generated_raw[first_brace : last_brace + 1]
|
||||
data = json_repair.loads(json_str)
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError(f"Expected a JSON object, but got {type(data).__name__}")
|
||||
return data
|
||||
except InvokeError as e:
|
||||
error = str(e)
|
||||
return {"error": f"Failed to generate code. Error: {error}"}
|
||||
|
|
|
|||
|
|
@ -371,7 +371,7 @@ class RetrievalService:
|
|||
include_segment_ids = set()
|
||||
segment_child_map = {}
|
||||
segment_file_map = {}
|
||||
with Session(db.engine) as session:
|
||||
with Session(bind=db.engine, expire_on_commit=False) as session:
|
||||
# Process documents
|
||||
for document in documents:
|
||||
segment_id = None
|
||||
|
|
@ -395,7 +395,7 @@ class RetrievalService:
|
|||
session,
|
||||
)
|
||||
if attachment_info_dict:
|
||||
attachment_info = attachment_info_dict["attchment_info"]
|
||||
attachment_info = attachment_info_dict["attachment_info"]
|
||||
segment_id = attachment_info_dict["segment_id"]
|
||||
else:
|
||||
child_index_node_id = document.metadata.get("doc_id")
|
||||
|
|
@ -417,13 +417,6 @@ class RetrievalService:
|
|||
DocumentSegment.status == "completed",
|
||||
DocumentSegment.id == segment_id,
|
||||
)
|
||||
.options(
|
||||
load_only(
|
||||
DocumentSegment.id,
|
||||
DocumentSegment.content,
|
||||
DocumentSegment.answer,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
|
@ -475,7 +468,7 @@ class RetrievalService:
|
|||
session,
|
||||
)
|
||||
if attachment_info_dict:
|
||||
attachment_info = attachment_info_dict["attchment_info"]
|
||||
attachment_info = attachment_info_dict["attachment_info"]
|
||||
segment_id = attachment_info_dict["segment_id"]
|
||||
document_segment_stmt = select(DocumentSegment).where(
|
||||
DocumentSegment.dataset_id == dataset_document.dataset_id,
|
||||
|
|
@ -684,7 +677,7 @@ class RetrievalService:
|
|||
.first()
|
||||
)
|
||||
if attachment_binding:
|
||||
attchment_info = {
|
||||
attachment_info = {
|
||||
"id": upload_file.id,
|
||||
"name": upload_file.name,
|
||||
"extension": "." + upload_file.extension,
|
||||
|
|
@ -692,5 +685,5 @@ class RetrievalService:
|
|||
"source_url": sign_upload_file(upload_file.id, upload_file.extension),
|
||||
"size": upload_file.size,
|
||||
}
|
||||
return {"attchment_info": attchment_info, "segment_id": attachment_binding.segment_id}
|
||||
return {"attachment_info": attachment_info, "segment_id": attachment_binding.segment_id}
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -266,7 +266,7 @@ class DatasetRetrieval:
|
|||
).all()
|
||||
if attachments_with_bindings:
|
||||
for _, upload_file in attachments_with_bindings:
|
||||
attchment_info = File(
|
||||
attachment_info = File(
|
||||
id=upload_file.id,
|
||||
filename=upload_file.name,
|
||||
extension="." + upload_file.extension,
|
||||
|
|
@ -280,7 +280,7 @@ class DatasetRetrieval:
|
|||
storage_key=upload_file.key,
|
||||
url=sign_upload_file(upload_file.id, upload_file.extension),
|
||||
)
|
||||
context_files.append(attchment_info)
|
||||
context_files.append(attachment_info)
|
||||
if show_retrieve_source:
|
||||
for record in records:
|
||||
segment = record.segment
|
||||
|
|
|
|||
|
|
@ -697,7 +697,7 @@ class LLMNode(Node[LLMNodeData]):
|
|||
).all()
|
||||
if attachments_with_bindings:
|
||||
for _, upload_file in attachments_with_bindings:
|
||||
attchment_info = File(
|
||||
attachment_info = File(
|
||||
id=upload_file.id,
|
||||
filename=upload_file.name,
|
||||
extension="." + upload_file.extension,
|
||||
|
|
@ -711,7 +711,7 @@ class LLMNode(Node[LLMNodeData]):
|
|||
storage_key=upload_file.key,
|
||||
url=sign_upload_file(upload_file.id, upload_file.extension),
|
||||
)
|
||||
context_files.append(attchment_info)
|
||||
context_files.append(attachment_info)
|
||||
yield RunRetrieverResourceEvent(
|
||||
retriever_resources=original_retriever_resource,
|
||||
context=context_str.strip(),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import {
|
|||
import { useKeyPress } from 'ahooks'
|
||||
import Divider from '../../base/divider'
|
||||
import Loading from '../../base/loading'
|
||||
import Toast from '../../base/toast'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../../workflow/utils'
|
||||
import AccessControl from '../app-access-control'
|
||||
|
|
@ -50,6 +49,7 @@ import { AppModeEnum } from '@/types/app'
|
|||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { basePath } from '@/utils/var'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
|
||||
const ACCESS_MODE_MAP: Record<AccessMode, { label: string, icon: React.ElementType }> = {
|
||||
[AccessMode.ORGANIZATION]: {
|
||||
|
|
@ -216,18 +216,23 @@ const AppPublisher = ({
|
|||
setPublished(false)
|
||||
}, [disabled, onToggle, open])
|
||||
|
||||
const handleOpenInExplore = useCallback(async () => {
|
||||
try {
|
||||
const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {}
|
||||
if (installed_apps?.length > 0)
|
||||
window.open(`${basePath}/explore/installed/${installed_apps[0].id}`, '_blank')
|
||||
else
|
||||
const { openAsync } = useAsyncWindowOpen()
|
||||
|
||||
const handleOpenInExplore = useCallback(() => {
|
||||
if (!appDetail?.id) return
|
||||
|
||||
openAsync(
|
||||
async () => {
|
||||
const { installed_apps }: { installed_apps?: { id: string }[] } = await fetchInstalledAppList(appDetail.id) || {}
|
||||
if (installed_apps && installed_apps.length > 0)
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
throw new Error('No app found in Explore')
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: `${e.message || e}` })
|
||||
}
|
||||
}, [appDetail?.id])
|
||||
},
|
||||
{
|
||||
errorMessage: 'Failed to open app in Explore',
|
||||
},
|
||||
)
|
||||
}, [appDetail?.id, openAsync])
|
||||
|
||||
const handleAccessControlUpdate = useCallback(async () => {
|
||||
if (!appDetail)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { type App, AppModeEnum } from '@/types/app'
|
||||
import Toast, { ToastContext } from '@/app/components/base/toast'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
|
|
@ -31,6 +31,7 @@ import { AccessMode } from '@/models/access-control'
|
|||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { formatTime } from '@/utils/time'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
|
||||
|
|
@ -242,20 +243,24 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
|||
e.preventDefault()
|
||||
setShowAccessControl(true)
|
||||
}
|
||||
const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const { openAsync } = useAsyncWindowOpen()
|
||||
|
||||
const onClickInstalledApp = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
props.onClick?.()
|
||||
e.preventDefault()
|
||||
try {
|
||||
const { installed_apps }: any = await fetchInstalledAppList(app.id) || {}
|
||||
if (installed_apps?.length > 0)
|
||||
window.open(`${basePath}/explore/installed/${installed_apps[0].id}`, '_blank')
|
||||
else
|
||||
|
||||
openAsync(
|
||||
async () => {
|
||||
const { installed_apps }: { installed_apps?: { id: string }[] } = await fetchInstalledAppList(app.id) || {}
|
||||
if (installed_apps && installed_apps.length > 0)
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
throw new Error('No app found in Explore')
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: `${e.message || e}` })
|
||||
}
|
||||
},
|
||||
{
|
||||
errorMessage: 'Failed to open app in Explore',
|
||||
},
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="relative flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import Toast from '../../../../base/toast'
|
|||
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { fetchBillingUrl, fetchSubscriptionUrls } from '@/service/billing'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import List from './list'
|
||||
import Button from './button'
|
||||
import { Professional, Sandbox, Team } from '../../assets'
|
||||
|
|
@ -54,6 +55,8 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
|||
})[plan]
|
||||
}, [isCurrent, plan, t])
|
||||
|
||||
const { openAsync } = useAsyncWindowOpen()
|
||||
|
||||
const handleGetPayUrl = async () => {
|
||||
if (loading)
|
||||
return
|
||||
|
|
@ -72,8 +75,13 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
|||
setLoading(true)
|
||||
try {
|
||||
if (isCurrentPaidPlan) {
|
||||
const res = await fetchBillingUrl()
|
||||
window.open(res.url, '_blank')
|
||||
await openAsync(
|
||||
() => fetchBillingUrl().then(res => res.url),
|
||||
{
|
||||
errorMessage: 'Failed to open billing page',
|
||||
windowFeatures: 'noopener,noreferrer',
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,367 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ExternalAPIItem } from '@/models/datasets'
|
||||
import ExternalKnowledgeBaseConnector from './index'
|
||||
import { createExternalKnowledgeBase } from '@/service/datasets'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockRouterBack = jest.fn()
|
||||
const mockReplace = jest.fn()
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
back: mockRouterBack,
|
||||
replace: mockReplace,
|
||||
push: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock react-i18next
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useDocLink hook
|
||||
jest.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,
|
||||
}))
|
||||
|
||||
// Mock toast context
|
||||
const mockNotify = jest.fn()
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock modal context
|
||||
jest.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowExternalKnowledgeAPIModal: jest.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API service
|
||||
jest.mock('@/service/datasets', () => ({
|
||||
createExternalKnowledgeBase: jest.fn(),
|
||||
}))
|
||||
|
||||
// Factory function to create mock ExternalAPIItem
|
||||
const createMockExternalAPIItem = (overrides: Partial<ExternalAPIItem> = {}): ExternalAPIItem => ({
|
||||
id: 'api-default',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Default API',
|
||||
description: 'Default API description',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com',
|
||||
api_key: 'test-api-key',
|
||||
},
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Default mock API list
|
||||
const createDefaultMockApiList = (): ExternalAPIItem[] => [
|
||||
createMockExternalAPIItem({
|
||||
id: 'api-1',
|
||||
name: 'Test API 1',
|
||||
settings: { endpoint: 'https://api1.example.com', api_key: 'key-1' },
|
||||
}),
|
||||
createMockExternalAPIItem({
|
||||
id: 'api-2',
|
||||
name: 'Test API 2',
|
||||
settings: { endpoint: 'https://api2.example.com', api_key: 'key-2' },
|
||||
}),
|
||||
]
|
||||
|
||||
let mockExternalKnowledgeApiList: ExternalAPIItem[] = createDefaultMockApiList()
|
||||
|
||||
jest.mock('@/context/external-knowledge-api-context', () => ({
|
||||
useExternalKnowledgeApi: () => ({
|
||||
externalKnowledgeApiList: mockExternalKnowledgeApiList,
|
||||
mutateExternalKnowledgeApis: jest.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Suppress console.error helper
|
||||
const suppressConsoleError = () => jest.spyOn(console, 'error').mockImplementation(jest.fn())
|
||||
|
||||
// Helper to create a pending promise with external resolver
|
||||
function createPendingPromise<T>() {
|
||||
let resolve: (value: T) => void = jest.fn()
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
// Helper to fill required form fields and submit
|
||||
async function fillFormAndSubmit(user: ReturnType<typeof userEvent.setup>) {
|
||||
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
|
||||
const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder')
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'Test Knowledge Base' } })
|
||||
fireEvent.change(knowledgeIdInput, { target: { value: 'kb-123' } })
|
||||
|
||||
// Wait for button to be enabled
|
||||
await waitFor(() => {
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
expect(connectButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
await user.click(connectButton!)
|
||||
}
|
||||
|
||||
describe('ExternalKnowledgeBaseConnector', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockExternalKnowledgeApiList = createDefaultMockApiList()
|
||||
;(createExternalKnowledgeBase as jest.Mock).mockResolvedValue({ id: 'new-kb-id' })
|
||||
})
|
||||
|
||||
// Tests for rendering with real ExternalKnowledgeBaseCreate component
|
||||
describe('Rendering', () => {
|
||||
it('should render the create form with all required elements', () => {
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
// Verify main title and form elements
|
||||
expect(screen.getByText('dataset.connectDataset')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.externalKnowledgeName')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.externalKnowledgeId')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.retrievalSettings')).toBeInTheDocument()
|
||||
|
||||
// Verify buttons
|
||||
expect(screen.getByText('dataset.externalKnowledgeForm.cancel')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.externalKnowledgeForm.connect')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render connect button disabled initially', () => {
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
expect(connectButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for API success flow
|
||||
describe('API Success Flow', () => {
|
||||
it('should call API and show success notification when form is submitted', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
await fillFormAndSubmit(user)
|
||||
|
||||
// Verify API was called with form data
|
||||
await waitFor(() => {
|
||||
expect(createExternalKnowledgeBase).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
name: 'Test Knowledge Base',
|
||||
external_knowledge_id: 'kb-123',
|
||||
external_knowledge_api_id: 'api-1',
|
||||
provider: 'external',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// Verify success notification
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'External Knowledge Base Connected Successfully',
|
||||
})
|
||||
|
||||
// Verify navigation back
|
||||
expect(mockRouterBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should include retrieval settings in API call', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
await fillFormAndSubmit(user)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createExternalKnowledgeBase).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
external_retrieval_model: expect.objectContaining({
|
||||
top_k: 4,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for API error flow
|
||||
describe('API Error Flow', () => {
|
||||
it('should show error notification when API fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
const consoleErrorSpy = suppressConsoleError()
|
||||
;(createExternalKnowledgeBase as jest.Mock).mockRejectedValue(new Error('Network Error'))
|
||||
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
await fillFormAndSubmit(user)
|
||||
|
||||
// Verify error notification
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Failed to connect External Knowledge Base',
|
||||
})
|
||||
})
|
||||
|
||||
// Verify no navigation
|
||||
expect(mockRouterBack).not.toHaveBeenCalled()
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should show error notification when API returns invalid result', async () => {
|
||||
const user = userEvent.setup()
|
||||
const consoleErrorSpy = suppressConsoleError()
|
||||
;(createExternalKnowledgeBase as jest.Mock).mockResolvedValue({})
|
||||
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
await fillFormAndSubmit(user)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Failed to connect External Knowledge Base',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockRouterBack).not.toHaveBeenCalled()
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for loading state
|
||||
describe('Loading State', () => {
|
||||
it('should show loading state during API call', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Create a promise that won't resolve immediately
|
||||
const { promise, resolve: resolvePromise } = createPendingPromise<{ id: string }>()
|
||||
;(createExternalKnowledgeBase as jest.Mock).mockReturnValue(promise)
|
||||
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
// Fill form
|
||||
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
|
||||
const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: 'Test' } })
|
||||
fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } })
|
||||
|
||||
await waitFor(() => {
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
expect(connectButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Click connect
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
await user.click(connectButton!)
|
||||
|
||||
// Button should show loading (the real Button component has loading prop)
|
||||
await waitFor(() => {
|
||||
expect(createExternalKnowledgeBase).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise({ id: 'new-id' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'External Knowledge Base Connected Successfully',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for form validation (integration with real create component)
|
||||
describe('Form Validation', () => {
|
||||
it('should keep button disabled when only name is filled', () => {
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: 'Test' } })
|
||||
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
expect(connectButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should keep button disabled when only knowledge id is filled', () => {
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder')
|
||||
fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } })
|
||||
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
expect(connectButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable button when all required fields are filled', async () => {
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
|
||||
const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder')
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'Test' } })
|
||||
fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } })
|
||||
|
||||
await waitFor(() => {
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
expect(connectButton).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for user interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should allow typing in form fields', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
|
||||
const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder')
|
||||
|
||||
await user.type(nameInput, 'My Knowledge Base')
|
||||
await user.type(descriptionInput, 'My Description')
|
||||
|
||||
expect((nameInput as HTMLInputElement).value).toBe('My Knowledge Base')
|
||||
expect((descriptionInput as HTMLTextAreaElement).value).toBe('My Description')
|
||||
})
|
||||
|
||||
it('should handle cancel button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const cancelButton = screen.getByText('dataset.externalKnowledgeForm.cancel').closest('button')
|
||||
await user.click(cancelButton!)
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
|
||||
it('should handle back button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const backButton = buttons.find(btn => btn.classList.contains('rounded-full'))
|
||||
await user.click(backButton!)
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,13 +1,9 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { TaskStatus } from '@/app/components/plugins/types'
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import {
|
||||
useMutationClearAllTaskPlugin,
|
||||
useMutationClearTaskPlugin,
|
||||
usePluginTaskList,
|
||||
} from '@/service/use-plugins'
|
||||
|
|
@ -18,7 +14,6 @@ export const usePluginTaskStatus = () => {
|
|||
handleRefetch,
|
||||
} = usePluginTaskList()
|
||||
const { mutateAsync } = useMutationClearTaskPlugin()
|
||||
const { mutateAsync: mutateAsyncClearAll } = useMutationClearAllTaskPlugin()
|
||||
const allPlugins = pluginTasks.map(task => task.plugins.map((plugin) => {
|
||||
return {
|
||||
...plugin,
|
||||
|
|
@ -45,10 +40,6 @@ export const usePluginTaskStatus = () => {
|
|||
})
|
||||
handleRefetch()
|
||||
}, [mutateAsync, handleRefetch])
|
||||
const handleClearAllErrorPlugin = useCallback(async () => {
|
||||
await mutateAsyncClearAll()
|
||||
handleRefetch()
|
||||
}, [mutateAsyncClearAll, handleRefetch])
|
||||
const totalPluginsLength = allPlugins.length
|
||||
const runningPluginsLength = runningPlugins.length
|
||||
const errorPluginsLength = errorPlugins.length
|
||||
|
|
@ -60,26 +51,6 @@ export const usePluginTaskStatus = () => {
|
|||
const isSuccess = successPluginsLength === totalPluginsLength && totalPluginsLength > 0
|
||||
const isFailed = runningPluginsLength === 0 && (errorPluginsLength + successPluginsLength) === totalPluginsLength && totalPluginsLength > 0 && errorPluginsLength > 0
|
||||
|
||||
const [opacity, setOpacity] = useState(1)
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
if (opacity > 0) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setOpacity(v => v - 0.1)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSuccess)
|
||||
setOpacity(1)
|
||||
}, [isSuccess, opacity])
|
||||
|
||||
return {
|
||||
errorPlugins,
|
||||
successPlugins,
|
||||
|
|
@ -94,7 +65,5 @@ export const usePluginTaskStatus = () => {
|
|||
isSuccess,
|
||||
isFailed,
|
||||
handleClearErrorPlugin,
|
||||
handleClearAllErrorPlugin,
|
||||
opacity,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
|
@ -6,6 +7,7 @@ import {
|
|||
RiCheckboxCircleFill,
|
||||
RiErrorWarningFill,
|
||||
RiInstallLine,
|
||||
RiLoaderLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePluginTaskStatus } from './hooks'
|
||||
|
|
@ -14,7 +16,6 @@ import {
|
|||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import CardIcon from '@/app/components/plugins/card/base/card-icon'
|
||||
|
|
@ -22,6 +23,7 @@ import cn from '@/utils/classnames'
|
|||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
|
||||
import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
const PluginTasks = () => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -29,6 +31,8 @@ const PluginTasks = () => {
|
|||
const [open, setOpen] = useState(false)
|
||||
const {
|
||||
errorPlugins,
|
||||
successPlugins,
|
||||
runningPlugins,
|
||||
runningPluginsLength,
|
||||
successPluginsLength,
|
||||
errorPluginsLength,
|
||||
|
|
@ -39,33 +43,69 @@ const PluginTasks = () => {
|
|||
isSuccess,
|
||||
isFailed,
|
||||
handleClearErrorPlugin,
|
||||
handleClearAllErrorPlugin,
|
||||
opacity,
|
||||
} = usePluginTaskStatus()
|
||||
const { getIconUrl } = useGetIcon()
|
||||
|
||||
const handleClearAllWithModal = useCallback(async () => {
|
||||
// Clear all completed plugins (success and error) but keep running ones
|
||||
const completedPlugins = [...successPlugins, ...errorPlugins]
|
||||
|
||||
// Clear all completed plugins individually
|
||||
for (const plugin of completedPlugins)
|
||||
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
|
||||
|
||||
// Only close modal if no plugins are still installing
|
||||
if (runningPluginsLength === 0)
|
||||
setOpen(false)
|
||||
}, [successPlugins, errorPlugins, handleClearErrorPlugin, runningPluginsLength])
|
||||
|
||||
const handleClearErrorsWithModal = useCallback(async () => {
|
||||
// Clear only error plugins, not all plugins
|
||||
for (const plugin of errorPlugins)
|
||||
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
|
||||
// Only close modal if no plugins are still installing
|
||||
if (runningPluginsLength === 0)
|
||||
setOpen(false)
|
||||
}, [errorPlugins, handleClearErrorPlugin, runningPluginsLength])
|
||||
|
||||
const handleClearSingleWithModal = useCallback(async (taskId: string, pluginId: string) => {
|
||||
await handleClearErrorPlugin(taskId, pluginId)
|
||||
// Only close modal if no plugins are still installing
|
||||
if (runningPluginsLength === 0)
|
||||
setOpen(false)
|
||||
}, [handleClearErrorPlugin, runningPluginsLength])
|
||||
|
||||
const tip = useMemo(() => {
|
||||
if (isInstalling)
|
||||
return t('plugin.task.installing', { installingLength: runningPluginsLength })
|
||||
|
||||
if (isInstallingWithSuccess)
|
||||
return t('plugin.task.installingWithSuccess', { installingLength: runningPluginsLength, successLength: successPluginsLength })
|
||||
|
||||
if (isInstallingWithError)
|
||||
return t('plugin.task.installingWithError', { installingLength: runningPluginsLength, successLength: successPluginsLength, errorLength: errorPluginsLength })
|
||||
|
||||
if (isInstallingWithSuccess)
|
||||
return t('plugin.task.installingWithSuccess', { installingLength: runningPluginsLength, successLength: successPluginsLength })
|
||||
if (isInstalling)
|
||||
return t('plugin.task.installing')
|
||||
if (isFailed)
|
||||
return t('plugin.task.installError', { errorLength: errorPluginsLength })
|
||||
}, [isInstalling, isInstallingWithSuccess, isInstallingWithError, isFailed, errorPluginsLength, runningPluginsLength, successPluginsLength, t])
|
||||
return t('plugin.task.installedError', { errorLength: errorPluginsLength })
|
||||
if (isSuccess)
|
||||
return t('plugin.task.installSuccess', { successLength: successPluginsLength })
|
||||
return t('plugin.task.installed')
|
||||
}, [
|
||||
errorPluginsLength,
|
||||
isFailed,
|
||||
isInstalling,
|
||||
isInstallingWithError,
|
||||
isInstallingWithSuccess,
|
||||
isSuccess,
|
||||
runningPluginsLength,
|
||||
successPluginsLength,
|
||||
t,
|
||||
])
|
||||
|
||||
if (!totalPluginsLength)
|
||||
// Show icon if there are any plugin tasks (completed, running, or failed)
|
||||
// Only hide when there are absolutely no plugin tasks
|
||||
if (totalPluginsLength === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center', opacity < 0 && 'hidden')}
|
||||
style={{ opacity }}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
|
|
@ -77,15 +117,20 @@ const PluginTasks = () => {
|
|||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => {
|
||||
if (isFailed)
|
||||
if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess)
|
||||
setOpen(v => !v)
|
||||
}}
|
||||
>
|
||||
<Tooltip popupContent={tip}>
|
||||
<Tooltip
|
||||
popupContent={tip}
|
||||
asChild
|
||||
offset={8}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-components-button-secondary-bg-hover',
|
||||
(isInstallingWithError || isFailed) && 'cursor-pointer border-components-button-destructive-secondary-border-hover bg-state-destructive-hover hover:bg-state-destructive-hover-alt',
|
||||
(isInstalling || isInstallingWithSuccess || isSuccess) && 'cursor-pointer hover:bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
id="plugin-task-trigger"
|
||||
>
|
||||
|
|
@ -124,7 +169,7 @@ const PluginTasks = () => {
|
|||
)
|
||||
}
|
||||
{
|
||||
isSuccess && (
|
||||
(isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0 && errorPluginsLength === 0)) && (
|
||||
<RiCheckboxCircleFill className='h-3.5 w-3.5 text-text-success' />
|
||||
)
|
||||
}
|
||||
|
|
@ -138,52 +183,129 @@ const PluginTasks = () => {
|
|||
</Tooltip>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[11]'>
|
||||
<div className='w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 pb-2 shadow-lg'>
|
||||
<div className='system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1'>
|
||||
{t('plugin.task.installedError', { errorLength: errorPluginsLength })}
|
||||
<Button
|
||||
className='shrink-0'
|
||||
size='small'
|
||||
variant='ghost'
|
||||
onClick={() => handleClearAllErrorPlugin()}
|
||||
>
|
||||
{t('plugin.task.clearAll')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='max-h-[400px] overflow-y-auto'>
|
||||
{
|
||||
errorPlugins.map(errorPlugin => (
|
||||
<div
|
||||
key={errorPlugin.plugin_unique_identifier}
|
||||
className='flex rounded-lg p-2 hover:bg-state-base-hover'
|
||||
>
|
||||
<div className='relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'>
|
||||
<RiErrorWarningFill className='absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive' />
|
||||
<CardIcon
|
||||
size='tiny'
|
||||
src={getIconUrl(errorPlugin.icon)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<div className='system-md-regular truncate text-text-secondary'>
|
||||
{errorPlugin.labels[language]}
|
||||
</div>
|
||||
<div className='system-xs-regular break-all text-text-destructive'>
|
||||
{errorPlugin.message}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className='shrink-0'
|
||||
size='small'
|
||||
variant='ghost'
|
||||
onClick={() => handleClearErrorPlugin(errorPlugin.taskId, errorPlugin.plugin_unique_identifier)}
|
||||
<div className='w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'>
|
||||
{/* Running Plugins */}
|
||||
{runningPlugins.length > 0 && (
|
||||
<>
|
||||
<div className='system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1'>
|
||||
{t('plugin.task.installing')} ({runningPlugins.length})
|
||||
</div>
|
||||
<div className='max-h-[200px] overflow-y-auto'>
|
||||
{runningPlugins.map(runningPlugin => (
|
||||
<div
|
||||
key={runningPlugin.plugin_unique_identifier}
|
||||
className='flex items-center rounded-lg p-2 hover:bg-state-base-hover'
|
||||
>
|
||||
{t('common.operation.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div className='relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'>
|
||||
<RiLoaderLine className='absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 animate-spin text-text-accent' />
|
||||
<CardIcon
|
||||
size='tiny'
|
||||
src={getIconUrl(runningPlugin.icon)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<div className='system-md-regular truncate text-text-secondary'>
|
||||
{runningPlugin.labels[language]}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{t('plugin.task.installing')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Success Plugins */}
|
||||
{successPlugins.length > 0 && (
|
||||
<>
|
||||
<div className='system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1'>
|
||||
{t('plugin.task.installed')} ({successPlugins.length})
|
||||
<Button
|
||||
className='shrink-0'
|
||||
size='small'
|
||||
variant='ghost'
|
||||
onClick={() => handleClearAllWithModal()}
|
||||
>
|
||||
{t('plugin.task.clearAll')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='max-h-[200px] overflow-y-auto'>
|
||||
{successPlugins.map(successPlugin => (
|
||||
<div
|
||||
key={successPlugin.plugin_unique_identifier}
|
||||
className='flex items-center rounded-lg p-2 hover:bg-state-base-hover'
|
||||
>
|
||||
<div className='relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'>
|
||||
<RiCheckboxCircleFill className='absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-success' />
|
||||
<CardIcon
|
||||
size='tiny'
|
||||
src={getIconUrl(successPlugin.icon)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<div className='system-md-regular truncate text-text-secondary'>
|
||||
{successPlugin.labels[language]}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-success'>
|
||||
{successPlugin.message || t('plugin.task.installed')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error Plugins */}
|
||||
{errorPlugins.length > 0 && (
|
||||
<>
|
||||
<div className='system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1'>
|
||||
{t('plugin.task.installError', { errorLength: errorPlugins.length })}
|
||||
<Button
|
||||
className='shrink-0'
|
||||
size='small'
|
||||
variant='ghost'
|
||||
onClick={() => handleClearErrorsWithModal()}
|
||||
>
|
||||
{t('plugin.task.clearAll')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='max-h-[200px] overflow-y-auto'>
|
||||
{errorPlugins.map(errorPlugin => (
|
||||
<div
|
||||
key={errorPlugin.plugin_unique_identifier}
|
||||
className='flex items-center rounded-lg p-2 hover:bg-state-base-hover'
|
||||
>
|
||||
<div className='relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'>
|
||||
<RiErrorWarningFill className='absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive' />
|
||||
<CardIcon
|
||||
size='tiny'
|
||||
src={getIconUrl(errorPlugin.icon)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<div className='system-md-regular truncate text-text-secondary'>
|
||||
{errorPlugin.labels[language]}
|
||||
</div>
|
||||
<div className='system-xs-regular break-all text-text-destructive'>
|
||||
{errorPlugin.message}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className='shrink-0'
|
||||
size='small'
|
||||
variant='ghost'
|
||||
onClick={() => handleClearSingleWithModal(errorPlugin.taskId, errorPlugin.plugin_unique_identifier)}
|
||||
>
|
||||
{t('common.operation.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { useCallback } from 'react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
export type AsyncWindowOpenOptions = {
|
||||
successMessage?: string
|
||||
errorMessage?: string
|
||||
windowFeatures?: string
|
||||
onError?: (error: any) => void
|
||||
onSuccess?: (url: string) => void
|
||||
}
|
||||
|
||||
export const useAsyncWindowOpen = () => {
|
||||
const openAsync = useCallback(async (
|
||||
fetchUrl: () => Promise<string>,
|
||||
options: AsyncWindowOpenOptions = {},
|
||||
) => {
|
||||
const {
|
||||
successMessage,
|
||||
errorMessage = 'Failed to open page',
|
||||
windowFeatures = 'noopener,noreferrer',
|
||||
onError,
|
||||
onSuccess,
|
||||
} = options
|
||||
|
||||
const newWindow = window.open('', '_blank', windowFeatures)
|
||||
|
||||
if (!newWindow) {
|
||||
const error = new Error('Popup blocked by browser')
|
||||
onError?.(error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Popup blocked. Please allow popups for this site.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await fetchUrl()
|
||||
|
||||
if (url) {
|
||||
newWindow.location.href = url
|
||||
onSuccess?.(url)
|
||||
|
||||
if (successMessage) {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: successMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
newWindow.close()
|
||||
const error = new Error('Invalid URL received')
|
||||
onError?.(error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
newWindow.close()
|
||||
onError?.(error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { openAsync }
|
||||
}
|
||||
|
|
@ -230,6 +230,11 @@ const translation = {
|
|||
installing: 'Installation von {{installingLength}} Plugins, 0 erledigt.',
|
||||
installError:
|
||||
'{{errorLength}} Plugins konnten nicht installiert werden, klicken Sie hier, um sie anzusehen',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
allCategories: 'Alle Kategorien',
|
||||
install: '{{num}} Installationen',
|
||||
|
|
|
|||
|
|
@ -270,12 +270,17 @@ const translation = {
|
|||
partnerTip: 'Verified by a Dify partner',
|
||||
},
|
||||
task: {
|
||||
installing: 'Installing {{installingLength}} plugins, 0 done.',
|
||||
installing: 'Installing plugins',
|
||||
installingWithSuccess: 'Installing {{installingLength}} plugins, {{successLength}} success.',
|
||||
installingWithError: 'Installing {{installingLength}} plugins, {{successLength}} success, {{errorLength}} failed',
|
||||
installError: '{{errorLength}} plugins failed to install, click to view',
|
||||
installedError: '{{errorLength}} plugins failed to install',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
clearAll: 'Clear all',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
requestAPlugin: 'Request a plugin',
|
||||
publishPlugins: 'Publish plugins',
|
||||
|
|
|
|||
|
|
@ -230,6 +230,11 @@ const translation = {
|
|||
'Los complementos {{errorLength}} no se pudieron instalar, haga clic para ver',
|
||||
installingWithError:
|
||||
'Instalando plugins {{installingLength}}, {{successLength}} éxito, {{errorLength}} fallido',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
fromMarketplace: 'De Marketplace',
|
||||
endpointsEnabled: '{{num}} conjuntos de puntos finales habilitados',
|
||||
|
|
|
|||
|
|
@ -223,6 +223,11 @@ const translation = {
|
|||
'نصب پلاگین های {{installingLength}}، {{successLength}} موفقیت آمیز است.',
|
||||
installingWithError:
|
||||
'نصب پلاگین های {{installingLength}}، {{successLength}} با موفقیت مواجه شد، {{errorLength}} ناموفق بود',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
searchTools: 'ابزارهای جستجو...',
|
||||
findMoreInMarketplace: 'اطلاعات بیشتر در Marketplace',
|
||||
|
|
|
|||
|
|
@ -228,6 +228,11 @@ const translation = {
|
|||
installedError: '{{errorLength}} les plugins n’ont pas pu être installés',
|
||||
clearAll: 'Effacer tout',
|
||||
installing: 'Installation des plugins {{installingLength}}, 0 fait.',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
search: 'Rechercher',
|
||||
installAction: 'Installer',
|
||||
|
|
|
|||
|
|
@ -227,6 +227,11 @@ const translation = {
|
|||
'{{installingLength}} प्लगइन्स स्थापित कर रहे हैं, {{successLength}} सफल, {{errorLength}} विफल',
|
||||
installingWithSuccess:
|
||||
'{{installingLength}} प्लगइन्स स्थापित कर रहे हैं, {{successLength}} सफल।',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
installFrom: 'से इंस्टॉल करें',
|
||||
fromMarketplace: 'मार्केटप्लेस से',
|
||||
|
|
|
|||
|
|
@ -261,6 +261,11 @@ const translation = {
|
|||
installingWithError: 'Memasang {{installingLength}} plugin, {{successLength}} berhasil, {{errorLength}} gagal',
|
||||
installError: 'Gagal menginstal plugin {{errorLength}}, klik untuk melihat',
|
||||
installedError: 'Gagal menginstal {{errorLength}} plugin',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
auth: {
|
||||
customCredentialUnavailable: 'Kredensial kustom saat ini tidak tersedia',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ const translation = {
|
|||
installedError: 'Impossibile installare i plugin di {{errorLength}}',
|
||||
installingWithError: 'Installazione dei plugin {{installingLength}}, {{successLength}} successo, {{errorLength}} fallito',
|
||||
installingWithSuccess: 'Installazione dei plugin {{installingLength}}, {{successLength}} successo.',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
searchInMarketplace: 'Cerca nel Marketplace',
|
||||
endpointsEnabled: '{{num}} set di endpoint abilitati',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ const translation = {
|
|||
installedError: '{{errorLength}} プラグインのインストールに失敗しました',
|
||||
installingWithError: '{{installingLength}}個のプラグインをインストール中、{{successLength}}件成功、{{errorLength}}件失敗',
|
||||
installing: '{{installingLength}}個のプラグインをインストール中、0 個完了。',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
from: 'インストール元',
|
||||
install: '{{num}} インストール',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ const translation = {
|
|||
installingWithError: '{{installingLength}} 플러그인 설치, {{successLength}} 성공, {{errorLength}} 실패',
|
||||
installError: '{{errorLength}} 플러그인 설치 실패, 보려면 클릭하십시오.',
|
||||
clearAll: '모두 지우기',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
installAction: '설치하다',
|
||||
searchTools: '검색 도구...',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ const translation = {
|
|||
installingWithSuccess: 'Instalacja wtyczek {{installingLength}}, {{successLength}} powodzenie.',
|
||||
clearAll: 'Wyczyść wszystko',
|
||||
installingWithError: 'Instalacja wtyczek {{installingLength}}, {{successLength}} powodzenie, {{errorLength}} niepowodzenie',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
search: 'Szukać',
|
||||
installFrom: 'ZAINSTALUJ Z',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ const translation = {
|
|||
installingWithError: 'Instalando plug-ins {{installingLength}}, {{successLength}} sucesso, {{errorLength}} falhou',
|
||||
installing: 'Instalando plugins {{installingLength}}, 0 feito.',
|
||||
clearAll: 'Apagar tudo',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
installAction: 'Instalar',
|
||||
endpointsEnabled: '{{num}} conjuntos de endpoints habilitados',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ const translation = {
|
|||
installingWithError: 'Instalarea pluginurilor {{installingLength}}, {{successLength}} succes, {{errorLength}} eșuat',
|
||||
installingWithSuccess: 'Instalarea pluginurilor {{installingLength}}, {{successLength}} succes.',
|
||||
installing: 'Instalarea pluginurilor {{installingLength}}, 0 terminat.',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
fromMarketplace: 'Din Marketplace',
|
||||
from: 'Din',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ const translation = {
|
|||
installingWithSuccess: 'Установка плагинов {{installingLength}}, {{successLength}} успех.',
|
||||
installedError: 'плагины {{errorLength}} не удалось установить',
|
||||
installError: 'Плагины {{errorLength}} не удалось установить, нажмите для просмотра',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
install: '{{num}} установок',
|
||||
searchCategories: 'Поиск категорий',
|
||||
|
|
|
|||
|
|
@ -211,6 +211,11 @@ const translation = {
|
|||
installingWithSuccess: 'Namestitev {{installingLength}} dodatkov, {{successLength}} uspešnih.',
|
||||
installedError: '{{errorLength}} vtičnikov ni uspelo namestiti',
|
||||
installingWithError: 'Namestitev {{installingLength}} vtičnikov, {{successLength}} uspešnih, {{errorLength}} neuspešnih',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
endpointsEnabled: '{{num}} nizov končnih točk omogočenih',
|
||||
search: 'Iskanje',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ const translation = {
|
|||
installedError: '{{errorLength}} ปลั๊กอินติดตั้งไม่สําเร็จ',
|
||||
clearAll: 'ล้างทั้งหมด',
|
||||
installError: '{{errorLength}} ปลั๊กอินติดตั้งไม่สําเร็จ คลิกเพื่อดู',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
searchCategories: 'หมวดหมู่การค้นหา',
|
||||
searchInMarketplace: 'ค้นหาใน Marketplace',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ const translation = {
|
|||
installingWithSuccess: '{{installingLength}} eklentileri yükleniyor, {{successLength}} başarılı.',
|
||||
installError: '{{errorLength}} eklentileri yüklenemedi, görüntülemek için tıklayın',
|
||||
installingWithError: '{{installingLength}} eklentileri yükleniyor, {{successLength}} başarılı, {{errorLength}} başarısız oldu',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
allCategories: 'Tüm Kategoriler',
|
||||
installAction: 'Yüklemek',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ const translation = {
|
|||
installError: 'Плагіни {{errorLength}} не вдалося встановити, натисніть, щоб переглянути',
|
||||
installing: 'Встановлення плагінів {{installingLength}}, 0 виконано.',
|
||||
installingWithSuccess: 'Встановлення плагінів {{installingLength}}, успіх {{successLength}}.',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
from: 'Від',
|
||||
searchInMarketplace: 'Пошук у Marketplace',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ const translation = {
|
|||
installError: '{{errorLength}} plugin không cài đặt được, nhấp để xem',
|
||||
installedError: '{{errorLength}} plugin không cài đặt được',
|
||||
clearAll: 'Xóa tất cả',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
from: 'Từ',
|
||||
installAction: 'Cài đặt',
|
||||
|
|
|
|||
|
|
@ -270,12 +270,17 @@ const translation = {
|
|||
partnerTip: '此插件由 Dify 合作伙伴认证',
|
||||
},
|
||||
task: {
|
||||
installing: '{{installingLength}} 个插件安装中,0 已完成',
|
||||
installing: '正在安装插件',
|
||||
installingWithSuccess: '{{installingLength}} 个插件安装中,{{successLength}} 安装成功',
|
||||
installingWithError: '{{installingLength}} 个插件安装中,{{successLength}} 安装成功,{{errorLength}} 安装失败',
|
||||
installError: '{{errorLength}} 个插件安装失败,点击查看',
|
||||
installedError: '{{errorLength}} 个插件安装失败',
|
||||
installSuccess: '{{successLength}} 个插件安装成功',
|
||||
installed: '已安装',
|
||||
clearAll: '清除所有',
|
||||
runningPlugins: '正在安装的插件',
|
||||
successPlugins: '安装成功的插件',
|
||||
errorPlugins: '安装失败的插件',
|
||||
},
|
||||
requestAPlugin: '申请插件',
|
||||
publishPlugins: '发布插件',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ const translation = {
|
|||
installingWithSuccess: '安裝 {{installingLength}} 個插件,{{successLength}} 成功。',
|
||||
clearAll: '全部清除',
|
||||
installing: '安裝 {{installingLength}} 個插件,0 個完成。',
|
||||
installSuccess: '{{successLength}} plugins installed successfully',
|
||||
installed: 'Installed',
|
||||
runningPlugins: 'Installing Plugins',
|
||||
successPlugins: 'Successfully Installed Plugins',
|
||||
errorPlugins: 'Failed to Install Plugins',
|
||||
},
|
||||
requestAPlugin: '申请插件',
|
||||
publishPlugins: '發佈插件',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,25 @@
|
|||
import '@testing-library/jest-dom'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
|
||||
// Fix for @headlessui/react compatibility with happy-dom
|
||||
// headlessui tries to override focus properties which may be read-only in happy-dom
|
||||
if (typeof window !== 'undefined') {
|
||||
const ensureWritable = (target: object, prop: string) => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target, prop)
|
||||
if (descriptor && !descriptor.writable) {
|
||||
const original = descriptor.value ?? descriptor.get?.call(target)
|
||||
Object.defineProperty(target, prop, {
|
||||
value: typeof original === 'function' ? original : jest.fn(),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ensureWritable(window, 'focus')
|
||||
ensureWritable(HTMLElement.prototype, 'focus')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@
|
|||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
|
|
|||
|
|
@ -416,6 +416,9 @@ importers:
|
|||
'@testing-library/react':
|
||||
specifier: ^16.3.0
|
||||
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@testing-library/user-event':
|
||||
specifier: ^14.6.1
|
||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||
'@types/jest':
|
||||
specifier: ^29.5.14
|
||||
version: 29.5.14
|
||||
|
|
|
|||
|
|
@ -634,7 +634,8 @@ export const usePluginTaskList = (category?: PluginCategoryEnum | string) => {
|
|||
export const useMutationClearTaskPlugin = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ taskId, pluginId }: { taskId: string; pluginId: string }) => {
|
||||
return post<{ success: boolean }>(`/workspaces/current/plugin/tasks/${taskId}/delete/${pluginId}`)
|
||||
const encodedPluginId = encodeURIComponent(pluginId)
|
||||
return post<{ success: boolean }>(`/workspaces/current/plugin/tasks/${taskId}/delete/${encodedPluginId}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,8 +145,17 @@ Treat component state as part of the public behavior: confirm the initial render
|
|||
- ✅ When creating lightweight provider stubs, mirror the real default values and surface helper builders (for example `createMockWorkflowContext`).
|
||||
- ✅ Reset shared stores (React context, Zustand, TanStack Query cache) between tests to avoid leaking state. Prefer helper factory functions over module-level singletons in specs.
|
||||
- ✅ For hooks that read from context, use `renderHook` with a custom wrapper that supplies required providers.
|
||||
- ✅ **Use factory functions for mock data**: Import actual types and create factory functions with complete defaults (see [Test Data Builders](#9-test-data-builders-anti-hardcoding) section).
|
||||
- ✅ If it's need to mock some common context provider used across many components (for example, `ProviderContext`), put it in __mocks__/context(for example, `__mocks__/context/provider-context`). To dynamically control the mock behavior (for example, toggling plan type), use module-level variables to track state and change them(for example, `context/provider-context-mock.spec.tsx`).
|
||||
- ✅ Use factory functions to create mock data with TypeScript types. This ensures type safety and makes tests more maintainable.
|
||||
|
||||
If it's need to mock some common context provider used across many components (for example, `ProviderContext`), put it in __mocks__/context(for example, `__mocks__/context/provider-context`). To dynamically control the mock behavior (for example, toggling plan type), use module-level variables to track state and change them(for example, `context/provier-context-mock.spec.tsx`).
|
||||
**Rules**:
|
||||
|
||||
1. **Import actual types**: Always import types from the source (`@/models/`, `@/types/`, etc.) instead of defining inline types.
|
||||
1. **Provide complete defaults**: Factory functions should return complete objects with all required fields filled with sensible defaults.
|
||||
1. **Allow partial overrides**: Accept `Partial<T>` to enable flexible customization for specific test cases.
|
||||
1. **Create list factories**: For array data, create a separate factory function that composes item factories.
|
||||
1. **Reference**: See `__mocks__/provider-context.ts` for reusable context mock factories used across multiple test files.
|
||||
|
||||
### 4. Performance Optimization
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue