Merge branch 'main' into fix/upload-restrictions

This commit is contained in:
twwu 2025-12-10 15:32:14 +08:00
commit 0404007982
39 changed files with 1888 additions and 145 deletions

View File

@ -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

View File

@ -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}"}

View File

@ -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

View File

@ -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

View File

@ -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(),

View File

@ -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)

View File

@ -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}>

View File

@ -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
}

View File

@ -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

View File

@ -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,
}
}

View File

@ -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>

View File

@ -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 }
}

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -228,6 +228,11 @@ const translation = {
installedError: '{{errorLength}} les plugins nont 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',

View File

@ -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: 'मार्केटप्लेस से',

View File

@ -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',

View File

@ -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',

View File

@ -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}} インストール',

View File

@ -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: '검색 도구...',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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: 'Поиск категорий',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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: '发布插件',

View File

@ -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: '發佈插件',

View File

@ -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()
})

View File

@ -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",

View File

@ -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

View File

@ -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}`)
},
})
}

View File

@ -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