mirror of
https://github.com/langgenius/dify.git
synced 2026-04-14 16:08:01 +08:00
Merge branch 'main' into feat/enchance-warn-user-time-when-need-upgrade-plan
This commit is contained in:
commit
5891731ab2
8
.github/CODEOWNERS
vendored
8
.github/CODEOWNERS
vendored
@ -9,6 +9,14 @@
|
||||
# Backend (default owner, more specific rules below will override)
|
||||
api/ @QuantumGhost
|
||||
|
||||
# Backend - MCP
|
||||
api/core/mcp/ @Nov1c444
|
||||
api/core/entities/mcp_provider.py @Nov1c444
|
||||
api/services/tools/mcp_tools_manage_service.py @Nov1c444
|
||||
api/controllers/mcp/ @Nov1c444
|
||||
api/controllers/console/app/mcp_server.py @Nov1c444
|
||||
api/tests/**/*mcp* @Nov1c444
|
||||
|
||||
# Backend - Workflow - Engine (Core graph execution engine)
|
||||
api/core/workflow/graph_engine/ @laipz8200 @QuantumGhost
|
||||
api/core/workflow/runtime/ @laipz8200 @QuantumGhost
|
||||
|
||||
14
.github/ISSUE_TEMPLATE/refactor.yml
vendored
14
.github/ISSUE_TEMPLATE/refactor.yml
vendored
@ -1,8 +1,6 @@
|
||||
name: "✨ Refactor"
|
||||
description: Refactor existing code for improved readability and maintainability.
|
||||
title: "[Chore/Refactor] "
|
||||
labels:
|
||||
- refactor
|
||||
name: "✨ Refactor or Chore"
|
||||
description: Refactor existing code or perform maintenance chores to improve readability and reliability.
|
||||
title: "[Refactor/Chore] "
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
@ -11,7 +9,7 @@ body:
|
||||
options:
|
||||
- label: I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
|
||||
required: true
|
||||
- label: This is only for refactoring, if you would like to ask a question, please head to [Discussions](https://github.com/langgenius/dify/discussions/categories/general).
|
||||
- label: This is only for refactors or chores; if you would like to ask a question, please head to [Discussions](https://github.com/langgenius/dify/discussions/categories/general).
|
||||
required: true
|
||||
- label: I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
|
||||
required: true
|
||||
@ -25,14 +23,14 @@ body:
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
placeholder: "Describe the refactor you are proposing."
|
||||
placeholder: "Describe the refactor or chore you are proposing."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation
|
||||
placeholder: "Explain why this refactor is necessary."
|
||||
placeholder: "Explain why this refactor or chore is necessary."
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
13
.github/ISSUE_TEMPLATE/tracker.yml
vendored
13
.github/ISSUE_TEMPLATE/tracker.yml
vendored
@ -1,13 +0,0 @@
|
||||
name: "👾 Tracker"
|
||||
description: For inner usages, please do not use this template.
|
||||
title: "[Tracker] "
|
||||
labels:
|
||||
- tracker
|
||||
body:
|
||||
- type: textarea
|
||||
id: content
|
||||
attributes:
|
||||
label: Blockers
|
||||
placeholder: "- [ ] ..."
|
||||
validations:
|
||||
required: true
|
||||
@ -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,
|
||||
@ -483,7 +476,7 @@ class RetrievalService:
|
||||
DocumentSegment.status == "completed",
|
||||
DocumentSegment.id == segment_id,
|
||||
)
|
||||
segment = db.session.scalar(document_segment_stmt)
|
||||
segment = session.scalar(document_segment_stmt)
|
||||
if segment:
|
||||
segment_file_map[segment.id] = [attachment_info]
|
||||
else:
|
||||
@ -496,7 +489,7 @@ class RetrievalService:
|
||||
DocumentSegment.status == "completed",
|
||||
DocumentSegment.index_node_id == index_node_id,
|
||||
)
|
||||
segment = db.session.scalar(document_segment_stmt)
|
||||
segment = session.scalar(document_segment_stmt)
|
||||
|
||||
if not segment:
|
||||
continue
|
||||
@ -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
|
||||
|
||||
@ -334,6 +334,7 @@ class LLMNode(Node[LLMNodeData]):
|
||||
inputs=node_inputs,
|
||||
process_data=process_data,
|
||||
error_type=type(e).__name__,
|
||||
llm_usage=usage,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
@ -344,6 +345,8 @@ class LLMNode(Node[LLMNodeData]):
|
||||
error=str(e),
|
||||
inputs=node_inputs,
|
||||
process_data=process_data,
|
||||
error_type=type(e).__name__,
|
||||
llm_usage=usage,
|
||||
)
|
||||
)
|
||||
|
||||
@ -694,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,
|
||||
@ -708,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(),
|
||||
|
||||
@ -221,6 +221,7 @@ class QuestionClassifierNode(Node[QuestionClassifierNodeData]):
|
||||
status=WorkflowNodeExecutionStatus.FAILED,
|
||||
inputs=variables,
|
||||
error=str(e),
|
||||
error_type=type(e).__name__,
|
||||
metadata={
|
||||
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens,
|
||||
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price,
|
||||
|
||||
@ -78,7 +78,7 @@ class Dataset(Base):
|
||||
pipeline_id = mapped_column(StringUUID, nullable=True)
|
||||
chunk_structure = mapped_column(sa.String(255), nullable=True)
|
||||
enable_api = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"))
|
||||
is_multimodal = mapped_column(sa.Boolean, nullable=False, server_default=db.text("false"))
|
||||
is_multimodal = mapped_column(sa.Boolean, default=False, nullable=False, server_default=db.text("false"))
|
||||
|
||||
@property
|
||||
def total_documents(self):
|
||||
|
||||
@ -259,7 +259,7 @@ class App(Base):
|
||||
provider_id = tool.get("provider_id", "")
|
||||
|
||||
if provider_type == ToolProviderType.API:
|
||||
if uuid.UUID(provider_id) not in existing_api_providers:
|
||||
if provider_id not in existing_api_providers:
|
||||
deleted_tools.append(
|
||||
{
|
||||
"type": ToolProviderType.API,
|
||||
|
||||
@ -907,19 +907,29 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo
|
||||
@property
|
||||
def extras(self) -> dict[str, Any]:
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from core.trigger.trigger_manager import TriggerManager
|
||||
|
||||
extras: dict[str, Any] = {}
|
||||
if self.execution_metadata_dict:
|
||||
if self.node_type == NodeType.TOOL and "tool_info" in self.execution_metadata_dict:
|
||||
tool_info: dict[str, Any] = self.execution_metadata_dict["tool_info"]
|
||||
execution_metadata = self.execution_metadata_dict
|
||||
if execution_metadata:
|
||||
if self.node_type == NodeType.TOOL and "tool_info" in execution_metadata:
|
||||
tool_info: dict[str, Any] = execution_metadata["tool_info"]
|
||||
extras["icon"] = ToolManager.get_tool_icon(
|
||||
tenant_id=self.tenant_id,
|
||||
provider_type=tool_info["provider_type"],
|
||||
provider_id=tool_info["provider_id"],
|
||||
)
|
||||
elif self.node_type == NodeType.DATASOURCE and "datasource_info" in self.execution_metadata_dict:
|
||||
datasource_info = self.execution_metadata_dict["datasource_info"]
|
||||
elif self.node_type == NodeType.DATASOURCE and "datasource_info" in execution_metadata:
|
||||
datasource_info = execution_metadata["datasource_info"]
|
||||
extras["icon"] = datasource_info.get("icon")
|
||||
elif self.node_type == NodeType.TRIGGER_PLUGIN and "trigger_info" in execution_metadata:
|
||||
trigger_info = execution_metadata["trigger_info"] or {}
|
||||
provider_id = trigger_info.get("provider_id")
|
||||
if provider_id:
|
||||
extras["icon"] = TriggerManager.get_trigger_plugin_icon(
|
||||
tenant_id=self.tenant_id,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
return extras
|
||||
|
||||
def _get_offload_by_type(self, type_: ExecutionOffLoadType) -> Optional["WorkflowNodeExecutionOffload"]:
|
||||
|
||||
@ -1129,6 +1129,9 @@ WEAVIATE_AUTHENTICATION_APIKEY_USERS=hello@dify.ai
|
||||
WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED=true
|
||||
WEAVIATE_AUTHORIZATION_ADMINLIST_USERS=hello@dify.ai
|
||||
WEAVIATE_DISABLE_TELEMETRY=false
|
||||
WEAVIATE_ENABLE_TOKENIZER_GSE=false
|
||||
WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA=false
|
||||
WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR=false
|
||||
|
||||
# ------------------------------
|
||||
# Environment Variables for Chroma
|
||||
|
||||
@ -451,6 +451,9 @@ services:
|
||||
AUTHORIZATION_ADMINLIST_ENABLED: ${WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED:-true}
|
||||
AUTHORIZATION_ADMINLIST_USERS: ${WEAVIATE_AUTHORIZATION_ADMINLIST_USERS:-hello@dify.ai}
|
||||
DISABLE_TELEMETRY: ${WEAVIATE_DISABLE_TELEMETRY:-false}
|
||||
ENABLE_TOKENIZER_GSE: ${WEAVIATE_ENABLE_TOKENIZER_GSE:-false}
|
||||
ENABLE_TOKENIZER_KAGOME_JA: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA:-false}
|
||||
ENABLE_TOKENIZER_KAGOME_KR: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR:-false}
|
||||
|
||||
# OceanBase vector database
|
||||
oceanbase:
|
||||
|
||||
@ -479,6 +479,9 @@ x-shared-env: &shared-api-worker-env
|
||||
WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED: ${WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED:-true}
|
||||
WEAVIATE_AUTHORIZATION_ADMINLIST_USERS: ${WEAVIATE_AUTHORIZATION_ADMINLIST_USERS:-hello@dify.ai}
|
||||
WEAVIATE_DISABLE_TELEMETRY: ${WEAVIATE_DISABLE_TELEMETRY:-false}
|
||||
WEAVIATE_ENABLE_TOKENIZER_GSE: ${WEAVIATE_ENABLE_TOKENIZER_GSE:-false}
|
||||
WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA:-false}
|
||||
WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR:-false}
|
||||
CHROMA_SERVER_AUTHN_CREDENTIALS: ${CHROMA_SERVER_AUTHN_CREDENTIALS:-difyai123456}
|
||||
CHROMA_SERVER_AUTHN_PROVIDER: ${CHROMA_SERVER_AUTHN_PROVIDER:-chromadb.auth.token_authn.TokenAuthenticationServerProvider}
|
||||
CHROMA_IS_PERSISTENT: ${CHROMA_IS_PERSISTENT:-TRUE}
|
||||
@ -1085,6 +1088,9 @@ services:
|
||||
AUTHORIZATION_ADMINLIST_ENABLED: ${WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED:-true}
|
||||
AUTHORIZATION_ADMINLIST_USERS: ${WEAVIATE_AUTHORIZATION_ADMINLIST_USERS:-hello@dify.ai}
|
||||
DISABLE_TELEMETRY: ${WEAVIATE_DISABLE_TELEMETRY:-false}
|
||||
ENABLE_TOKENIZER_GSE: ${WEAVIATE_ENABLE_TOKENIZER_GSE:-false}
|
||||
ENABLE_TOKENIZER_KAGOME_JA: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA:-false}
|
||||
ENABLE_TOKENIZER_KAGOME_KR: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR:-false}
|
||||
|
||||
# OceanBase vector database
|
||||
oceanbase:
|
||||
|
||||
@ -42,6 +42,7 @@ import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
@ -153,6 +154,7 @@ const AppPublisher = ({
|
||||
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
|
||||
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
|
||||
@ -217,17 +219,19 @@ const AppPublisher = ({
|
||||
}, [disabled, onToggle, open])
|
||||
|
||||
const handleOpenInExplore = useCallback(async () => {
|
||||
try {
|
||||
await openAsyncWindow(async () => {
|
||||
if (!appDetail?.id)
|
||||
throw new Error('App not found')
|
||||
const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {}
|
||||
if (installed_apps?.length > 0)
|
||||
window.open(`${basePath}/explore/installed/${installed_apps[0].id}`, '_blank')
|
||||
else
|
||||
throw new Error('No app found in Explore')
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: `${e.message || e}` })
|
||||
}
|
||||
}, [appDetail?.id])
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
throw new Error('No app found in Explore')
|
||||
}, {
|
||||
onError: (err) => {
|
||||
Toast.notify({ type: 'error', message: `${err.message || err}` })
|
||||
},
|
||||
})
|
||||
}, [appDetail?.id, openAsyncWindow])
|
||||
|
||||
const handleAccessControlUpdate = useCallback(async () => {
|
||||
if (!appDetail)
|
||||
|
||||
@ -27,6 +27,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { AppTypeIcon } from '@/app/components/app/type-selector'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { formatTime } from '@/utils/time'
|
||||
@ -64,6 +65,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { onPlanInfoChanged } = useProviderContext()
|
||||
const { push } = useRouter()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
|
||||
@ -247,11 +249,16 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
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
|
||||
await openAsyncWindow(async () => {
|
||||
const { installed_apps }: any = await fetchInstalledAppList(app.id) || {}
|
||||
if (installed_apps?.length > 0)
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
throw new Error('No app found in Explore')
|
||||
}, {
|
||||
onError: (err) => {
|
||||
Toast.notify({ type: 'error', message: `${err.message || err}` })
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: `${e.message || e}` })
|
||||
|
||||
@ -21,6 +21,7 @@ type NotionPageSelectorProps = {
|
||||
datasetId?: string
|
||||
credentialList: DataSourceCredential[]
|
||||
onSelectCredential?: (credentialId: string) => void
|
||||
supportBatchUpload?: boolean
|
||||
}
|
||||
|
||||
const NotionPageSelector = ({
|
||||
@ -32,6 +33,7 @@ const NotionPageSelector = ({
|
||||
datasetId = '',
|
||||
credentialList,
|
||||
onSelectCredential,
|
||||
supportBatchUpload = false,
|
||||
}: NotionPageSelectorProps) => {
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
||||
@ -110,7 +112,7 @@ const NotionPageSelector = ({
|
||||
setCurrentCredential(credential)
|
||||
onSelect([]) // Clear selected pages when changing credential
|
||||
onSelectCredential?.(credential.credentialId)
|
||||
}, [invalidPreImportNotionPages, onSelect, onSelectCredential])
|
||||
}, [datasetId, invalidPreImportNotionPages, notionCredentials, onSelect, onSelectCredential])
|
||||
|
||||
const handleSelectPages = useCallback((newSelectedPagesId: Set<string>) => {
|
||||
const selectedPages = Array.from(newSelectedPagesId).map(pageId => pagesMapAndSelectedPagesId[0][pageId])
|
||||
@ -175,6 +177,7 @@ const NotionPageSelector = ({
|
||||
canPreview={canPreview}
|
||||
previewPageId={previewPageId}
|
||||
onPreview={handlePreviewPage}
|
||||
isMultipleChoice={supportBatchUpload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import NotionIcon from '../../notion-icon'
|
||||
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
|
||||
|
||||
export type NotionCredential = {
|
||||
credentialId: string
|
||||
@ -23,14 +22,10 @@ const CredentialSelector = ({
|
||||
items,
|
||||
onSelect,
|
||||
}: CredentialSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const currentCredential = items.find(item => item.credentialId === value)!
|
||||
|
||||
const getDisplayName = (item: NotionCredential) => {
|
||||
return item.workspaceName || t('datasetPipeline.credentialSelector.name', {
|
||||
credentialName: item.credentialName,
|
||||
pluginName: 'Notion',
|
||||
})
|
||||
return item.workspaceName || item.credentialName
|
||||
}
|
||||
|
||||
const currentDisplayName = useMemo(() => {
|
||||
@ -43,10 +38,11 @@ const CredentialSelector = ({
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={`flex h-7 items-center justify-center rounded-md p-1 pr-2 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} cursor-pointer`}>
|
||||
<NotionIcon
|
||||
<CredentialIcon
|
||||
className='mr-2'
|
||||
src={currentCredential?.workspaceIcon}
|
||||
avatarUrl={currentCredential?.workspaceIcon}
|
||||
name={currentDisplayName}
|
||||
size={20}
|
||||
/>
|
||||
<div
|
||||
className='mr-1 w-[90px] truncate text-left text-sm font-medium text-text-secondary'
|
||||
@ -80,10 +76,11 @@ const CredentialSelector = ({
|
||||
className='flex h-9 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover'
|
||||
onClick={() => onSelect(item.credentialId)}
|
||||
>
|
||||
<NotionIcon
|
||||
<CredentialIcon
|
||||
className='mr-2 shrink-0'
|
||||
src={item.workspaceIcon}
|
||||
avatarUrl={item.workspaceIcon}
|
||||
name={displayName}
|
||||
size={20}
|
||||
/>
|
||||
<div
|
||||
className='system-sm-medium mr-2 grow truncate text-text-secondary'
|
||||
|
||||
@ -7,6 +7,7 @@ import Checkbox from '../../checkbox'
|
||||
import NotionIcon from '../../notion-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
|
||||
type PageSelectorProps = {
|
||||
value: Set<string>
|
||||
@ -18,6 +19,7 @@ type PageSelectorProps = {
|
||||
canPreview?: boolean
|
||||
previewPageId?: string
|
||||
onPreview?: (selectedPageId: string) => void
|
||||
isMultipleChoice?: boolean
|
||||
}
|
||||
type NotionPageTreeItem = {
|
||||
children: Set<string>
|
||||
@ -80,6 +82,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
|
||||
searchValue: string
|
||||
previewPageId: string
|
||||
pagesMap: DataSourceNotionPageMap
|
||||
isMultipleChoice?: boolean
|
||||
}>) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
@ -94,6 +97,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
|
||||
searchValue,
|
||||
previewPageId,
|
||||
pagesMap,
|
||||
isMultipleChoice,
|
||||
} = data
|
||||
const current = dataList[index]
|
||||
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id]
|
||||
@ -134,16 +138,24 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
|
||||
previewPageId === current.page_id && 'bg-state-base-hover')}
|
||||
style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }}
|
||||
>
|
||||
<Checkbox
|
||||
className='mr-2 shrink-0'
|
||||
checked={checkedIds.has(current.page_id)}
|
||||
disabled={disabled}
|
||||
onCheck={() => {
|
||||
if (disabled)
|
||||
return
|
||||
handleCheck(index)
|
||||
}}
|
||||
/>
|
||||
{isMultipleChoice ? (
|
||||
<Checkbox
|
||||
className='mr-2 shrink-0'
|
||||
checked={checkedIds.has(current.page_id)}
|
||||
disabled={disabled}
|
||||
onCheck={() => {
|
||||
handleCheck(index)
|
||||
}}
|
||||
/>) : (
|
||||
<Radio
|
||||
className='mr-2 shrink-0'
|
||||
isChecked={checkedIds.has(current.page_id)}
|
||||
disabled={disabled}
|
||||
onCheck={() => {
|
||||
handleCheck(index)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!searchValue && renderArrow()}
|
||||
<NotionIcon
|
||||
className='mr-1 shrink-0'
|
||||
@ -192,6 +204,7 @@ const PageSelector = ({
|
||||
canPreview = true,
|
||||
previewPageId,
|
||||
onPreview,
|
||||
isMultipleChoice = true,
|
||||
}: PageSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [dataList, setDataList] = useState<NotionPageItem[]>([])
|
||||
@ -265,7 +278,7 @@ const PageSelector = ({
|
||||
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
|
||||
|
||||
if (copyValue.has(pageId)) {
|
||||
if (!searchValue) {
|
||||
if (!searchValue && isMultipleChoice) {
|
||||
for (const item of currentWithChildrenAndDescendants.descendants)
|
||||
copyValue.delete(item)
|
||||
}
|
||||
@ -273,12 +286,18 @@ const PageSelector = ({
|
||||
copyValue.delete(pageId)
|
||||
}
|
||||
else {
|
||||
if (!searchValue) {
|
||||
if (!searchValue && isMultipleChoice) {
|
||||
for (const item of currentWithChildrenAndDescendants.descendants)
|
||||
copyValue.add(item)
|
||||
}
|
||||
|
||||
copyValue.add(pageId)
|
||||
// Single choice mode, clear previous selection
|
||||
if (!isMultipleChoice && copyValue.size > 0) {
|
||||
copyValue.clear()
|
||||
copyValue.add(pageId)
|
||||
}
|
||||
else {
|
||||
copyValue.add(pageId)
|
||||
}
|
||||
}
|
||||
|
||||
onSelect(new Set(copyValue))
|
||||
@ -322,6 +341,7 @@ const PageSelector = ({
|
||||
searchValue,
|
||||
previewPageId: currentPreviewPageId,
|
||||
pagesMap,
|
||||
isMultipleChoice,
|
||||
}}
|
||||
>
|
||||
{Item}
|
||||
|
||||
@ -9,33 +9,28 @@ import PlanComp from '../plan'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useBillingUrl } from '@/service/use-billing'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
|
||||
const Billing: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const { enableBilling } = useProviderContext()
|
||||
const { data: billingUrl, isFetching, refetch } = useBillingUrl(enableBilling && isCurrentWorkspaceManager)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const handleOpenBilling = async () => {
|
||||
// Open synchronously to preserve user gesture for popup blockers
|
||||
if (billingUrl) {
|
||||
window.open(billingUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
const newWindow = window.open('', '_blank', 'noopener,noreferrer')
|
||||
try {
|
||||
await openAsyncWindow(async () => {
|
||||
const url = (await refetch()).data
|
||||
if (url && newWindow) {
|
||||
newWindow.location.href = url
|
||||
return
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to fetch billing url', err)
|
||||
}
|
||||
// Close the placeholder window if we failed to fetch the URL
|
||||
newWindow?.close()
|
||||
if (url)
|
||||
return url
|
||||
return null
|
||||
}, {
|
||||
immediateUrl: billingUrl,
|
||||
features: 'noopener,noreferrer',
|
||||
onError: (err) => {
|
||||
console.error('Failed to fetch billing url', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -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'
|
||||
@ -42,6 +43,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
||||
const isCurrentPaidPlan = isCurrent && !isFreePlan
|
||||
const isPlanDisabled = isCurrentPaidPlan ? false : planInfo.level <= ALL_PLANS[currentPlan].level
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const btnText = useMemo(() => {
|
||||
if (isCurrent)
|
||||
@ -72,8 +74,16 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
||||
setLoading(true)
|
||||
try {
|
||||
if (isCurrentPaidPlan) {
|
||||
const res = await fetchBillingUrl()
|
||||
window.open(res.url, '_blank')
|
||||
await openAsyncWindow(async () => {
|
||||
const res = await fetchBillingUrl()
|
||||
if (res.url)
|
||||
return res.url
|
||||
throw new Error('Failed to open billing page')
|
||||
}, {
|
||||
onError: (err) => {
|
||||
Toast.notify({ type: 'error', message: err.message || String(err) })
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import cn from '@/utils/classnames'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
type CredentialIconProps = {
|
||||
avatar_url?: string
|
||||
avatarUrl?: string
|
||||
name: string
|
||||
size?: number
|
||||
className?: string
|
||||
@ -16,12 +16,12 @@ const ICON_BG_COLORS = [
|
||||
]
|
||||
|
||||
export const CredentialIcon: React.FC<CredentialIconProps> = ({
|
||||
avatar_url,
|
||||
avatarUrl,
|
||||
name,
|
||||
size = 20,
|
||||
className = '',
|
||||
}) => {
|
||||
const [showAvatar, setShowAvatar] = useState(!!avatar_url && avatar_url !== 'default')
|
||||
const [showAvatar, setShowAvatar] = useState(!!avatarUrl && avatarUrl !== 'default')
|
||||
const firstLetter = useMemo(() => name.charAt(0).toUpperCase(), [name])
|
||||
const bgColor = useMemo(() => ICON_BG_COLORS[firstLetter.charCodeAt(0) % ICON_BG_COLORS.length], [firstLetter])
|
||||
|
||||
@ -29,17 +29,20 @@ export const CredentialIcon: React.FC<CredentialIconProps> = ({
|
||||
setShowAvatar(false)
|
||||
}, [])
|
||||
|
||||
if (avatar_url && avatar_url !== 'default' && showAvatar) {
|
||||
if (avatarUrl && avatarUrl !== 'default' && showAvatar) {
|
||||
return (
|
||||
<div
|
||||
className='flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-divider-regular'
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-divider-regular',
|
||||
className,
|
||||
)}
|
||||
style={{ width: `${size}px`, height: `${size}px` }}
|
||||
>
|
||||
<img
|
||||
src={avatar_url}
|
||||
src={avatarUrl}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn('shrink-0 object-contain', className)}
|
||||
className='shrink-0 object-contain'
|
||||
onError={onImgLoadError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -25,7 +25,7 @@ type IFileUploaderProps = {
|
||||
onFileUpdate: (fileItem: FileItem, progress: number, list: FileItem[]) => void
|
||||
onFileListUpdate?: (files: FileItem[]) => void
|
||||
onPreview: (file: File) => void
|
||||
notSupportBatchUpload?: boolean
|
||||
supportBatchUpload?: boolean
|
||||
}
|
||||
|
||||
const FileUploader = ({
|
||||
@ -35,7 +35,7 @@ const FileUploader = ({
|
||||
onFileUpdate,
|
||||
onFileListUpdate,
|
||||
onPreview,
|
||||
notSupportBatchUpload,
|
||||
supportBatchUpload = false,
|
||||
}: IFileUploaderProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
@ -44,7 +44,7 @@ const FileUploader = ({
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
const dragRef = useRef<HTMLDivElement>(null)
|
||||
const fileUploader = useRef<HTMLInputElement>(null)
|
||||
const hideUpload = notSupportBatchUpload && fileList.length > 0
|
||||
const hideUpload = !supportBatchUpload && fileList.length > 0
|
||||
|
||||
const { data: fileUploadConfigResponse } = useFileUploadConfig()
|
||||
const { data: supportFileTypesResponse } = useFileSupportTypes()
|
||||
@ -68,9 +68,9 @@ const FileUploader = ({
|
||||
const ACCEPTS = supportTypes.map((ext: string) => `.${ext}`)
|
||||
const fileUploadConfig = useMemo(() => ({
|
||||
file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15,
|
||||
batch_count_limit: fileUploadConfigResponse?.batch_count_limit ?? 5,
|
||||
file_upload_limit: fileUploadConfigResponse?.file_upload_limit ?? 5,
|
||||
}), [fileUploadConfigResponse])
|
||||
batch_count_limit: supportBatchUpload ? (fileUploadConfigResponse?.batch_count_limit ?? 5) : 1,
|
||||
file_upload_limit: supportBatchUpload ? (fileUploadConfigResponse?.file_upload_limit ?? 5) : 1,
|
||||
}), [fileUploadConfigResponse, supportBatchUpload])
|
||||
|
||||
const fileListRef = useRef<FileItem[]>([])
|
||||
|
||||
@ -254,12 +254,12 @@ const FileUploader = ({
|
||||
}),
|
||||
)
|
||||
let files = nested.flat()
|
||||
if (notSupportBatchUpload) files = files.slice(0, 1)
|
||||
if (!supportBatchUpload) files = files.slice(0, 1)
|
||||
files = files.slice(0, fileUploadConfig.batch_count_limit)
|
||||
const valid = files.filter(isValid)
|
||||
initialUpload(valid)
|
||||
},
|
||||
[initialUpload, isValid, notSupportBatchUpload, traverseFileEntry, fileUploadConfig],
|
||||
[initialUpload, isValid, supportBatchUpload, traverseFileEntry, fileUploadConfig],
|
||||
)
|
||||
const selectHandle = () => {
|
||||
if (fileUploader.current)
|
||||
@ -303,7 +303,7 @@ const FileUploader = ({
|
||||
id="fileUploader"
|
||||
className="hidden"
|
||||
type="file"
|
||||
multiple={!notSupportBatchUpload}
|
||||
multiple={supportBatchUpload}
|
||||
accept={ACCEPTS.join(',')}
|
||||
onChange={fileChangeHandle}
|
||||
/>
|
||||
@ -317,7 +317,7 @@ const FileUploader = ({
|
||||
<RiUploadCloud2Line className='mr-2 size-5' />
|
||||
|
||||
<span>
|
||||
{notSupportBatchUpload ? t('datasetCreation.stepOne.uploader.buttonSingleFile') : t('datasetCreation.stepOne.uploader.button')}
|
||||
{supportBatchUpload ? t('datasetCreation.stepOne.uploader.button') : t('datasetCreation.stepOne.uploader.buttonSingleFile')}
|
||||
{supportTypes.length > 0 && (
|
||||
<label className="ml-1 cursor-pointer text-text-accent" onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.browse')}</label>
|
||||
)}
|
||||
@ -326,7 +326,7 @@ const FileUploader = ({
|
||||
<div>{t('datasetCreation.stepOne.uploader.tip', {
|
||||
size: fileUploadConfig.file_size_limit,
|
||||
supportTypes: supportTypesShowNames,
|
||||
batchCount: notSupportBatchUpload ? 1 : fileUploadConfig.batch_count_limit,
|
||||
batchCount: fileUploadConfig.batch_count_limit,
|
||||
totalCount: fileUploadConfig.file_upload_limit,
|
||||
})}</div>
|
||||
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
|
||||
|
||||
@ -259,7 +259,7 @@ const StepOne = ({
|
||||
onFileListUpdate={updateFileList}
|
||||
onFileUpdate={updateFile}
|
||||
onPreview={updateCurrentFile}
|
||||
notSupportBatchUpload={notSupportBatchUpload}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
{isShowVectorSpaceFull && (
|
||||
<div className='mb-4 max-w-[640px]'>
|
||||
@ -297,6 +297,7 @@ const StepOne = ({
|
||||
credentialList={notionCredentialList}
|
||||
onSelectCredential={updateNotionCredentialId}
|
||||
datasetId={datasetId}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
</div>
|
||||
{isShowVectorSpaceFull && (
|
||||
|
||||
@ -6,6 +6,7 @@ import cn from '@/utils/classnames'
|
||||
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
|
||||
type Props = {
|
||||
payload: CrawlResultItemType
|
||||
@ -13,6 +14,7 @@ type Props = {
|
||||
isPreview: boolean
|
||||
onCheckChange: (checked: boolean) => void
|
||||
onPreview: () => void
|
||||
isMultipleChoice: boolean
|
||||
}
|
||||
|
||||
const CrawledResultItem: FC<Props> = ({
|
||||
@ -21,6 +23,7 @@ const CrawledResultItem: FC<Props> = ({
|
||||
isChecked,
|
||||
onCheckChange,
|
||||
onPreview,
|
||||
isMultipleChoice,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -31,7 +34,21 @@ const CrawledResultItem: FC<Props> = ({
|
||||
<div className={cn(isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover', 'cursor-pointer rounded-lg p-2')}>
|
||||
<div className='relative flex'>
|
||||
<div className='flex h-5 items-center'>
|
||||
<Checkbox className='mr-2 shrink-0' checked={isChecked} onCheck={handleCheckChange} />
|
||||
{
|
||||
isMultipleChoice ? (
|
||||
<Checkbox
|
||||
className='mr-2 shrink-0'
|
||||
checked={isChecked}
|
||||
onCheck={handleCheckChange}
|
||||
/>
|
||||
) : (
|
||||
<Radio
|
||||
className='mr-2 shrink-0'
|
||||
isChecked={isChecked}
|
||||
onCheck={handleCheckChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className='flex min-w-0 grow flex-col'>
|
||||
<div
|
||||
|
||||
@ -16,6 +16,7 @@ type Props = {
|
||||
onSelectedChange: (selected: CrawlResultItem[]) => void
|
||||
onPreview: (payload: CrawlResultItem) => void
|
||||
usedTime: number
|
||||
isMultipleChoice: boolean
|
||||
}
|
||||
|
||||
const CrawledResult: FC<Props> = ({
|
||||
@ -25,6 +26,7 @@ const CrawledResult: FC<Props> = ({
|
||||
onSelectedChange,
|
||||
onPreview,
|
||||
usedTime,
|
||||
isMultipleChoice,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -40,13 +42,17 @@ const CrawledResult: FC<Props> = ({
|
||||
|
||||
const handleItemCheckChange = useCallback((item: CrawlResultItem) => {
|
||||
return (checked: boolean) => {
|
||||
if (checked)
|
||||
onSelectedChange([...checkedList, item])
|
||||
|
||||
else
|
||||
if (checked) {
|
||||
if (isMultipleChoice)
|
||||
onSelectedChange([...checkedList, item])
|
||||
else
|
||||
onSelectedChange([item])
|
||||
}
|
||||
else {
|
||||
onSelectedChange(checkedList.filter(checkedItem => checkedItem.source_url !== item.source_url))
|
||||
}
|
||||
}
|
||||
}, [checkedList, onSelectedChange])
|
||||
}, [checkedList, isMultipleChoice, onSelectedChange])
|
||||
|
||||
const [previewIndex, setPreviewIndex] = React.useState<number>(-1)
|
||||
const handlePreview = useCallback((index: number) => {
|
||||
@ -59,11 +65,13 @@ const CrawledResult: FC<Props> = ({
|
||||
return (
|
||||
<div className={cn(className, 'border-t-[0.5px] border-divider-regular shadow-xs shadow-shadow-shadow-3')}>
|
||||
<div className='flex h-[34px] items-center justify-between px-4'>
|
||||
<CheckboxWithLabel
|
||||
isChecked={isCheckAll}
|
||||
onChange={handleCheckedAll} label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
|
||||
labelClassName='system-[13px] leading-[16px] font-medium text-text-secondary'
|
||||
/>
|
||||
{isMultipleChoice && (
|
||||
<CheckboxWithLabel
|
||||
isChecked={isCheckAll}
|
||||
onChange={handleCheckedAll} label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
|
||||
labelClassName='system-[13px] leading-[16px] font-medium text-text-secondary'
|
||||
/>
|
||||
)}
|
||||
<div className='text-xs text-text-tertiary'>
|
||||
{t(`${I18N_PREFIX}.scrapTimeInfo`, {
|
||||
total: list.length,
|
||||
@ -80,6 +88,7 @@ const CrawledResult: FC<Props> = ({
|
||||
payload={item}
|
||||
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
|
||||
onCheckChange={handleItemCheckChange(item)}
|
||||
isMultipleChoice={isMultipleChoice}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -26,6 +26,7 @@ type Props = {
|
||||
onJobIdChange: (jobId: string) => void
|
||||
crawlOptions: CrawlOptions
|
||||
onCrawlOptionsChange: (payload: CrawlOptions) => void
|
||||
supportBatchUpload: boolean
|
||||
}
|
||||
|
||||
enum Step {
|
||||
@ -41,6 +42,7 @@ const FireCrawl: FC<Props> = ({
|
||||
onJobIdChange,
|
||||
crawlOptions,
|
||||
onCrawlOptionsChange,
|
||||
supportBatchUpload,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState<Step>(Step.init)
|
||||
@ -171,7 +173,7 @@ const FireCrawl: FC<Props> = ({
|
||||
content: item.markdown,
|
||||
}))
|
||||
setCrawlResult(data)
|
||||
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
|
||||
onCheckedCrawlResultChange(supportBatchUpload ? (data.data || []) : (data.data?.slice(0, 1) || [])) // default select the crawl result
|
||||
setCrawlErrorMessage('')
|
||||
}
|
||||
}
|
||||
@ -182,7 +184,7 @@ const FireCrawl: FC<Props> = ({
|
||||
finally {
|
||||
setStep(Step.finished)
|
||||
}
|
||||
}, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished, onCheckedCrawlResultChange])
|
||||
}, [checkValid, crawlOptions, onJobIdChange, waitForCrawlFinished, t, onCheckedCrawlResultChange, supportBatchUpload])
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -221,6 +223,7 @@ const FireCrawl: FC<Props> = ({
|
||||
onSelectedChange={onCheckedCrawlResultChange}
|
||||
onPreview={onPreview}
|
||||
usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0}
|
||||
isMultipleChoice={supportBatchUpload}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -24,6 +24,7 @@ type Props = {
|
||||
crawlOptions: CrawlOptions
|
||||
onCrawlOptionsChange: (payload: CrawlOptions) => void
|
||||
authedDataSourceList: DataSourceAuth[]
|
||||
supportBatchUpload?: boolean
|
||||
}
|
||||
|
||||
const Website: FC<Props> = ({
|
||||
@ -35,6 +36,7 @@ const Website: FC<Props> = ({
|
||||
crawlOptions,
|
||||
onCrawlOptionsChange,
|
||||
authedDataSourceList,
|
||||
supportBatchUpload = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { setShowAccountSettingModal } = useModalContext()
|
||||
@ -116,6 +118,7 @@ const Website: FC<Props> = ({
|
||||
onJobIdChange={onJobIdChange}
|
||||
crawlOptions={crawlOptions}
|
||||
onCrawlOptionsChange={onCrawlOptionsChange}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
)}
|
||||
{source && selectedProvider === DataSourceProvider.waterCrawl && (
|
||||
@ -126,6 +129,7 @@ const Website: FC<Props> = ({
|
||||
onJobIdChange={onJobIdChange}
|
||||
crawlOptions={crawlOptions}
|
||||
onCrawlOptionsChange={onCrawlOptionsChange}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
)}
|
||||
{source && selectedProvider === DataSourceProvider.jinaReader && (
|
||||
@ -136,6 +140,7 @@ const Website: FC<Props> = ({
|
||||
onJobIdChange={onJobIdChange}
|
||||
crawlOptions={crawlOptions}
|
||||
onCrawlOptionsChange={onCrawlOptionsChange}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
)}
|
||||
{!source && (
|
||||
|
||||
@ -26,6 +26,7 @@ type Props = {
|
||||
onJobIdChange: (jobId: string) => void
|
||||
crawlOptions: CrawlOptions
|
||||
onCrawlOptionsChange: (payload: CrawlOptions) => void
|
||||
supportBatchUpload: boolean
|
||||
}
|
||||
|
||||
enum Step {
|
||||
@ -41,6 +42,7 @@ const JinaReader: FC<Props> = ({
|
||||
onJobIdChange,
|
||||
crawlOptions,
|
||||
onCrawlOptionsChange,
|
||||
supportBatchUpload,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState<Step>(Step.init)
|
||||
@ -157,7 +159,7 @@ const JinaReader: FC<Props> = ({
|
||||
total: 1,
|
||||
data: [{
|
||||
title,
|
||||
content,
|
||||
markdown: content,
|
||||
description,
|
||||
source_url: url,
|
||||
}],
|
||||
@ -176,7 +178,7 @@ const JinaReader: FC<Props> = ({
|
||||
}
|
||||
else {
|
||||
setCrawlResult(data)
|
||||
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
|
||||
onCheckedCrawlResultChange(supportBatchUpload ? (data.data || []) : (data.data?.slice(0, 1) || [])) // default select the crawl result
|
||||
setCrawlErrorMessage('')
|
||||
}
|
||||
}
|
||||
@ -188,7 +190,7 @@ const JinaReader: FC<Props> = ({
|
||||
finally {
|
||||
setStep(Step.finished)
|
||||
}
|
||||
}, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, t, waitForCrawlFinished])
|
||||
}, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, supportBatchUpload, t, waitForCrawlFinished])
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -227,6 +229,7 @@ const JinaReader: FC<Props> = ({
|
||||
onSelectedChange={onCheckedCrawlResultChange}
|
||||
onPreview={onPreview}
|
||||
usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0}
|
||||
isMultipleChoice={supportBatchUpload}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -32,7 +32,7 @@ const WebsitePreview = ({
|
||||
<div className='system-xs-medium truncate text-text-tertiary' title={payload.source_url}>{payload.source_url}</div>
|
||||
</div>
|
||||
<div className={cn(s.previewContent, 'body-md-regular')}>
|
||||
<div className={cn(s.fileContent)}>{payload.content}</div>
|
||||
<div className={cn(s.fileContent)}>{payload.markdown}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -26,6 +26,7 @@ type Props = {
|
||||
onJobIdChange: (jobId: string) => void
|
||||
crawlOptions: CrawlOptions
|
||||
onCrawlOptionsChange: (payload: CrawlOptions) => void
|
||||
supportBatchUpload: boolean
|
||||
}
|
||||
|
||||
enum Step {
|
||||
@ -41,6 +42,7 @@ const WaterCrawl: FC<Props> = ({
|
||||
onJobIdChange,
|
||||
crawlOptions,
|
||||
onCrawlOptionsChange,
|
||||
supportBatchUpload,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState<Step>(Step.init)
|
||||
@ -132,7 +134,7 @@ const WaterCrawl: FC<Props> = ({
|
||||
},
|
||||
}
|
||||
}
|
||||
}, [crawlOptions.limit])
|
||||
}, [crawlOptions.limit, onCheckedCrawlResultChange])
|
||||
|
||||
const handleRun = useCallback(async (url: string) => {
|
||||
const { isValid, errorMsg } = checkValid(url)
|
||||
@ -163,7 +165,7 @@ const WaterCrawl: FC<Props> = ({
|
||||
}
|
||||
else {
|
||||
setCrawlResult(data)
|
||||
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
|
||||
onCheckedCrawlResultChange(supportBatchUpload ? (data.data || []) : (data.data?.slice(0, 1) || [])) // default select the crawl result
|
||||
setCrawlErrorMessage('')
|
||||
}
|
||||
}
|
||||
@ -174,7 +176,7 @@ const WaterCrawl: FC<Props> = ({
|
||||
finally {
|
||||
setStep(Step.finished)
|
||||
}
|
||||
}, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished])
|
||||
}, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, supportBatchUpload, t, waitForCrawlFinished])
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -213,6 +215,7 @@ const WaterCrawl: FC<Props> = ({
|
||||
onSelectedChange={onCheckedCrawlResultChange}
|
||||
onPreview={onPreview}
|
||||
usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0}
|
||||
isMultipleChoice={supportBatchUpload}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -10,14 +10,12 @@ import Trigger from './trigger'
|
||||
import List from './list'
|
||||
|
||||
export type CredentialSelectorProps = {
|
||||
pluginName: string
|
||||
currentCredentialId: string
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
credentials: Array<DataSourceCredential>
|
||||
}
|
||||
|
||||
const CredentialSelector = ({
|
||||
pluginName,
|
||||
currentCredentialId,
|
||||
onCredentialChange,
|
||||
credentials,
|
||||
@ -50,7 +48,6 @@ const CredentialSelector = ({
|
||||
<PortalToFollowElemTrigger onClick={toggle} className='grow overflow-hidden'>
|
||||
<Trigger
|
||||
currentCredential={currentCredential}
|
||||
pluginName={pluginName}
|
||||
isOpen={open}
|
||||
/>
|
||||
</PortalToFollowElemTrigger>
|
||||
@ -58,7 +55,6 @@ const CredentialSelector = ({
|
||||
<List
|
||||
currentCredentialId={currentCredentialId}
|
||||
credentials={credentials}
|
||||
pluginName={pluginName}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
|
||||
@ -2,22 +2,18 @@ import { CredentialIcon } from '@/app/components/datasets/common/credential-icon
|
||||
import type { DataSourceCredential } from '@/types/pipeline'
|
||||
import { RiCheckLine } from '@remixicon/react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ItemProps = {
|
||||
credential: DataSourceCredential
|
||||
pluginName: string
|
||||
isSelected: boolean
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
}
|
||||
|
||||
const Item = ({
|
||||
credential,
|
||||
pluginName,
|
||||
isSelected,
|
||||
onCredentialChange,
|
||||
}: ItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { avatar_url, name } = credential
|
||||
|
||||
const handleCredentialChange = useCallback(() => {
|
||||
@ -30,15 +26,12 @@ const Item = ({
|
||||
onClick={handleCredentialChange}
|
||||
>
|
||||
<CredentialIcon
|
||||
avatar_url={avatar_url}
|
||||
avatarUrl={avatar_url}
|
||||
name={name}
|
||||
size={20}
|
||||
/>
|
||||
<span className='system-sm-medium grow truncate text-text-secondary'>
|
||||
{t('datasetPipeline.credentialSelector.name', {
|
||||
credentialName: name,
|
||||
pluginName,
|
||||
})}
|
||||
{name}
|
||||
</span>
|
||||
{
|
||||
isSelected && (
|
||||
|
||||
@ -5,14 +5,12 @@ import Item from './item'
|
||||
type ListProps = {
|
||||
currentCredentialId: string
|
||||
credentials: Array<DataSourceCredential>
|
||||
pluginName: string
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
}
|
||||
|
||||
const List = ({
|
||||
currentCredentialId,
|
||||
credentials,
|
||||
pluginName,
|
||||
onCredentialChange,
|
||||
}: ListProps) => {
|
||||
return (
|
||||
@ -24,7 +22,6 @@ const List = ({
|
||||
<Item
|
||||
key={credential.id}
|
||||
credential={credential}
|
||||
pluginName={pluginName}
|
||||
isSelected={isSelected}
|
||||
onCredentialChange={onCredentialChange}
|
||||
/>
|
||||
|
||||
@ -1,23 +1,18 @@
|
||||
import React from 'react'
|
||||
import type { DataSourceCredential } from '@/types/pipeline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
|
||||
|
||||
type TriggerProps = {
|
||||
currentCredential: DataSourceCredential | undefined
|
||||
pluginName: string
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const Trigger = ({
|
||||
currentCredential,
|
||||
pluginName,
|
||||
isOpen,
|
||||
}: TriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
avatar_url,
|
||||
name = '',
|
||||
@ -31,16 +26,13 @@ const Trigger = ({
|
||||
)}
|
||||
>
|
||||
<CredentialIcon
|
||||
avatar_url={avatar_url}
|
||||
avatarUrl={avatar_url}
|
||||
name={name}
|
||||
size={20}
|
||||
/>
|
||||
<div className='flex grow items-center gap-x-1 overflow-hidden'>
|
||||
<span className='system-md-semibold grow truncate text-text-secondary'>
|
||||
{t('datasetPipeline.credentialSelector.name', {
|
||||
credentialName: name,
|
||||
pluginName,
|
||||
})}
|
||||
{name}
|
||||
</span>
|
||||
<RiArrowDownSLine className='size-4 shrink-0 text-text-secondary' />
|
||||
</div>
|
||||
|
||||
@ -11,12 +11,14 @@ type HeaderProps = {
|
||||
docTitle: string
|
||||
docLink: string
|
||||
onClickConfiguration?: () => void
|
||||
pluginName: string
|
||||
} & CredentialSelectorProps
|
||||
|
||||
const Header = ({
|
||||
docTitle,
|
||||
docLink,
|
||||
onClickConfiguration,
|
||||
pluginName,
|
||||
...rest
|
||||
}: HeaderProps) => {
|
||||
const { t } = useTranslation()
|
||||
@ -29,7 +31,7 @@ const Header = ({
|
||||
/>
|
||||
<Divider type='vertical' className='mx-1 h-3.5 shrink-0' />
|
||||
<Tooltip
|
||||
popupContent={t('datasetPipeline.configurationTip', { pluginName: rest.pluginName })}
|
||||
popupContent={t('datasetPipeline.configurationTip', { pluginName })}
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
|
||||
@ -23,12 +23,12 @@ const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-ch
|
||||
|
||||
export type LocalFileProps = {
|
||||
allowedExtensions: string[]
|
||||
notSupportBatchUpload?: boolean
|
||||
supportBatchUpload?: boolean
|
||||
}
|
||||
|
||||
const LocalFile = ({
|
||||
allowedExtensions,
|
||||
notSupportBatchUpload,
|
||||
supportBatchUpload = false,
|
||||
}: LocalFileProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
@ -42,7 +42,7 @@ const LocalFile = ({
|
||||
const fileUploader = useRef<HTMLInputElement>(null)
|
||||
const fileListRef = useRef<FileItem[]>([])
|
||||
|
||||
const hideUpload = notSupportBatchUpload && localFileList.length > 0
|
||||
const hideUpload = !supportBatchUpload && localFileList.length > 0
|
||||
|
||||
const { data: fileUploadConfigResponse } = useFileUploadConfig()
|
||||
const supportTypesShowNames = useMemo(() => {
|
||||
@ -64,9 +64,9 @@ const LocalFile = ({
|
||||
const ACCEPTS = allowedExtensions.map((ext: string) => `.${ext}`)
|
||||
const fileUploadConfig = useMemo(() => ({
|
||||
file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15,
|
||||
batch_count_limit: fileUploadConfigResponse?.batch_count_limit ?? 5,
|
||||
file_upload_limit: fileUploadConfigResponse?.file_upload_limit ?? 5,
|
||||
}), [fileUploadConfigResponse])
|
||||
batch_count_limit: supportBatchUpload ? (fileUploadConfigResponse?.batch_count_limit ?? 5) : 1,
|
||||
file_upload_limit: supportBatchUpload ? (fileUploadConfigResponse?.file_upload_limit ?? 5) : 1,
|
||||
}), [fileUploadConfigResponse, supportBatchUpload])
|
||||
|
||||
const updateFile = useCallback((fileItem: FileItem, progress: number, list: FileItem[]) => {
|
||||
const { setLocalFileList } = dataSourceStore.getState()
|
||||
@ -119,7 +119,7 @@ const LocalFile = ({
|
||||
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.size', { size: fileUploadConfig.file_size_limit }) })
|
||||
|
||||
return isValidType && isValidSize
|
||||
}, [fileUploadConfig, notify, t, ACCEPTS])
|
||||
}, [notify, t, ACCEPTS, fileUploadConfig.file_size_limit])
|
||||
|
||||
type UploadResult = Awaited<ReturnType<typeof upload>>
|
||||
|
||||
@ -230,12 +230,12 @@ const LocalFile = ({
|
||||
return
|
||||
|
||||
let files = [...e.dataTransfer.files] as File[]
|
||||
if (notSupportBatchUpload)
|
||||
if (!supportBatchUpload)
|
||||
files = files.slice(0, 1)
|
||||
|
||||
const validFiles = files.filter(isValid)
|
||||
initialUpload(validFiles)
|
||||
}, [initialUpload, isValid, notSupportBatchUpload])
|
||||
}, [initialUpload, isValid, supportBatchUpload])
|
||||
|
||||
const selectHandle = useCallback(() => {
|
||||
if (fileUploader.current)
|
||||
@ -280,7 +280,7 @@ const LocalFile = ({
|
||||
id='fileUploader'
|
||||
className='hidden'
|
||||
type='file'
|
||||
multiple={!notSupportBatchUpload}
|
||||
multiple={supportBatchUpload}
|
||||
accept={ACCEPTS.join(',')}
|
||||
onChange={fileChangeHandle}
|
||||
/>
|
||||
@ -296,7 +296,7 @@ const LocalFile = ({
|
||||
<RiUploadCloud2Line className='mr-2 size-5' />
|
||||
|
||||
<span>
|
||||
{notSupportBatchUpload ? t('datasetCreation.stepOne.uploader.buttonSingleFile') : t('datasetCreation.stepOne.uploader.button')}
|
||||
{supportBatchUpload ? t('datasetCreation.stepOne.uploader.button') : t('datasetCreation.stepOne.uploader.buttonSingleFile')}
|
||||
{allowedExtensions.length > 0 && (
|
||||
<label className='ml-1 cursor-pointer text-text-accent' onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.browse')}</label>
|
||||
)}
|
||||
@ -305,7 +305,7 @@ const LocalFile = ({
|
||||
<div>{t('datasetCreation.stepOne.uploader.tip', {
|
||||
size: fileUploadConfig.file_size_limit,
|
||||
supportTypes: supportTypesShowNames,
|
||||
batchCount: notSupportBatchUpload ? 1 : fileUploadConfig.batch_count_limit,
|
||||
batchCount: fileUploadConfig.batch_count_limit,
|
||||
totalCount: fileUploadConfig.file_upload_limit,
|
||||
})}</div>
|
||||
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
|
||||
|
||||
@ -19,16 +19,18 @@ import { useDocLink } from '@/context/i18n'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
|
||||
type OnlineDocumentsProps = {
|
||||
isInPipeline?: boolean
|
||||
nodeId: string
|
||||
nodeData: DataSourceNodeType
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
isInPipeline?: boolean
|
||||
supportBatchUpload?: boolean
|
||||
}
|
||||
|
||||
const OnlineDocuments = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
isInPipeline = false,
|
||||
supportBatchUpload = false,
|
||||
onCredentialChange,
|
||||
}: OnlineDocumentsProps) => {
|
||||
const docLink = useDocLink()
|
||||
@ -157,7 +159,7 @@ const OnlineDocuments = ({
|
||||
onSelect={handleSelectPages}
|
||||
canPreview={!isInPipeline}
|
||||
onPreview={handlePreviewPage}
|
||||
isMultipleChoice={!isInPipeline}
|
||||
isMultipleChoice={supportBatchUpload}
|
||||
currentCredentialId={currentCredentialId}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -17,6 +17,7 @@ type FileListProps = {
|
||||
handleSelectFile: (file: OnlineDriveFile) => void
|
||||
handleOpenFolder: (file: OnlineDriveFile) => void
|
||||
isLoading: boolean
|
||||
supportBatchUpload: boolean
|
||||
}
|
||||
|
||||
const FileList = ({
|
||||
@ -32,6 +33,7 @@ const FileList = ({
|
||||
handleOpenFolder,
|
||||
isInPipeline,
|
||||
isLoading,
|
||||
supportBatchUpload,
|
||||
}: FileListProps) => {
|
||||
const [inputValue, setInputValue] = useState(keywords)
|
||||
|
||||
@ -72,8 +74,8 @@ const FileList = ({
|
||||
handleResetKeywords={handleResetKeywords}
|
||||
handleOpenFolder={handleOpenFolder}
|
||||
handleSelectFile={handleSelectFile}
|
||||
isInPipeline={isInPipeline}
|
||||
isLoading={isLoading}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -11,8 +11,8 @@ type FileListProps = {
|
||||
fileList: OnlineDriveFile[]
|
||||
selectedFileIds: string[]
|
||||
keywords: string
|
||||
isInPipeline: boolean
|
||||
isLoading: boolean
|
||||
supportBatchUpload: boolean
|
||||
handleResetKeywords: () => void
|
||||
handleSelectFile: (file: OnlineDriveFile) => void
|
||||
handleOpenFolder: (file: OnlineDriveFile) => void
|
||||
@ -25,8 +25,8 @@ const List = ({
|
||||
handleResetKeywords,
|
||||
handleSelectFile,
|
||||
handleOpenFolder,
|
||||
isInPipeline,
|
||||
isLoading,
|
||||
supportBatchUpload,
|
||||
}: FileListProps) => {
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const observerRef = useRef<IntersectionObserver>(null)
|
||||
@ -80,7 +80,7 @@ const List = ({
|
||||
isSelected={isSelected}
|
||||
onSelect={handleSelectFile}
|
||||
onOpen={handleOpenFolder}
|
||||
isMultipleChoice={!isInPipeline}
|
||||
isMultipleChoice={supportBatchUpload}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@ -20,14 +20,16 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con
|
||||
type OnlineDriveProps = {
|
||||
nodeId: string
|
||||
nodeData: DataSourceNodeType
|
||||
isInPipeline?: boolean
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
isInPipeline?: boolean
|
||||
supportBatchUpload?: boolean
|
||||
}
|
||||
|
||||
const OnlineDrive = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
isInPipeline = false,
|
||||
supportBatchUpload = false,
|
||||
onCredentialChange,
|
||||
}: OnlineDriveProps) => {
|
||||
const docLink = useDocLink()
|
||||
@ -111,7 +113,7 @@ const OnlineDrive = ({
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [datasourceNodeRunURL, dataSourceStore])
|
||||
}, [dataSourceStore, datasourceNodeRunURL, breadcrumbs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentCredentialId) return
|
||||
@ -152,12 +154,12 @@ const OnlineDrive = ({
|
||||
draft.splice(index, 1)
|
||||
}
|
||||
else {
|
||||
if (isInPipeline && draft.length >= 1) return
|
||||
if (!supportBatchUpload && draft.length >= 1) return
|
||||
draft.push(file.id)
|
||||
}
|
||||
})
|
||||
setSelectedFileIds(newSelectedFileList)
|
||||
}, [dataSourceStore, isInPipeline])
|
||||
}, [dataSourceStore, supportBatchUpload])
|
||||
|
||||
const handleOpenFolder = useCallback((file: OnlineDriveFile) => {
|
||||
const { breadcrumbs, prefix, setBreadcrumbs, setPrefix, setBucket, setOnlineDriveFileList, setSelectedFileIds } = dataSourceStore.getState()
|
||||
@ -177,7 +179,7 @@ const OnlineDrive = ({
|
||||
setBreadcrumbs(newBreadcrumbs)
|
||||
setPrefix(newPrefix)
|
||||
}
|
||||
}, [dataSourceStore, getOnlineDriveFiles])
|
||||
}, [dataSourceStore])
|
||||
|
||||
const handleSetting = useCallback(() => {
|
||||
setShowAccountSettingModal({
|
||||
@ -209,6 +211,7 @@ const OnlineDrive = ({
|
||||
handleOpenFolder={handleOpenFolder}
|
||||
isInPipeline={isInPipeline}
|
||||
isLoading={isLoading}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -46,6 +46,7 @@ const CrawledResultItem = ({
|
||||
/>
|
||||
) : (
|
||||
<Radio
|
||||
className='shrink-0'
|
||||
isChecked={isChecked}
|
||||
onCheck={handleCheckChange}
|
||||
/>
|
||||
|
||||
@ -33,14 +33,16 @@ const I18N_PREFIX = 'datasetCreation.stepOne.website'
|
||||
export type WebsiteCrawlProps = {
|
||||
nodeId: string
|
||||
nodeData: DataSourceNodeType
|
||||
isInPipeline?: boolean
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
isInPipeline?: boolean
|
||||
supportBatchUpload?: boolean
|
||||
}
|
||||
|
||||
const WebsiteCrawl = ({
|
||||
nodeId,
|
||||
nodeData,
|
||||
isInPipeline = false,
|
||||
supportBatchUpload = false,
|
||||
onCredentialChange,
|
||||
}: WebsiteCrawlProps) => {
|
||||
const { t } = useTranslation()
|
||||
@ -122,7 +124,7 @@ const WebsiteCrawl = ({
|
||||
time_consuming: time_consuming ?? 0,
|
||||
}
|
||||
setCrawlResult(crawlResultData)
|
||||
handleCheckedCrawlResultChange(isInPipeline ? [crawlData[0]] : crawlData) // default select the crawl result
|
||||
handleCheckedCrawlResultChange(supportBatchUpload ? crawlData : crawlData.slice(0, 1)) // default select the crawl result
|
||||
setCrawlErrorMessage('')
|
||||
setStep(CrawlStep.finished)
|
||||
},
|
||||
@ -132,7 +134,7 @@ const WebsiteCrawl = ({
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [dataSourceStore, datasourceNodeRunURL, handleCheckedCrawlResultChange, isInPipeline, t])
|
||||
}, [dataSourceStore, datasourceNodeRunURL, handleCheckedCrawlResultChange, supportBatchUpload, t])
|
||||
|
||||
const handleSubmit = useCallback((value: Record<string, any>) => {
|
||||
handleRun(value)
|
||||
@ -149,7 +151,7 @@ const WebsiteCrawl = ({
|
||||
setTotalNum(0)
|
||||
setCrawlErrorMessage('')
|
||||
onCredentialChange(credentialId)
|
||||
}, [dataSourceStore, onCredentialChange])
|
||||
}, [onCredentialChange])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
@ -195,7 +197,7 @@ const WebsiteCrawl = ({
|
||||
previewIndex={previewIndex}
|
||||
onPreview={handlePreview}
|
||||
showPreview={!isInPipeline}
|
||||
isMultipleChoice={!isInPipeline} // only support single choice in test run
|
||||
isMultipleChoice={supportBatchUpload} // only support single choice in test run
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -102,7 +102,7 @@ const CreateFormPipeline = () => {
|
||||
return onlineDriveFileList.length > 0 && isVectorSpaceFull && enableBilling
|
||||
return false
|
||||
}, [allFileLoaded, datasource, datasourceType, enableBilling, isVectorSpaceFull, onlineDocuments.length, onlineDriveFileList.length, websitePages.length])
|
||||
const notSupportBatchUpload = enableBilling && plan.type === 'sandbox'
|
||||
const supportBatchUpload = !enableBilling || plan.type !== 'sandbox'
|
||||
|
||||
const nextBtnDisabled = useMemo(() => {
|
||||
if (!datasource) return true
|
||||
@ -125,15 +125,16 @@ const CreateFormPipeline = () => {
|
||||
const showSelect = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument) {
|
||||
const pagesCount = currentWorkspace?.pages.length ?? 0
|
||||
return pagesCount > 0
|
||||
return supportBatchUpload && pagesCount > 0
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
const isBucketList = onlineDriveFileList.some(file => file.type === 'bucket')
|
||||
return !isBucketList && onlineDriveFileList.filter((item) => {
|
||||
return supportBatchUpload && !isBucketList && onlineDriveFileList.filter((item) => {
|
||||
return item.type !== 'bucket'
|
||||
}).length > 0
|
||||
}
|
||||
}, [currentWorkspace?.pages.length, datasourceType, onlineDriveFileList])
|
||||
return false
|
||||
}, [currentWorkspace?.pages.length, datasourceType, supportBatchUpload, onlineDriveFileList])
|
||||
|
||||
const totalOptions = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
@ -395,7 +396,7 @@ const CreateFormPipeline = () => {
|
||||
clearWebsiteCrawlData()
|
||||
else if (dataSource.nodeData.provider_type === DatasourceType.onlineDrive)
|
||||
clearOnlineDriveData()
|
||||
}, [])
|
||||
}, [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData])
|
||||
|
||||
const handleSwitchDataSource = useCallback((dataSource: Datasource) => {
|
||||
const {
|
||||
@ -406,13 +407,13 @@ const CreateFormPipeline = () => {
|
||||
setCurrentCredentialId('')
|
||||
currentNodeIdRef.current = dataSource.nodeId
|
||||
setDatasource(dataSource)
|
||||
}, [dataSourceStore])
|
||||
}, [clearDataSourceData, dataSourceStore])
|
||||
|
||||
const handleCredentialChange = useCallback((credentialId: string) => {
|
||||
const { setCurrentCredentialId } = dataSourceStore.getState()
|
||||
clearDataSourceData(datasource!)
|
||||
setCurrentCredentialId(credentialId)
|
||||
}, [dataSourceStore, datasource])
|
||||
}, [clearDataSourceData, dataSourceStore, datasource])
|
||||
|
||||
if (isFetchingPipelineInfo) {
|
||||
return (
|
||||
@ -443,7 +444,7 @@ const CreateFormPipeline = () => {
|
||||
{datasourceType === DatasourceType.localFile && (
|
||||
<LocalFile
|
||||
allowedExtensions={datasource!.nodeData.fileExtensions || []}
|
||||
notSupportBatchUpload={notSupportBatchUpload}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.onlineDocument && (
|
||||
@ -451,6 +452,7 @@ const CreateFormPipeline = () => {
|
||||
nodeId={datasource!.nodeId}
|
||||
nodeData={datasource!.nodeData}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.websiteCrawl && (
|
||||
@ -458,6 +460,7 @@ const CreateFormPipeline = () => {
|
||||
nodeId={datasource!.nodeId}
|
||||
nodeData={datasource!.nodeData}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.onlineDrive && (
|
||||
@ -465,6 +468,7 @@ const CreateFormPipeline = () => {
|
||||
nodeId={datasource!.nodeId}
|
||||
nodeData={datasource!.nodeData}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
)}
|
||||
{isShowVectorSpaceFull && (
|
||||
|
||||
@ -27,7 +27,7 @@ const WebsitePreview = ({
|
||||
<span className='uppercase' title={currentWebsite.source_url}>{currentWebsite.source_url}</span>
|
||||
<span>·</span>
|
||||
<span>·</span>
|
||||
<span>{`${formatNumberAbbreviated(currentWebsite.content.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span>
|
||||
<span>{`${formatNumberAbbreviated(currentWebsite.markdown.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@ -39,7 +39,7 @@ const WebsitePreview = ({
|
||||
</button>
|
||||
</div>
|
||||
<div className='body-md-regular grow overflow-hidden px-6 py-5 text-text-secondary'>
|
||||
{currentWebsite.content}
|
||||
{currentWebsite.markdown}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -113,7 +113,7 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
|
||||
return [{
|
||||
title: websiteInfo.title,
|
||||
source_url: websiteInfo.source_url,
|
||||
content: websiteInfo.content,
|
||||
markdown: websiteInfo.content,
|
||||
description: websiteInfo.description,
|
||||
}]
|
||||
}, [websiteInfo])
|
||||
|
||||
@ -55,7 +55,7 @@ const PipelineSettings = ({
|
||||
if (lastRunData?.datasource_type === DatasourceType.websiteCrawl) {
|
||||
const { content, description, source_url, title } = lastRunData.datasource_info
|
||||
websitePages.push({
|
||||
content,
|
||||
markdown: content,
|
||||
description,
|
||||
source_url,
|
||||
title,
|
||||
@ -135,7 +135,7 @@ const PipelineSettings = ({
|
||||
push(`/datasets/${datasetId}/documents`)
|
||||
},
|
||||
})
|
||||
}, [datasetId, invalidDocumentDetail, invalidDocumentList, lastRunData, pipelineId, push, runPublishedPipeline])
|
||||
}, [datasetId, documentId, invalidDocumentDetail, invalidDocumentList, lastRunData, pipelineId, push, runPublishedPipeline])
|
||||
|
||||
const onClickProcess = useCallback(() => {
|
||||
isPreview.current = false
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -131,7 +131,7 @@ const Preparation = () => {
|
||||
clearWebsiteCrawlData()
|
||||
else if (dataSource.nodeData.provider_type === DatasourceType.onlineDrive)
|
||||
clearOnlineDriveData()
|
||||
}, [])
|
||||
}, [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData])
|
||||
|
||||
const handleSwitchDataSource = useCallback((dataSource: Datasource) => {
|
||||
const {
|
||||
@ -142,13 +142,13 @@ const Preparation = () => {
|
||||
setCurrentCredentialId('')
|
||||
currentNodeIdRef.current = dataSource.nodeId
|
||||
setDatasource(dataSource)
|
||||
}, [dataSourceStore])
|
||||
}, [clearDataSourceData, dataSourceStore])
|
||||
|
||||
const handleCredentialChange = useCallback((credentialId: string) => {
|
||||
const { setCurrentCredentialId } = dataSourceStore.getState()
|
||||
clearDataSourceData(datasource!)
|
||||
setCurrentCredentialId(credentialId)
|
||||
}, [dataSourceStore, datasource])
|
||||
}, [clearDataSourceData, dataSourceStore, datasource])
|
||||
return (
|
||||
<>
|
||||
<StepIndicator steps={steps} currentStep={currentStep} />
|
||||
@ -164,7 +164,7 @@ const Preparation = () => {
|
||||
{datasourceType === DatasourceType.localFile && (
|
||||
<LocalFile
|
||||
allowedExtensions={datasource!.nodeData.fileExtensions || []}
|
||||
notSupportBatchUpload // only support single file upload in test run
|
||||
supportBatchUpload={false} // only support single file upload in test run
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.onlineDocument && (
|
||||
@ -173,6 +173,7 @@ const Preparation = () => {
|
||||
nodeData={datasource!.nodeData}
|
||||
isInPipeline
|
||||
onCredentialChange={handleCredentialChange}
|
||||
supportBatchUpload={false}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.websiteCrawl && (
|
||||
@ -181,6 +182,7 @@ const Preparation = () => {
|
||||
nodeData={datasource!.nodeData}
|
||||
isInPipeline
|
||||
onCredentialChange={handleCredentialChange}
|
||||
supportBatchUpload={false}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.onlineDrive && (
|
||||
@ -189,6 +191,7 @@ const Preparation = () => {
|
||||
nodeData={datasource!.nodeData}
|
||||
isInPipeline
|
||||
onCredentialChange={handleCredentialChange}
|
||||
supportBatchUpload={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -43,13 +43,13 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => {
|
||||
clearWebsiteCrawlData()
|
||||
else if (datasourceType === DatasourceType.onlineDrive)
|
||||
clearOnlineDriveData()
|
||||
}, [datasourceType])
|
||||
}, [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData, datasourceType])
|
||||
|
||||
const handleCredentialChange = useCallback((credentialId: string) => {
|
||||
const { setCurrentCredentialId } = dataSourceStore.getState()
|
||||
clearDataSourceData()
|
||||
setCurrentCredentialId(credentialId)
|
||||
}, [dataSourceStore])
|
||||
}, [clearDataSourceData, dataSourceStore])
|
||||
|
||||
return (
|
||||
<PanelWrap
|
||||
@ -60,7 +60,7 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => {
|
||||
{datasourceType === DatasourceType.localFile && (
|
||||
<LocalFile
|
||||
allowedExtensions={datasourceNodeData.fileExtensions || []}
|
||||
notSupportBatchUpload
|
||||
supportBatchUpload={false}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.onlineDocument && (
|
||||
@ -69,6 +69,7 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => {
|
||||
nodeData={datasourceNodeData}
|
||||
isInPipeline
|
||||
onCredentialChange={handleCredentialChange}
|
||||
supportBatchUpload={false}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.websiteCrawl && (
|
||||
@ -77,6 +78,7 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => {
|
||||
nodeData={datasourceNodeData}
|
||||
isInPipeline
|
||||
onCredentialChange={handleCredentialChange}
|
||||
supportBatchUpload={false}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.onlineDrive && (
|
||||
@ -85,6 +87,7 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => {
|
||||
nodeData={datasourceNodeData}
|
||||
isInPipeline
|
||||
onCredentialChange={handleCredentialChange}
|
||||
supportBatchUpload={false}
|
||||
/>
|
||||
)}
|
||||
<div className='flex justify-end gap-x-2'>
|
||||
|
||||
116
web/hooks/use-async-window-open.spec.ts
Normal file
116
web/hooks/use-async-window-open.spec.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useAsyncWindowOpen } from './use-async-window-open'
|
||||
|
||||
describe('useAsyncWindowOpen', () => {
|
||||
const originalOpen = window.open
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
window.open = originalOpen
|
||||
})
|
||||
|
||||
it('opens immediate url synchronously without calling async getter', async () => {
|
||||
const openSpy = jest.fn()
|
||||
window.open = openSpy
|
||||
const getUrl = jest.fn()
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
await act(async () => {
|
||||
await result.current(getUrl, {
|
||||
immediateUrl: 'https://example.com',
|
||||
target: '_blank',
|
||||
features: 'noopener,noreferrer',
|
||||
})
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer')
|
||||
expect(getUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets opener to null and redirects when async url resolves', async () => {
|
||||
const close = jest.fn()
|
||||
const mockWindow: any = {
|
||||
location: { href: '' },
|
||||
close,
|
||||
opener: 'should-be-cleared',
|
||||
}
|
||||
const openSpy = jest.fn(() => mockWindow)
|
||||
window.open = openSpy
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
await act(async () => {
|
||||
await result.current(async () => 'https://example.com/path')
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith('about:blank', '_blank', undefined)
|
||||
expect(mockWindow.opener).toBeNull()
|
||||
expect(mockWindow.location.href).toBe('https://example.com/path')
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes placeholder and forwards error when async getter throws', async () => {
|
||||
const close = jest.fn()
|
||||
const mockWindow: any = {
|
||||
location: { href: '' },
|
||||
close,
|
||||
opener: null,
|
||||
}
|
||||
const openSpy = jest.fn(() => mockWindow)
|
||||
window.open = openSpy
|
||||
const onError = jest.fn()
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
const error = new Error('fetch failed')
|
||||
await act(async () => {
|
||||
await result.current(async () => {
|
||||
throw error
|
||||
}, { onError })
|
||||
})
|
||||
|
||||
expect(close).toHaveBeenCalled()
|
||||
expect(onError).toHaveBeenCalledWith(error)
|
||||
expect(mockWindow.location.href).toBe('')
|
||||
})
|
||||
|
||||
it('closes placeholder and reports when no url is returned', async () => {
|
||||
const close = jest.fn()
|
||||
const mockWindow: any = {
|
||||
location: { href: '' },
|
||||
close,
|
||||
opener: null,
|
||||
}
|
||||
const openSpy = jest.fn(() => mockWindow)
|
||||
window.open = openSpy
|
||||
const onError = jest.fn()
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
await act(async () => {
|
||||
await result.current(async () => null, { onError })
|
||||
})
|
||||
|
||||
expect(close).toHaveBeenCalled()
|
||||
expect(onError).toHaveBeenCalled()
|
||||
const errArg = onError.mock.calls[0][0] as Error
|
||||
expect(errArg.message).toBe('No url resolved for new window')
|
||||
})
|
||||
|
||||
it('reports failure when window.open returns null', async () => {
|
||||
const openSpy = jest.fn(() => null)
|
||||
window.open = openSpy
|
||||
const getUrl = jest.fn()
|
||||
const onError = jest.fn()
|
||||
const { result } = renderHook(() => useAsyncWindowOpen())
|
||||
|
||||
await act(async () => {
|
||||
await result.current(getUrl, { onError })
|
||||
})
|
||||
|
||||
expect(onError).toHaveBeenCalled()
|
||||
const errArg = onError.mock.calls[0][0] as Error
|
||||
expect(errArg.message).toBe('Failed to open new window')
|
||||
expect(getUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
49
web/hooks/use-async-window-open.ts
Normal file
49
web/hooks/use-async-window-open.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
type GetUrl = () => Promise<string | null | undefined>
|
||||
|
||||
type AsyncWindowOpenOptions = {
|
||||
immediateUrl?: string | null
|
||||
target?: string
|
||||
features?: string
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export const useAsyncWindowOpen = () => useCallback(async (getUrl: GetUrl, options?: AsyncWindowOpenOptions) => {
|
||||
const {
|
||||
immediateUrl,
|
||||
target = '_blank',
|
||||
features,
|
||||
onError,
|
||||
} = options ?? {}
|
||||
|
||||
if (immediateUrl) {
|
||||
window.open(immediateUrl, target, features)
|
||||
return
|
||||
}
|
||||
|
||||
const newWindow = window.open('about:blank', target, features)
|
||||
if (!newWindow) {
|
||||
onError?.(new Error('Failed to open new window'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
newWindow.opener = null
|
||||
}
|
||||
catch { /* noop */ }
|
||||
|
||||
try {
|
||||
const url = await getUrl()
|
||||
if (url) {
|
||||
newWindow.location.href = url
|
||||
return
|
||||
}
|
||||
newWindow.close()
|
||||
onError?.(new Error('No url resolved for new window'))
|
||||
}
|
||||
catch (error) {
|
||||
newWindow.close()
|
||||
onError?.(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}, [])
|
||||
@ -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',
|
||||
|
||||
@ -145,9 +145,6 @@ const translation = {
|
||||
emptySearchResult: 'No items were found',
|
||||
resetKeywords: 'Reset keywords',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}\'s {{pluginName}}',
|
||||
},
|
||||
configurationTip: 'Configure {{pluginName}}',
|
||||
conversion: {
|
||||
title: 'Convert to Knowledge Pipeline',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -137,9 +137,6 @@ const translation = {
|
||||
emptySearchResult: 'アイテムは見つかりませんでした',
|
||||
resetKeywords: 'キーワードをリセットする',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}}の{{pluginName}}',
|
||||
},
|
||||
configurationTip: '{{pluginName}}を設定',
|
||||
conversion: {
|
||||
confirm: {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -145,9 +145,6 @@ const translation = {
|
||||
emptySearchResult: '未找到任何项目',
|
||||
resetKeywords: '重置关键词',
|
||||
},
|
||||
credentialSelector: {
|
||||
name: '{{credentialName}} 的 {{pluginName}}',
|
||||
},
|
||||
configurationTip: '配置 {{pluginName}}',
|
||||
conversion: {
|
||||
title: '转换为知识流水线',
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -156,7 +156,7 @@ export type CrawlOptions = {
|
||||
|
||||
export type CrawlResultItem = {
|
||||
title: string
|
||||
content: string
|
||||
markdown: string
|
||||
description: string
|
||||
source_url: string
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
3
web/pnpm-lock.yaml
generated
3
web/pnpm-lock.yaml
generated
@ -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
Block a user