From bcbc07e99c06e54371fcacf6800c3190c41eb851 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Tue, 9 Dec 2025 20:45:57 +0800 Subject: [PATCH 01/18] Add MCP backend codeowners (#29354) --- .github/CODEOWNERS | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 94e5b0f969..d6f326d4dc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 From efa1b452da104b70f5f190a00170eafe3b574b77 Mon Sep 17 00:00:00 2001 From: Nan LI Date: Tue, 9 Dec 2025 05:00:19 -0800 Subject: [PATCH 02/18] feat: Add startup parameters for language-specific Weaviate tokenizer (#29347) Co-authored-by: Jing --- docker/.env.example | 3 +++ docker/docker-compose-template.yaml | 3 +++ docker/docker-compose.yaml | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/docker/.env.example b/docker/.env.example index 80e87425c1..85e8b1dc7f 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -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 diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index f1061ef5f9..3c01274ce8 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -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: diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 3e416c36c9..809aa1f841 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -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: From 56f8bdd724765b2d28f3041af9b1acdb375a3bd3 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Tue, 9 Dec 2025 22:03:21 +0800 Subject: [PATCH 03/18] Update refactor issue template and remove tracker (#29357) --- .github/ISSUE_TEMPLATE/refactor.yml | 14 ++++++-------- .github/ISSUE_TEMPLATE/tracker.yml | 13 ------------- 2 files changed, 6 insertions(+), 21 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/tracker.yml diff --git a/.github/ISSUE_TEMPLATE/refactor.yml b/.github/ISSUE_TEMPLATE/refactor.yml index cf74dcc546..dbe8cbb602 100644 --- a/.github/ISSUE_TEMPLATE/refactor.yml +++ b/.github/ISSUE_TEMPLATE/refactor.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/tracker.yml b/.github/ISSUE_TEMPLATE/tracker.yml deleted file mode 100644 index 35fedefc75..0000000000 --- a/.github/ISSUE_TEMPLATE/tracker.yml +++ /dev/null @@ -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 From 1b9165624f2cf7534b1ffebc46753061ace2cdb4 Mon Sep 17 00:00:00 2001 From: znn Date: Wed, 10 Dec 2025 06:49:13 +0530 Subject: [PATCH 04/18] adding llm_usage and error_type (#26546) --- api/core/workflow/nodes/llm/node.py | 3 +++ .../nodes/question_classifier/question_classifier_node.py | 1 + 2 files changed, 4 insertions(+) diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index 10682ae38a..a5973862b2 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -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, ) ) diff --git a/api/core/workflow/nodes/question_classifier/question_classifier_node.py b/api/core/workflow/nodes/question_classifier/question_classifier_node.py index db3d4d4aac..4a3e8e56f8 100644 --- a/api/core/workflow/nodes/question_classifier/question_classifier_node.py +++ b/api/core/workflow/nodes/question_classifier/question_classifier_node.py @@ -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, From 4a88c8fd1932c3f43f242ba697ad7bdd1b121540 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Wed, 10 Dec 2025 09:44:47 +0800 Subject: [PATCH 05/18] chore: set is_multimodal db define default = false (#29362) --- api/models/dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/models/dataset.py b/api/models/dataset.py index 5bbf44050c..ba2eaf6749 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -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): From e205182e1f9cf6dfa374bfe3240fdc909a15b42a Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Wed, 10 Dec 2025 10:01:45 +0800 Subject: [PATCH 06/18] =?UTF-8?q?fix:=20Parent=20instance=20=20is=20not=20bound=E2=80=A6=20(#29377?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/core/rag/datasource/retrieval_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py index cbd7cbeb64..e644e754ec 100644 --- a/api/core/rag/datasource/retrieval_service.py +++ b/api/core/rag/datasource/retrieval_service.py @@ -483,7 +483,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 +496,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 From 7df360a2921f2e256602ca0cd131f65c96150404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Wed, 10 Dec 2025 10:15:21 +0800 Subject: [PATCH 07/18] fix: workflow log missing trigger icon (#29379) --- api/models/workflow.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/api/models/workflow.py b/api/models/workflow.py index 42ee8a1f2b..853d5afefc 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -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"]: From 51330c0ee631de49f6cc5f7b7436c00cab62d38f Mon Sep 17 00:00:00 2001 From: Nie Ronghua <40586915+NieRonghua@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:47:45 +0800 Subject: [PATCH 08/18] fix(App.deleted_tools): incorrect compare between UUID and map with string-typed key. (#29340) Co-authored-by: 01393547 Co-authored-by: Yeuoly <45712896+Yeuoly@users.noreply.github.com> --- api/models/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/models/model.py b/api/models/model.py index 1731ff5699..6b0bf4b4a2 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -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, From c033030d8c636a1c274dbe2396f1b3f92c783669 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Wed, 10 Dec 2025 12:45:53 +0800 Subject: [PATCH 09/18] fix: 'list' object has no attribute 'find' (#29384) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/llm_generator/llm_generator.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index 6b168fd4e8..4a577e6c38 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -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}"} From f722fdfa6d7d4a8c2617c49d0260d131a491ae87 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:46:01 +0800 Subject: [PATCH 10/18] fix: prevent popup blocker from blocking async window.open (#29391) --- .../components/app/app-publisher/index.tsx | 29 ++++---- web/app/components/apps/app-card.tsx | 27 ++++--- .../pricing/plans/cloud-plan-item/index.tsx | 12 +++- web/hooks/use-async-window-open.ts | 72 +++++++++++++++++++ 4 files changed, 115 insertions(+), 25 deletions(-) create mode 100644 web/hooks/use-async-window-open.ts diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index bba5ebfa21..801345798b 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -21,7 +21,6 @@ import { import { useKeyPress } from 'ahooks' import Divider from '../../base/divider' import Loading from '../../base/loading' -import Toast from '../../base/toast' import Tooltip from '../../base/tooltip' import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../../workflow/utils' import AccessControl from '../app-access-control' @@ -50,6 +49,7 @@ import { AppModeEnum } from '@/types/app' import type { PublishWorkflowParams } from '@/types/workflow' import { basePath } from '@/utils/var' import UpgradeBtn from '@/app/components/billing/upgrade-btn' +import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' const ACCESS_MODE_MAP: Record = { [AccessMode.ORGANIZATION]: { @@ -216,18 +216,23 @@ const AppPublisher = ({ setPublished(false) }, [disabled, onToggle, open]) - const handleOpenInExplore = useCallback(async () => { - try { - const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {} - if (installed_apps?.length > 0) - window.open(`${basePath}/explore/installed/${installed_apps[0].id}`, '_blank') - else + const { openAsync } = useAsyncWindowOpen() + + const handleOpenInExplore = useCallback(() => { + if (!appDetail?.id) return + + openAsync( + async () => { + const { installed_apps }: { installed_apps?: { id: string }[] } = await fetchInstalledAppList(appDetail.id) || {} + if (installed_apps && installed_apps.length > 0) + return `${basePath}/explore/installed/${installed_apps[0].id}` throw new Error('No app found in Explore') - } - catch (e: any) { - Toast.notify({ type: 'error', message: `${e.message || e}` }) - } - }, [appDetail?.id]) + }, + { + errorMessage: 'Failed to open app in Explore', + }, + ) + }, [appDetail?.id, openAsync]) const handleAccessControlUpdate = useCallback(async () => { if (!appDetail) diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 8356cfd31c..407df23913 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next' import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react' import cn from '@/utils/classnames' import { type App, AppModeEnum } from '@/types/app' -import Toast, { ToastContext } from '@/app/components/base/toast' +import { ToastContext } from '@/app/components/base/toast' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import AppIcon from '@/app/components/base/app-icon' @@ -31,6 +31,7 @@ import { AccessMode } from '@/models/access-control' import { useGlobalPublicStore } from '@/context/global-public-context' import { formatTime } from '@/utils/time' import { useGetUserCanAccessApp } from '@/service/access-control' +import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import dynamic from 'next/dynamic' const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { @@ -242,20 +243,24 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { e.preventDefault() setShowAccessControl(true) } - const onClickInstalledApp = async (e: React.MouseEvent) => { + const { openAsync } = useAsyncWindowOpen() + + const onClickInstalledApp = (e: React.MouseEvent) => { e.stopPropagation() props.onClick?.() e.preventDefault() - try { - const { installed_apps }: any = await fetchInstalledAppList(app.id) || {} - if (installed_apps?.length > 0) - window.open(`${basePath}/explore/installed/${installed_apps[0].id}`, '_blank') - else + + openAsync( + async () => { + const { installed_apps }: { installed_apps?: { id: string }[] } = await fetchInstalledAppList(app.id) || {} + if (installed_apps && installed_apps.length > 0) + return `${basePath}/explore/installed/${installed_apps[0].id}` throw new Error('No app found in Explore') - } - catch (e: any) { - Toast.notify({ type: 'error', message: `${e.message || e}` }) - } + }, + { + errorMessage: 'Failed to open app in Explore', + }, + ) } return (
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx index 396dd4a1b0..164ad9061a 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx @@ -9,6 +9,7 @@ import Toast from '../../../../base/toast' import { PlanRange } from '../../plan-switcher/plan-range-switcher' import { useAppContext } from '@/context/app-context' import { fetchBillingUrl, fetchSubscriptionUrls } from '@/service/billing' +import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import List from './list' import Button from './button' import { Professional, Sandbox, Team } from '../../assets' @@ -54,6 +55,8 @@ const CloudPlanItem: FC = ({ })[plan] }, [isCurrent, plan, t]) + const { openAsync } = useAsyncWindowOpen() + const handleGetPayUrl = async () => { if (loading) return @@ -72,8 +75,13 @@ const CloudPlanItem: FC = ({ setLoading(true) try { if (isCurrentPaidPlan) { - const res = await fetchBillingUrl() - window.open(res.url, '_blank') + await openAsync( + () => fetchBillingUrl().then(res => res.url), + { + errorMessage: 'Failed to open billing page', + windowFeatures: 'noopener,noreferrer', + }, + ) return } diff --git a/web/hooks/use-async-window-open.ts b/web/hooks/use-async-window-open.ts new file mode 100644 index 0000000000..582ab28be4 --- /dev/null +++ b/web/hooks/use-async-window-open.ts @@ -0,0 +1,72 @@ +import { useCallback } from 'react' +import Toast from '@/app/components/base/toast' + +export type AsyncWindowOpenOptions = { + successMessage?: string + errorMessage?: string + windowFeatures?: string + onError?: (error: any) => void + onSuccess?: (url: string) => void +} + +export const useAsyncWindowOpen = () => { + const openAsync = useCallback(async ( + fetchUrl: () => Promise, + options: AsyncWindowOpenOptions = {}, + ) => { + const { + successMessage, + errorMessage = 'Failed to open page', + windowFeatures = 'noopener,noreferrer', + onError, + onSuccess, + } = options + + const newWindow = window.open('', '_blank', windowFeatures) + + if (!newWindow) { + const error = new Error('Popup blocked by browser') + onError?.(error) + Toast.notify({ + type: 'error', + message: 'Popup blocked. Please allow popups for this site.', + }) + return + } + + try { + const url = await fetchUrl() + + if (url) { + newWindow.location.href = url + onSuccess?.(url) + + if (successMessage) { + Toast.notify({ + type: 'success', + message: successMessage, + }) + } + } + else { + newWindow.close() + const error = new Error('Invalid URL received') + onError?.(error) + Toast.notify({ + type: 'error', + message: errorMessage, + }) + } + } + catch (error) { + newWindow.close() + onError?.(error) + Toast.notify({ + type: 'error', + message: errorMessage, + }) + } + }, []) + + return { openAsync } +} From 681c06186ea381e5ea15ffeb7a5468e982460d1e Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 10 Dec 2025 12:46:52 +0800 Subject: [PATCH 11/18] add @testing-library/user-event and create tests for external-knowledge-base/ (#29323) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../connector/index.spec.tsx | 367 ++++++ .../create/index.spec.tsx | 1059 +++++++++++++++++ web/jest.setup.ts | 13 + web/package.json | 1 + web/pnpm-lock.yaml | 3 + web/testing/testing.md | 11 +- 6 files changed, 1453 insertions(+), 1 deletion(-) create mode 100644 web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx create mode 100644 web/app/components/datasets/external-knowledge-base/create/index.spec.tsx diff --git a/web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx new file mode 100644 index 0000000000..a6353a101c --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx @@ -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 => ({ + 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() { + let resolve: (value: T) => void = jest.fn() + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +// Helper to fill required form fields and submit +async function fillFormAndSubmit(user: ReturnType) { + 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() + + // 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + // 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + const buttons = screen.getAllByRole('button') + const backButton = buttons.find(btn => btn.classList.contains('rounded-full')) + await user.click(backButton!) + + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + }) +}) diff --git a/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx new file mode 100644 index 0000000000..c315743424 --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx @@ -0,0 +1,1059 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { ExternalAPIItem } from '@/models/datasets' +import ExternalKnowledgeBaseCreate from './index' + +// Mock next/navigation +const mockReplace = jest.fn() +const mockRefresh = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + replace: mockReplace, + push: jest.fn(), + refresh: mockRefresh, + }), +})) + +// 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 external context providers (these are external dependencies) +const mockSetShowExternalKnowledgeAPIModal = jest.fn() +jest.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal, + }), +})) + +// Factory function to create mock ExternalAPIItem (following project conventions) +const createMockExternalAPIItem = (overrides: Partial = {}): 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' }, + }), +] + +const mockMutateExternalKnowledgeApis = jest.fn() +let mockExternalKnowledgeApiList: ExternalAPIItem[] = createDefaultMockApiList() + +jest.mock('@/context/external-knowledge-api-context', () => ({ + useExternalKnowledgeApi: () => ({ + externalKnowledgeApiList: mockExternalKnowledgeApiList, + mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis, + isLoading: false, + }), +})) + +// Helper to render component with default props +const renderComponent = (props: Partial> = {}) => { + const defaultProps = { + onConnect: jest.fn(), + loading: false, + } + return render() +} + +describe('ExternalKnowledgeBaseCreate', () => { + beforeEach(() => { + jest.clearAllMocks() + // Reset API list to default using factory function + mockExternalKnowledgeApiList = createDefaultMockApiList() + }) + + // Tests for basic rendering + describe('Rendering', () => { + it('should render without crashing', () => { + renderComponent() + + expect(screen.getByText('dataset.connectDataset')).toBeInTheDocument() + }) + + it('should render KnowledgeBaseInfo component with correct labels', () => { + renderComponent() + + // KnowledgeBaseInfo renders these labels + expect(screen.getByText('dataset.externalKnowledgeName')).toBeInTheDocument() + expect(screen.getByText('dataset.externalKnowledgeDescription')).toBeInTheDocument() + }) + + it('should render ExternalApiSelection component', () => { + renderComponent() + + // ExternalApiSelection renders this label + expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument() + expect(screen.getByText('dataset.externalKnowledgeId')).toBeInTheDocument() + }) + + it('should render RetrievalSettings component', () => { + renderComponent() + + // RetrievalSettings renders this label + expect(screen.getByText('dataset.retrievalSettings')).toBeInTheDocument() + }) + + it('should render InfoPanel component', () => { + renderComponent() + + // InfoPanel renders these texts + expect(screen.getByText('dataset.connectDatasetIntro.title')).toBeInTheDocument() + expect(screen.getByText('dataset.connectDatasetIntro.learnMore')).toBeInTheDocument() + }) + + it('should render helper text with translation keys', () => { + renderComponent() + + expect(screen.getByText('dataset.connectHelper.helper1')).toBeInTheDocument() + expect(screen.getByText('dataset.connectHelper.helper2')).toBeInTheDocument() + expect(screen.getByText('dataset.connectHelper.helper3')).toBeInTheDocument() + expect(screen.getByText('dataset.connectHelper.helper4')).toBeInTheDocument() + expect(screen.getByText('dataset.connectHelper.helper5')).toBeInTheDocument() + }) + + it('should render cancel and connect buttons', () => { + renderComponent() + + expect(screen.getByText('dataset.externalKnowledgeForm.cancel')).toBeInTheDocument() + expect(screen.getByText('dataset.externalKnowledgeForm.connect')).toBeInTheDocument() + }) + + it('should render documentation link with correct href', () => { + renderComponent() + + const docLink = screen.getByText('dataset.connectHelper.helper4') + expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/guides/knowledge-base/connect-external-knowledge-base') + expect(docLink).toHaveAttribute('target', '_blank') + expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + // Tests for props handling + describe('Props', () => { + it('should pass loading prop to connect button', () => { + renderComponent({ loading: true }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeInTheDocument() + }) + + it('should call onConnect with form data when connect button is clicked', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + // Fill in name field (using the actual Input component) + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + fireEvent.change(nameInput, { target: { value: 'Test Knowledge Base' } }) + + // Fill in external knowledge id + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-456' } }) + + // Wait for useEffect to auto-select the first API + 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!) + + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Test Knowledge Base', + external_knowledge_id: 'knowledge-456', + external_knowledge_api_id: 'api-1', // Auto-selected first API + provider: 'external', + }), + ) + }) + + it('should not call onConnect when form is invalid and button is disabled', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeDisabled() + + await user.click(connectButton!) + expect(onConnect).not.toHaveBeenCalled() + }) + }) + + // Tests for state management with real child components + describe('State Management', () => { + it('should initialize form data with default values', () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') as HTMLInputElement + const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') as HTMLTextAreaElement + + expect(nameInput.value).toBe('') + expect(descriptionInput.value).toBe('') + }) + + it('should update name when input changes', () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + fireEvent.change(nameInput, { target: { value: 'New Name' } }) + + expect((nameInput as HTMLInputElement).value).toBe('New Name') + }) + + it('should update description when textarea changes', () => { + renderComponent() + + const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') + fireEvent.change(descriptionInput, { target: { value: 'New Description' } }) + + expect((descriptionInput as HTMLTextAreaElement).value).toBe('New Description') + }) + + it('should update external_knowledge_id when input changes', () => { + renderComponent() + + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + fireEvent.change(knowledgeIdInput, { target: { value: 'new-knowledge-id' } }) + + expect((knowledgeIdInput as HTMLInputElement).value).toBe('new-knowledge-id') + }) + + it('should apply filled text style when description has value', () => { + renderComponent() + + const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') as HTMLTextAreaElement + + // Initially empty - should have placeholder style + expect(descriptionInput.className).toContain('text-components-input-text-placeholder') + + // Add description - should have filled style + fireEvent.change(descriptionInput, { target: { value: 'Some description' } }) + expect(descriptionInput.className).toContain('text-components-input-text-filled') + }) + + it('should apply placeholder text style when description is empty', () => { + renderComponent() + + const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') as HTMLTextAreaElement + + // Add then clear description + fireEvent.change(descriptionInput, { target: { value: 'Some description' } }) + fireEvent.change(descriptionInput, { target: { value: '' } }) + + expect(descriptionInput.className).toContain('text-components-input-text-placeholder') + }) + }) + + // Tests for form validation + describe('Form Validation', () => { + it('should disable connect button when name is empty', async () => { + renderComponent() + + // Fill knowledge id but not name + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-456' } }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeDisabled() + }) + + it('should disable connect button when name is only whitespace', async () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: ' ' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-456' } }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeDisabled() + }) + + it('should disable connect button when external_knowledge_id is empty', () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + fireEvent.change(nameInput, { target: { value: 'Test Name' } }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeDisabled() + }) + + it('should enable connect button when all required fields are filled', async () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: 'Test Name' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-456' } }) + + // Wait for auto-selection of API + await waitFor(() => { + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).not.toBeDisabled() + }) + }) + }) + + // Tests for user interactions + describe('User Interactions', () => { + it('should navigate back when back button is clicked', async () => { + const user = userEvent.setup() + renderComponent() + + const buttons = screen.getAllByRole('button') + const backButton = buttons.find(btn => btn.classList.contains('rounded-full')) + await user.click(backButton!) + + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + + it('should navigate back when cancel button is clicked', async () => { + const user = userEvent.setup() + renderComponent() + + const cancelButton = screen.getByText('dataset.externalKnowledgeForm.cancel').closest('button') + await user.click(cancelButton!) + + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + + it('should call onConnect with complete form data when connect is clicked', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + // Fill all fields using real components + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: 'My Knowledge Base' } }) + fireEvent.change(descriptionInput, { target: { value: 'Test description' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-abc' } }) + + 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!) + + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'My Knowledge Base', + description: 'Test description', + external_knowledge_id: 'knowledge-abc', + provider: 'external', + }), + ) + }) + + it('should allow user to type in all input fields', async () => { + const user = userEvent.setup() + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + await user.type(nameInput, 'Typed Name') + await user.type(descriptionInput, 'Typed Description') + await user.type(knowledgeIdInput, 'typed-knowledge') + + expect((nameInput as HTMLInputElement).value).toBe('Typed Name') + expect((descriptionInput as HTMLTextAreaElement).value).toBe('Typed Description') + expect((knowledgeIdInput as HTMLInputElement).value).toBe('typed-knowledge') + }) + }) + + // Tests for ExternalApiSelection integration + describe('ExternalApiSelection Integration', () => { + it('should auto-select first API when API list is available', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + 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() + }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + await user.click(connectButton!) + + // Should have auto-selected the first API + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + external_knowledge_api_id: 'api-1', + }), + ) + }) + + it('should display API selector when APIs are available', () => { + renderComponent() + + // The ExternalApiSelect should show the first selected API name + expect(screen.getByText('Test API 1')).toBeInTheDocument() + }) + + it('should allow selecting different API from dropdown', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Select the second API + const secondApi = screen.getByText('Test API 2') + await user.click(secondApi) + + // Fill required fields + 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() + }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + await user.click(connectButton!) + + // Should have selected the second API + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + external_knowledge_api_id: 'api-2', + }), + ) + }) + + it('should show add API button when no APIs are available', () => { + // Set empty API list + mockExternalKnowledgeApiList = [] + renderComponent() + + // Should show "no external knowledge" button + expect(screen.getByText('dataset.noExternalKnowledge')).toBeInTheDocument() + }) + + it('should open add API modal when add button is clicked', async () => { + const user = userEvent.setup() + // Set empty API list + mockExternalKnowledgeApiList = [] + renderComponent() + + // Click the add button + const addButton = screen.getByText('dataset.noExternalKnowledge').closest('button') + await user.click(addButton!) + + // Should call the modal context function + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { name: '', settings: { endpoint: '', api_key: '' } }, + isEditMode: false, + }), + ) + }) + + it('should call mutate and router.refresh on modal save callback', async () => { + const user = userEvent.setup() + // Set empty API list + mockExternalKnowledgeApiList = [] + renderComponent() + + // Click the add button + const addButton = screen.getByText('dataset.noExternalKnowledge').closest('button') + await user.click(addButton!) + + // Get the callback and invoke it + const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + await modalCall.onSaveCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + expect(mockRefresh).toHaveBeenCalled() + }) + + it('should call mutate on modal cancel callback', async () => { + const user = userEvent.setup() + // Set empty API list + mockExternalKnowledgeApiList = [] + renderComponent() + + // Click the add button + const addButton = screen.getByText('dataset.noExternalKnowledge').closest('button') + await user.click(addButton!) + + // Get the callback and invoke it + const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + modalCall.onCancelCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + + it('should display API URL in dropdown', async () => { + const user = userEvent.setup() + renderComponent() + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Should show API URLs + expect(screen.getByText('https://api1.example.com')).toBeInTheDocument() + expect(screen.getByText('https://api2.example.com')).toBeInTheDocument() + }) + + it('should show create new API option in dropdown', async () => { + const user = userEvent.setup() + renderComponent() + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Should show create new API option + expect(screen.getByText('dataset.createNewExternalAPI')).toBeInTheDocument() + }) + + it('should open add API modal when clicking create new API in dropdown', async () => { + const user = userEvent.setup() + renderComponent() + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Click on create new API option + const createNewApiOption = screen.getByText('dataset.createNewExternalAPI') + await user.click(createNewApiOption) + + // Should call the modal context function + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { name: '', settings: { endpoint: '', api_key: '' } }, + isEditMode: false, + }), + ) + }) + + it('should call mutate and refresh on save callback from ExternalApiSelect dropdown', async () => { + const user = userEvent.setup() + renderComponent() + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Click on create new API option + const createNewApiOption = screen.getByText('dataset.createNewExternalAPI') + await user.click(createNewApiOption) + + // Get the callback from the modal call and invoke it + const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + await modalCall.onSaveCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + expect(mockRefresh).toHaveBeenCalled() + }) + + it('should call mutate on cancel callback from ExternalApiSelect dropdown', async () => { + const user = userEvent.setup() + renderComponent() + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Click on create new API option + const createNewApiOption = screen.getByText('dataset.createNewExternalAPI') + await user.click(createNewApiOption) + + // Get the callback from the modal call and invoke it + const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + modalCall.onCancelCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + + it('should close dropdown after selecting an API', async () => { + const user = userEvent.setup() + renderComponent() + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Dropdown should be open - API URLs visible + expect(screen.getByText('https://api1.example.com')).toBeInTheDocument() + + // Select the second API + const secondApi = screen.getByText('Test API 2') + await user.click(secondApi) + + // Dropdown should be closed - API URLs not visible + expect(screen.queryByText('https://api1.example.com')).not.toBeInTheDocument() + }) + + it('should toggle dropdown open/close on selector click', async () => { + const user = userEvent.setup() + renderComponent() + + // Click to open + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + expect(screen.getByText('https://api1.example.com')).toBeInTheDocument() + + // Click again to close + await user.click(apiSelector) + expect(screen.queryByText('https://api1.example.com')).not.toBeInTheDocument() + }) + }) + + // Tests for callback stability + describe('Callback Stability', () => { + it('should maintain stable navBackHandle callback reference', async () => { + const user = userEvent.setup() + const { rerender } = render( + , + ) + + const buttons = screen.getAllByRole('button') + const backButton = buttons.find(btn => btn.classList.contains('rounded-full')) + await user.click(backButton!) + + expect(mockReplace).toHaveBeenCalledTimes(1) + + rerender() + + await user.click(backButton!) + expect(mockReplace).toHaveBeenCalledTimes(2) + }) + + it('should not recreate handlers on prop changes', async () => { + const user = userEvent.setup() + const onConnect1 = jest.fn() + const onConnect2 = jest.fn() + + const { rerender } = render( + , + ) + + // 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: 'knowledge' } }) + + // Rerender with new callback + rerender() + + 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!) + + // Should use the new callback + expect(onConnect1).not.toHaveBeenCalled() + expect(onConnect2).toHaveBeenCalled() + }) + }) + + // Tests for edge cases + describe('Edge Cases', () => { + it('should handle empty description gracefully', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: 'Test' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge' } }) + + 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!) + + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + description: '', + }), + ) + }) + + it('should handle special characters in name', () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const specialName = 'Test Name' + + fireEvent.change(nameInput, { target: { value: specialName } }) + + expect((nameInput as HTMLInputElement).value).toBe(specialName) + }) + + it('should handle very long input values', () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const longName = 'A'.repeat(1000) + + fireEvent.change(nameInput, { target: { value: longName } }) + + expect((nameInput as HTMLInputElement).value).toBe(longName) + }) + + it('should handle rapid sequential updates', () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + + // Rapid updates + for (let i = 0; i < 10; i++) + fireEvent.change(nameInput, { target: { value: `Name ${i}` } }) + + expect((nameInput as HTMLInputElement).value).toBe('Name 9') + }) + + it('should preserve provider value as external', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: 'Test' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge' } }) + + 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!) + + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'external', + }), + ) + }) + }) + + // Tests for loading state + describe('Loading State', () => { + it('should pass loading state to connect button', () => { + renderComponent({ loading: true }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeInTheDocument() + }) + + it('should render correctly when not loading', () => { + renderComponent({ loading: false }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeInTheDocument() + }) + }) + + // Tests for RetrievalSettings integration + describe('RetrievalSettings Integration', () => { + it('should toggle score threshold enabled when switch is clicked', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + // Find and click the switch for score threshold + const switches = screen.getAllByRole('switch') + const scoreThresholdSwitch = switches[0] // The score threshold switch + await user.click(scoreThresholdSwitch) + + // Fill required fields + 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() + }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + await user.click(connectButton!) + + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + external_retrieval_model: expect.objectContaining({ + score_threshold_enabled: true, + }), + }), + ) + }) + + it('should display retrieval settings labels', () => { + renderComponent() + + // Should show the retrieval settings section title + expect(screen.getByText('dataset.retrievalSettings')).toBeInTheDocument() + // Should show Top K and Score Threshold labels + expect(screen.getByText('appDebug.datasetConfig.top_k')).toBeInTheDocument() + expect(screen.getByText('appDebug.datasetConfig.score_threshold')).toBeInTheDocument() + }) + }) + + // Direct unit tests for RetrievalSettings component to cover all branches + describe('RetrievalSettings Component Direct Tests', () => { + // Import RetrievalSettings directly for unit testing + const RetrievalSettings = require('./RetrievalSettings').default + + it('should render with isInHitTesting mode', () => { + const onChange = jest.fn() + render( + , + ) + + // In hit testing mode, the title should not be shown + expect(screen.queryByText('dataset.retrievalSettings')).not.toBeInTheDocument() + }) + + it('should render with isInRetrievalSetting mode', () => { + const onChange = jest.fn() + render( + , + ) + + // In retrieval setting mode, the title should not be shown + expect(screen.queryByText('dataset.retrievalSettings')).not.toBeInTheDocument() + }) + + it('should call onChange with score_threshold_enabled when switch is toggled', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + render( + , + ) + + // Find and click the switch + const switches = screen.getAllByRole('switch') + await user.click(switches[0]) + + expect(onChange).toHaveBeenCalledWith({ score_threshold_enabled: true }) + }) + + it('should call onChange with top_k when top k value changes', () => { + const onChange = jest.fn() + render( + , + ) + + // The TopKItem should render an input + const inputs = screen.getAllByRole('spinbutton') + const topKInput = inputs[0] + fireEvent.change(topKInput, { target: { value: '8' } }) + + expect(onChange).toHaveBeenCalledWith({ top_k: 8 }) + }) + + it('should call onChange with score_threshold when threshold value changes', () => { + const onChange = jest.fn() + render( + , + ) + + // The ScoreThresholdItem should render an input + const inputs = screen.getAllByRole('spinbutton') + const scoreThresholdInput = inputs[1] + fireEvent.change(scoreThresholdInput, { target: { value: '0.8' } }) + + expect(onChange).toHaveBeenCalledWith({ score_threshold: 0.8 }) + }) + }) + + // Tests for complete form submission flow + describe('Complete Form Submission Flow', () => { + it('should submit form with all default retrieval settings', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: 'Test KB' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } }) + + 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!) + + expect(onConnect).toHaveBeenCalledWith({ + name: 'Test KB', + description: '', + external_knowledge_api_id: 'api-1', + external_knowledge_id: 'kb-1', + external_retrieval_model: { + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + provider: 'external', + }) + }) + + it('should submit form with modified retrieval settings', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + // Toggle score threshold switch + const switches = screen.getAllByRole('switch') + const scoreThresholdSwitch = switches[0] + await user.click(scoreThresholdSwitch) + + // Fill required fields + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: 'Custom KB' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'custom-kb' } }) + + 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!) + + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Custom KB', + external_retrieval_model: expect.objectContaining({ + score_threshold_enabled: true, + }), + }), + ) + }) + }) + + // Tests for accessibility + describe('Accessibility', () => { + it('should have accessible buttons', () => { + renderComponent() + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(3) // back, cancel, connect + }) + + it('should have proper link attributes for external links', () => { + renderComponent() + + const externalLink = screen.getByText('dataset.connectHelper.helper4') + expect(externalLink.tagName).toBe('A') + expect(externalLink).toHaveAttribute('target', '_blank') + expect(externalLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should have labels for form inputs', () => { + renderComponent() + + // Check labels exist + expect(screen.getByText('dataset.externalKnowledgeName')).toBeInTheDocument() + expect(screen.getByText('dataset.externalKnowledgeDescription')).toBeInTheDocument() + expect(screen.getByText('dataset.externalKnowledgeId')).toBeInTheDocument() + }) + }) +}) diff --git a/web/jest.setup.ts b/web/jest.setup.ts index ef9ede0492..383ba412a2 100644 --- a/web/jest.setup.ts +++ b/web/jest.setup.ts @@ -1,6 +1,19 @@ import '@testing-library/jest-dom' import { cleanup } from '@testing-library/react' +// Fix for @headlessui/react compatibility with happy-dom +// headlessui tries to set focus property which is read-only in happy-dom +if (typeof window !== 'undefined') { + // Ensure window.focus is writable for headlessui + if (!Object.getOwnPropertyDescriptor(window, 'focus')?.writable) { + Object.defineProperty(window, 'focus', { + value: jest.fn(), + writable: true, + configurable: true, + }) + } +} + afterEach(() => { cleanup() }) diff --git a/web/package.json b/web/package.json index 478abceb45..e7d732c7b6 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 02b1c9b592..af7856329e 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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 diff --git a/web/testing/testing.md b/web/testing/testing.md index f03451230d..bf1b89af00 100644 --- a/web/testing/testing.md +++ b/web/testing/testing.md @@ -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` 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 From 0867c1800b6fc0058735cec5d133fe038d005f46 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Wed, 10 Dec 2025 13:34:05 +0800 Subject: [PATCH 12/18] refactor: simplify plugin task handling and improve UI feedback (#26293) --- .../plugins/plugin-page/plugin-tasks/hooks.ts | 31 --- .../plugin-page/plugin-tasks/index.tsx | 252 +++++++++++++----- web/i18n/en-US/plugin.ts | 7 +- web/i18n/zh-Hans/plugin.ts | 7 +- web/service/use-plugins.ts | 3 +- 5 files changed, 201 insertions(+), 99 deletions(-) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts b/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts index fba7dad454..7b9e7953c6 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts +++ b/web/app/components/plugins/plugin-page/plugin-tasks/hooks.ts @@ -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(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, } } diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx index c0bf5824e7..4c37705287 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx @@ -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 ( -
+
{ > { - if (isFailed) + if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess) setOpen(v => !v) }} > - +
@@ -124,7 +169,7 @@ const PluginTasks = () => { ) } { - isSuccess && ( + (isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0 && errorPluginsLength === 0)) && ( ) } @@ -138,52 +183,129 @@ const PluginTasks = () => { -
-
- {t('plugin.task.installedError', { errorLength: errorPluginsLength })} - -
-
- { - errorPlugins.map(errorPlugin => ( -
-
- - -
-
-
- {errorPlugin.labels[language]} -
-
- {errorPlugin.message} -
-
- -
- )) - } -
+
+ + +
+
+
+ {runningPlugin.labels[language]} +
+
+ {t('plugin.task.installing')} +
+
+
+ ))} +
+ + )} + + {/* Success Plugins */} + {successPlugins.length > 0 && ( + <> +
+ {t('plugin.task.installed')} ({successPlugins.length}) + +
+
+ {successPlugins.map(successPlugin => ( +
+
+ + +
+
+
+ {successPlugin.labels[language]} +
+
+ {successPlugin.message || t('plugin.task.installed')} +
+
+
+ ))} +
+ + )} + + {/* Error Plugins */} + {errorPlugins.length > 0 && ( + <> +
+ {t('plugin.task.installError', { errorLength: errorPlugins.length })} + +
+
+ {errorPlugins.map(errorPlugin => ( +
+
+ + +
+
+
+ {errorPlugin.labels[language]} +
+
+ {errorPlugin.message} +
+
+ +
+ ))} +
+ + )}
diff --git a/web/i18n/en-US/plugin.ts b/web/i18n/en-US/plugin.ts index 62a5f35c0b..edd60d65fb 100644 --- a/web/i18n/en-US/plugin.ts +++ b/web/i18n/en-US/plugin.ts @@ -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', diff --git a/web/i18n/zh-Hans/plugin.ts b/web/i18n/zh-Hans/plugin.ts index d648bccb85..20b238f178 100644 --- a/web/i18n/zh-Hans/plugin.ts +++ b/web/i18n/zh-Hans/plugin.ts @@ -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: '发布插件', diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index b5b8779a82..639d889fa0 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -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}`) }, }) } From e8720de9ad998ac49a9cfa7049486cf16777ac01 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:52:54 +0800 Subject: [PATCH 13/18] chore(i18n): translate i18n files and update type definitions (#29395) Co-authored-by: zhsama <33454514+zhsama@users.noreply.github.com> --- web/i18n/de-DE/plugin.ts | 5 +++++ web/i18n/es-ES/plugin.ts | 5 +++++ web/i18n/fa-IR/plugin.ts | 5 +++++ web/i18n/fr-FR/plugin.ts | 5 +++++ web/i18n/hi-IN/plugin.ts | 5 +++++ web/i18n/id-ID/plugin.ts | 5 +++++ web/i18n/it-IT/plugin.ts | 5 +++++ web/i18n/ja-JP/plugin.ts | 5 +++++ web/i18n/ko-KR/plugin.ts | 5 +++++ web/i18n/pl-PL/plugin.ts | 5 +++++ web/i18n/pt-BR/plugin.ts | 5 +++++ web/i18n/ro-RO/plugin.ts | 5 +++++ web/i18n/ru-RU/plugin.ts | 5 +++++ web/i18n/sl-SI/plugin.ts | 5 +++++ web/i18n/th-TH/plugin.ts | 5 +++++ web/i18n/tr-TR/plugin.ts | 5 +++++ web/i18n/uk-UA/plugin.ts | 5 +++++ web/i18n/vi-VN/plugin.ts | 5 +++++ web/i18n/zh-Hant/plugin.ts | 5 +++++ 19 files changed, 95 insertions(+) diff --git a/web/i18n/de-DE/plugin.ts b/web/i18n/de-DE/plugin.ts index 18da902a6c..50d42b3671 100644 --- a/web/i18n/de-DE/plugin.ts +++ b/web/i18n/de-DE/plugin.ts @@ -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', diff --git a/web/i18n/es-ES/plugin.ts b/web/i18n/es-ES/plugin.ts index 76e9f27c39..2452161791 100644 --- a/web/i18n/es-ES/plugin.ts +++ b/web/i18n/es-ES/plugin.ts @@ -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', diff --git a/web/i18n/fa-IR/plugin.ts b/web/i18n/fa-IR/plugin.ts index 3b171a01ce..030ca0022d 100644 --- a/web/i18n/fa-IR/plugin.ts +++ b/web/i18n/fa-IR/plugin.ts @@ -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', diff --git a/web/i18n/fr-FR/plugin.ts b/web/i18n/fr-FR/plugin.ts index e1e7ae14ef..f19f08eb6f 100644 --- a/web/i18n/fr-FR/plugin.ts +++ b/web/i18n/fr-FR/plugin.ts @@ -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', diff --git a/web/i18n/hi-IN/plugin.ts b/web/i18n/hi-IN/plugin.ts index c8a5618e1f..8e65877b58 100644 --- a/web/i18n/hi-IN/plugin.ts +++ b/web/i18n/hi-IN/plugin.ts @@ -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: 'मार्केटप्लेस से', diff --git a/web/i18n/id-ID/plugin.ts b/web/i18n/id-ID/plugin.ts index bcf9d88b34..d849777121 100644 --- a/web/i18n/id-ID/plugin.ts +++ b/web/i18n/id-ID/plugin.ts @@ -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', diff --git a/web/i18n/it-IT/plugin.ts b/web/i18n/it-IT/plugin.ts index ac5deb2ed3..aaa5803550 100644 --- a/web/i18n/it-IT/plugin.ts +++ b/web/i18n/it-IT/plugin.ts @@ -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', diff --git a/web/i18n/ja-JP/plugin.ts b/web/i18n/ja-JP/plugin.ts index 3b7985668a..d79baeb3b1 100644 --- a/web/i18n/ja-JP/plugin.ts +++ b/web/i18n/ja-JP/plugin.ts @@ -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}} インストール', diff --git a/web/i18n/ko-KR/plugin.ts b/web/i18n/ko-KR/plugin.ts index 875776d700..710490b9fb 100644 --- a/web/i18n/ko-KR/plugin.ts +++ b/web/i18n/ko-KR/plugin.ts @@ -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: '검색 도구...', diff --git a/web/i18n/pl-PL/plugin.ts b/web/i18n/pl-PL/plugin.ts index dcd799ae2e..e4d9081217 100644 --- a/web/i18n/pl-PL/plugin.ts +++ b/web/i18n/pl-PL/plugin.ts @@ -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', diff --git a/web/i18n/pt-BR/plugin.ts b/web/i18n/pt-BR/plugin.ts index 0fc620579c..b24d37ee63 100644 --- a/web/i18n/pt-BR/plugin.ts +++ b/web/i18n/pt-BR/plugin.ts @@ -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', diff --git a/web/i18n/ro-RO/plugin.ts b/web/i18n/ro-RO/plugin.ts index 547dbe0942..e3db03a057 100644 --- a/web/i18n/ro-RO/plugin.ts +++ b/web/i18n/ro-RO/plugin.ts @@ -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', diff --git a/web/i18n/ru-RU/plugin.ts b/web/i18n/ru-RU/plugin.ts index 55061a34f5..23214fb195 100644 --- a/web/i18n/ru-RU/plugin.ts +++ b/web/i18n/ru-RU/plugin.ts @@ -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: 'Поиск категорий', diff --git a/web/i18n/sl-SI/plugin.ts b/web/i18n/sl-SI/plugin.ts index ca8594df2f..ea99b649bf 100644 --- a/web/i18n/sl-SI/plugin.ts +++ b/web/i18n/sl-SI/plugin.ts @@ -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', diff --git a/web/i18n/th-TH/plugin.ts b/web/i18n/th-TH/plugin.ts index 64705b7e04..8f59a5bded 100644 --- a/web/i18n/th-TH/plugin.ts +++ b/web/i18n/th-TH/plugin.ts @@ -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', diff --git a/web/i18n/tr-TR/plugin.ts b/web/i18n/tr-TR/plugin.ts index 8aa60e0e7b..bdc10bc753 100644 --- a/web/i18n/tr-TR/plugin.ts +++ b/web/i18n/tr-TR/plugin.ts @@ -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', diff --git a/web/i18n/uk-UA/plugin.ts b/web/i18n/uk-UA/plugin.ts index 2d2683026d..948edc0c82 100644 --- a/web/i18n/uk-UA/plugin.ts +++ b/web/i18n/uk-UA/plugin.ts @@ -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', diff --git a/web/i18n/vi-VN/plugin.ts b/web/i18n/vi-VN/plugin.ts index 6eb4e9fbe5..127738f849 100644 --- a/web/i18n/vi-VN/plugin.ts +++ b/web/i18n/vi-VN/plugin.ts @@ -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', diff --git a/web/i18n/zh-Hant/plugin.ts b/web/i18n/zh-Hant/plugin.ts index 5ec8936257..d2809f9971 100644 --- a/web/i18n/zh-Hant/plugin.ts +++ b/web/i18n/zh-Hant/plugin.ts @@ -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: '發佈插件', From b49e2646ff51020bb9eadcc6b8c1505b95e10bc7 Mon Sep 17 00:00:00 2001 From: Jyong <76649700+JohnJyong@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:08:55 +0800 Subject: [PATCH 14/18] fix: session unbound during parent-child retrieval (#29396) --- api/core/rag/datasource/retrieval_service.py | 17 +++++------------ api/core/rag/retrieval/dataset_retrieval.py | 4 ++-- api/core/workflow/nodes/llm/node.py | 4 ++-- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py index e644e754ec..e4ca25b46b 100644 --- a/api/core/rag/datasource/retrieval_service.py +++ b/api/core/rag/datasource/retrieval_service.py @@ -371,7 +371,7 @@ class RetrievalService: include_segment_ids = set() segment_child_map = {} segment_file_map = {} - with Session(db.engine) as session: + with Session(bind=db.engine, expire_on_commit=False) as session: # Process documents for document in documents: segment_id = None @@ -395,7 +395,7 @@ class RetrievalService: session, ) if attachment_info_dict: - attachment_info = attachment_info_dict["attchment_info"] + attachment_info = attachment_info_dict["attachment_info"] segment_id = attachment_info_dict["segment_id"] else: child_index_node_id = document.metadata.get("doc_id") @@ -417,13 +417,6 @@ class RetrievalService: DocumentSegment.status == "completed", DocumentSegment.id == segment_id, ) - .options( - load_only( - DocumentSegment.id, - DocumentSegment.content, - DocumentSegment.answer, - ) - ) .first() ) @@ -475,7 +468,7 @@ class RetrievalService: session, ) if attachment_info_dict: - attachment_info = attachment_info_dict["attchment_info"] + attachment_info = attachment_info_dict["attachment_info"] segment_id = attachment_info_dict["segment_id"] document_segment_stmt = select(DocumentSegment).where( DocumentSegment.dataset_id == dataset_document.dataset_id, @@ -684,7 +677,7 @@ class RetrievalService: .first() ) if attachment_binding: - attchment_info = { + attachment_info = { "id": upload_file.id, "name": upload_file.name, "extension": "." + upload_file.extension, @@ -692,5 +685,5 @@ class RetrievalService: "source_url": sign_upload_file(upload_file.id, upload_file.extension), "size": upload_file.size, } - return {"attchment_info": attchment_info, "segment_id": attachment_binding.segment_id} + return {"attachment_info": attachment_info, "segment_id": attachment_binding.segment_id} return None diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index ec55d2d0cc..a65069b1b7 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -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 diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index a5973862b2..04e2802191 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -697,7 +697,7 @@ class LLMNode(Node[LLMNodeData]): ).all() if attachments_with_bindings: for _, upload_file in attachments_with_bindings: - attchment_info = File( + attachment_info = File( id=upload_file.id, filename=upload_file.name, extension="." + upload_file.extension, @@ -711,7 +711,7 @@ class LLMNode(Node[LLMNodeData]): storage_key=upload_file.key, url=sign_upload_file(upload_file.id, upload_file.extension), ) - context_files.append(attchment_info) + context_files.append(attachment_info) yield RunRetrieverResourceEvent( retriever_resources=original_retriever_resource, context=context_str.strip(), From 12d019cd3181e9618722123e39db7d1a3c2d3eea Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 10 Dec 2025 14:40:48 +0800 Subject: [PATCH 15/18] fix: improve compatibility of @headlessui/react with happy-dom by ensuring HTMLElement.prototype.focus is writable (#29399) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- web/jest.setup.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/web/jest.setup.ts b/web/jest.setup.ts index 383ba412a2..006b28322e 100644 --- a/web/jest.setup.ts +++ b/web/jest.setup.ts @@ -2,16 +2,22 @@ import '@testing-library/jest-dom' import { cleanup } from '@testing-library/react' // Fix for @headlessui/react compatibility with happy-dom -// headlessui tries to set focus property which is read-only in happy-dom +// headlessui tries to override focus properties which may be read-only in happy-dom if (typeof window !== 'undefined') { - // Ensure window.focus is writable for headlessui - if (!Object.getOwnPropertyDescriptor(window, 'focus')?.writable) { - Object.defineProperty(window, 'focus', { - value: jest.fn(), - writable: true, - configurable: true, - }) + 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(() => { From 88b20bc6d00cc668d733c69e0316af8c8f71e734 Mon Sep 17 00:00:00 2001 From: Jyong <76649700+JohnJyong@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:18:38 +0800 Subject: [PATCH 16/18] fix dataset multimodal field not update (#29403) --- api/controllers/console/datasets/datasets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index c0422ef6f4..70b6e932e9 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -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 From bafd093fa98b8ecfd69cddba5bebfd83412e5f84 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:41:05 +0800 Subject: [PATCH 17/18] fix: Add dataset file upload restrictions (#29397) Co-authored-by: kurokobo Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .../base/notion-page-selector/base.tsx | 5 +- .../credential-selector/index.tsx | 19 ++++---- .../page-selector/index.tsx | 48 +++++++++++++------ .../datasets/common/credential-icon.tsx | 17 ++++--- .../datasets/create/file-uploader/index.tsx | 22 ++++----- .../datasets/create/step-one/index.tsx | 5 +- .../website/base/crawled-result-item.tsx | 19 +++++++- .../create/website/base/crawled-result.tsx | 29 +++++++---- .../create/website/firecrawl/index.tsx | 7 ++- .../datasets/create/website/index.tsx | 5 ++ .../create/website/jina-reader/index.tsx | 9 ++-- .../datasets/create/website/preview.tsx | 2 +- .../create/website/watercrawl/index.tsx | 9 ++-- .../base/credential-selector/index.tsx | 4 -- .../base/credential-selector/item.tsx | 11 +---- .../base/credential-selector/list.tsx | 3 -- .../base/credential-selector/trigger.tsx | 12 +---- .../data-source/base/header.tsx | 4 +- .../data-source/local-file/index.tsx | 24 +++++----- .../data-source/online-documents/index.tsx | 6 ++- .../online-drive/file-list/index.tsx | 4 +- .../online-drive/file-list/list/index.tsx | 6 +-- .../data-source/online-drive/index.tsx | 13 +++-- .../base/crawled-result-item.tsx | 1 + .../data-source/website-crawl/index.tsx | 12 +++-- .../documents/create-from-pipeline/index.tsx | 20 ++++---- .../preview/web-preview.tsx | 4 +- .../detail/settings/document-settings.tsx | 2 +- .../settings/pipeline-settings/index.tsx | 4 +- .../panel/test-run/preparation/index.tsx | 11 +++-- .../nodes/data-source/before-run-form.tsx | 9 ++-- web/i18n/en-US/dataset-pipeline.ts | 3 -- web/i18n/ja-JP/dataset-pipeline.ts | 3 -- web/i18n/zh-Hans/dataset-pipeline.ts | 3 -- web/models/datasets.ts | 2 +- 35 files changed, 206 insertions(+), 151 deletions(-) diff --git a/web/app/components/base/notion-page-selector/base.tsx b/web/app/components/base/notion-page-selector/base.tsx index 1f9ddeaebd..9315605cdf 100644 --- a/web/app/components/base/notion-page-selector/base.tsx +++ b/web/app/components/base/notion-page-selector/base.tsx @@ -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) => { const selectedPages = Array.from(newSelectedPagesId).map(pageId => pagesMapAndSelectedPagesId[0][pageId]) @@ -175,6 +177,7 @@ const NotionPageSelector = ({ canPreview={canPreview} previewPageId={previewPageId} onPreview={handlePreviewPage} + isMultipleChoice={supportBatchUpload} /> )}
diff --git a/web/app/components/base/notion-page-selector/credential-selector/index.tsx b/web/app/components/base/notion-page-selector/credential-selector/index.tsx index f0ec399544..360a38ba8f 100644 --- a/web/app/components/base/notion-page-selector/credential-selector/index.tsx +++ b/web/app/components/base/notion-page-selector/credential-selector/index.tsx @@ -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 }) => ( <> -
onSelect(item.credentialId)} > -
@@ -18,6 +19,7 @@ type PageSelectorProps = { canPreview?: boolean previewPageId?: string onPreview?: (selectedPageId: string) => void + isMultipleChoice?: boolean } type NotionPageTreeItem = { children: Set @@ -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)' }} > - { - if (disabled) - return - handleCheck(index) - }} - /> + {isMultipleChoice ? ( + { + handleCheck(index) + }} + />) : ( + { + handleCheck(index) + }} + /> + )} {!searchValue && renderArrow()} { const { t } = useTranslation() const [dataList, setDataList] = useState([]) @@ -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} diff --git a/web/app/components/datasets/common/credential-icon.tsx b/web/app/components/datasets/common/credential-icon.tsx index 5a25963f3b..d4e6fd69ac 100644 --- a/web/app/components/datasets/common/credential-icon.tsx +++ b/web/app/components/datasets/common/credential-icon.tsx @@ -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 = ({ - 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 = ({ setShowAvatar(false) }, []) - if (avatar_url && avatar_url !== 'default' && showAvatar) { + if (avatarUrl && avatarUrl !== 'default' && showAvatar) { return (
diff --git a/web/app/components/datasets/create/file-uploader/index.tsx b/web/app/components/datasets/create/file-uploader/index.tsx index abe2564ad2..700a5f7680 100644 --- a/web/app/components/datasets/create/file-uploader/index.tsx +++ b/web/app/components/datasets/create/file-uploader/index.tsx @@ -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(null) const dragRef = useRef(null) const fileUploader = useRef(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([]) @@ -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 = ({ - {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 && ( )} @@ -326,7 +326,7 @@ const FileUploader = ({
{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, })}
{dragging &&
} diff --git a/web/app/components/datasets/create/step-one/index.tsx b/web/app/components/datasets/create/step-one/index.tsx index cab1637661..f2768be470 100644 --- a/web/app/components/datasets/create/step-one/index.tsx +++ b/web/app/components/datasets/create/step-one/index.tsx @@ -110,7 +110,7 @@ const StepOne = ({ const hasNotin = notionPages.length > 0 const isVectorSpaceFull = plan.usage.vectorSpace >= plan.total.vectorSpace const isShowVectorSpaceFull = (allFileLoaded || hasNotin) && isVectorSpaceFull && enableBilling - const notSupportBatchUpload = enableBilling && plan.type === 'sandbox' + const supportBatchUpload = !enableBilling || plan.type !== 'sandbox' const nextDisabled = useMemo(() => { if (!files.length) return true @@ -229,7 +229,7 @@ const StepOne = ({ onFileListUpdate={updateFileList} onFileUpdate={updateFile} onPreview={updateCurrentFile} - notSupportBatchUpload={notSupportBatchUpload} + supportBatchUpload={supportBatchUpload} /> {isShowVectorSpaceFull && (
@@ -259,6 +259,7 @@ const StepOne = ({ credentialList={notionCredentialList} onSelectCredential={updateNotionCredentialId} datasetId={datasetId} + supportBatchUpload={supportBatchUpload} />
{isShowVectorSpaceFull && ( diff --git a/web/app/components/datasets/create/website/base/crawled-result-item.tsx b/web/app/components/datasets/create/website/base/crawled-result-item.tsx index 8ea316f62a..51e043c35a 100644 --- a/web/app/components/datasets/create/website/base/crawled-result-item.tsx +++ b/web/app/components/datasets/create/website/base/crawled-result-item.tsx @@ -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 = ({ @@ -21,6 +23,7 @@ const CrawledResultItem: FC = ({ isChecked, onCheckChange, onPreview, + isMultipleChoice, }) => { const { t } = useTranslation() @@ -31,7 +34,21 @@ const CrawledResultItem: FC = ({
- + { + isMultipleChoice ? ( + + ) : ( + + ) + }
void onPreview: (payload: CrawlResultItem) => void usedTime: number + isMultipleChoice: boolean } const CrawledResult: FC = ({ @@ -25,6 +26,7 @@ const CrawledResult: FC = ({ onSelectedChange, onPreview, usedTime, + isMultipleChoice, }) => { const { t } = useTranslation() @@ -40,13 +42,17 @@ const CrawledResult: FC = ({ 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(-1) const handlePreview = useCallback((index: number) => { @@ -59,11 +65,13 @@ const CrawledResult: FC = ({ return (
- + {isMultipleChoice && ( + + )}
{t(`${I18N_PREFIX}.scrapTimeInfo`, { total: list.length, @@ -80,6 +88,7 @@ const CrawledResult: FC = ({ payload={item} isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)} onCheckChange={handleItemCheckChange(item)} + isMultipleChoice={isMultipleChoice} /> ))}
diff --git a/web/app/components/datasets/create/website/firecrawl/index.tsx b/web/app/components/datasets/create/website/firecrawl/index.tsx index 51c2c7d505..1ef934308a 100644 --- a/web/app/components/datasets/create/website/firecrawl/index.tsx +++ b/web/app/components/datasets/create/website/firecrawl/index.tsx @@ -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 = ({ onJobIdChange, crawlOptions, onCrawlOptionsChange, + supportBatchUpload, }) => { const { t } = useTranslation() const [step, setStep] = useState(Step.init) @@ -171,7 +173,7 @@ const FireCrawl: FC = ({ 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 = ({ finally { setStep(Step.finished) } - }, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished, onCheckedCrawlResultChange]) + }, [checkValid, crawlOptions, onJobIdChange, waitForCrawlFinished, t, onCheckedCrawlResultChange, supportBatchUpload]) return (
@@ -221,6 +223,7 @@ const FireCrawl: FC = ({ onSelectedChange={onCheckedCrawlResultChange} onPreview={onPreview} usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0} + isMultipleChoice={supportBatchUpload} /> }
diff --git a/web/app/components/datasets/create/website/index.tsx b/web/app/components/datasets/create/website/index.tsx index ee7ace6815..15324f642e 100644 --- a/web/app/components/datasets/create/website/index.tsx +++ b/web/app/components/datasets/create/website/index.tsx @@ -24,6 +24,7 @@ type Props = { crawlOptions: CrawlOptions onCrawlOptionsChange: (payload: CrawlOptions) => void authedDataSourceList: DataSourceAuth[] + supportBatchUpload?: boolean } const Website: FC = ({ @@ -35,6 +36,7 @@ const Website: FC = ({ crawlOptions, onCrawlOptionsChange, authedDataSourceList, + supportBatchUpload = false, }) => { const { t } = useTranslation() const { setShowAccountSettingModal } = useModalContext() @@ -116,6 +118,7 @@ const Website: FC = ({ onJobIdChange={onJobIdChange} crawlOptions={crawlOptions} onCrawlOptionsChange={onCrawlOptionsChange} + supportBatchUpload={supportBatchUpload} /> )} {source && selectedProvider === DataSourceProvider.waterCrawl && ( @@ -126,6 +129,7 @@ const Website: FC = ({ onJobIdChange={onJobIdChange} crawlOptions={crawlOptions} onCrawlOptionsChange={onCrawlOptionsChange} + supportBatchUpload={supportBatchUpload} /> )} {source && selectedProvider === DataSourceProvider.jinaReader && ( @@ -136,6 +140,7 @@ const Website: FC = ({ onJobIdChange={onJobIdChange} crawlOptions={crawlOptions} onCrawlOptionsChange={onCrawlOptionsChange} + supportBatchUpload={supportBatchUpload} /> )} {!source && ( diff --git a/web/app/components/datasets/create/website/jina-reader/index.tsx b/web/app/components/datasets/create/website/jina-reader/index.tsx index b6e6177af2..b2189b3e5c 100644 --- a/web/app/components/datasets/create/website/jina-reader/index.tsx +++ b/web/app/components/datasets/create/website/jina-reader/index.tsx @@ -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 = ({ onJobIdChange, crawlOptions, onCrawlOptionsChange, + supportBatchUpload, }) => { const { t } = useTranslation() const [step, setStep] = useState(Step.init) @@ -157,7 +159,7 @@ const JinaReader: FC = ({ total: 1, data: [{ title, - content, + markdown: content, description, source_url: url, }], @@ -176,7 +178,7 @@ const JinaReader: FC = ({ } 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 = ({ finally { setStep(Step.finished) } - }, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, t, waitForCrawlFinished]) + }, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, supportBatchUpload, t, waitForCrawlFinished]) return (
@@ -227,6 +229,7 @@ const JinaReader: FC = ({ onSelectedChange={onCheckedCrawlResultChange} onPreview={onPreview} usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0} + isMultipleChoice={supportBatchUpload} /> }
diff --git a/web/app/components/datasets/create/website/preview.tsx b/web/app/components/datasets/create/website/preview.tsx index d148c87196..f43dc83589 100644 --- a/web/app/components/datasets/create/website/preview.tsx +++ b/web/app/components/datasets/create/website/preview.tsx @@ -32,7 +32,7 @@ const WebsitePreview = ({
{payload.source_url}
-
{payload.content}
+
{payload.markdown}
) diff --git a/web/app/components/datasets/create/website/watercrawl/index.tsx b/web/app/components/datasets/create/website/watercrawl/index.tsx index 67a3e53feb..bf0048b788 100644 --- a/web/app/components/datasets/create/website/watercrawl/index.tsx +++ b/web/app/components/datasets/create/website/watercrawl/index.tsx @@ -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 = ({ onJobIdChange, crawlOptions, onCrawlOptionsChange, + supportBatchUpload, }) => { const { t } = useTranslation() const [step, setStep] = useState(Step.init) @@ -132,7 +134,7 @@ const WaterCrawl: FC = ({ }, } } - }, [crawlOptions.limit]) + }, [crawlOptions.limit, onCheckedCrawlResultChange]) const handleRun = useCallback(async (url: string) => { const { isValid, errorMsg } = checkValid(url) @@ -163,7 +165,7 @@ const WaterCrawl: FC = ({ } 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 = ({ finally { setStep(Step.finished) } - }, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished]) + }, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, supportBatchUpload, t, waitForCrawlFinished]) return (
@@ -213,6 +215,7 @@ const WaterCrawl: FC = ({ onSelectedChange={onCheckedCrawlResultChange} onPreview={onPreview} usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0} + isMultipleChoice={supportBatchUpload} /> }
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.tsx index 0de3879969..0e588e4e1d 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.tsx @@ -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 } const CredentialSelector = ({ - pluginName, currentCredentialId, onCredentialChange, credentials, @@ -50,7 +48,6 @@ const CredentialSelector = ({ @@ -58,7 +55,6 @@ const CredentialSelector = ({ diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx index ab8de51fb1..9c8368e299 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx @@ -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} > - {t('datasetPipeline.credentialSelector.name', { - credentialName: name, - pluginName, - })} + {name} { isSelected && ( diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/list.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/list.tsx index b161a80309..cdcb2b5af5 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/list.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/list.tsx @@ -5,14 +5,12 @@ import Item from './item' type ListProps = { currentCredentialId: string credentials: Array - pluginName: string onCredentialChange: (credentialId: string) => void } const List = ({ currentCredentialId, credentials, - pluginName, onCredentialChange, }: ListProps) => { return ( @@ -24,7 +22,6 @@ const List = ({ diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/trigger.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/trigger.tsx index 88f47384f3..dc328ef87f 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/trigger.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/trigger.tsx @@ -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 = ({ )} >
- {t('datasetPipeline.credentialSelector.name', { - credentialName: name, - pluginName, - })} + {name}
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx index ef8932ba24..b826e53d93 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx @@ -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 = ({ />
) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx index 753b32c396..bdfcddfd77 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/crawled-result-item.tsx @@ -46,6 +46,7 @@ const CrawledResultItem = ({ /> ) : ( diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx index 648f6a5d93..513ac8edd9 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx @@ -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) => { handleRun(value) @@ -149,7 +151,7 @@ const WebsiteCrawl = ({ setTotalNum(0) setCrawlErrorMessage('') onCredentialChange(credentialId) - }, [dataSourceStore, onCredentialChange]) + }, [onCredentialChange]) return (
@@ -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 /> )}
diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/index.tsx index 77b77700ca..1d9232403a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/index.tsx @@ -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 && ( )} {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 && ( diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.tsx index bae4deb86e..ce7a5da24c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.tsx @@ -27,7 +27,7 @@ const WebsitePreview = ({ {currentWebsite.source_url} · · - {`${formatNumberAbbreviated(currentWebsite.content.length)} ${t('datasetPipeline.addDocuments.characters')}`} + {`${formatNumberAbbreviated(currentWebsite.markdown.length)} ${t('datasetPipeline.addDocuments.characters')}`}
- {currentWebsite.content} + {currentWebsite.markdown}
) diff --git a/web/app/components/datasets/documents/detail/settings/document-settings.tsx b/web/app/components/datasets/documents/detail/settings/document-settings.tsx index 3bcb8ef3aa..16c90c925f 100644 --- a/web/app/components/datasets/documents/detail/settings/document-settings.tsx +++ b/web/app/components/datasets/documents/detail/settings/document-settings.tsx @@ -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]) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx index 1ab47be445..0381222415 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx @@ -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 diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.tsx index eb73599314..c659d8669a 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.tsx @@ -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 ( <> @@ -164,7 +164,7 @@ const Preparation = () => { {datasourceType === DatasourceType.localFile && ( )} {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} /> )}
diff --git a/web/app/components/workflow/nodes/data-source/before-run-form.tsx b/web/app/components/workflow/nodes/data-source/before-run-form.tsx index 764599b4cb..521fdfb087 100644 --- a/web/app/components/workflow/nodes/data-source/before-run-form.tsx +++ b/web/app/components/workflow/nodes/data-source/before-run-form.tsx @@ -43,13 +43,13 @@ const BeforeRunForm: FC = (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 ( = (props) => { {datasourceType === DatasourceType.localFile && ( )} {datasourceType === DatasourceType.onlineDocument && ( @@ -69,6 +69,7 @@ const BeforeRunForm: FC = (props) => { nodeData={datasourceNodeData} isInPipeline onCredentialChange={handleCredentialChange} + supportBatchUpload={false} /> )} {datasourceType === DatasourceType.websiteCrawl && ( @@ -77,6 +78,7 @@ const BeforeRunForm: FC = (props) => { nodeData={datasourceNodeData} isInPipeline onCredentialChange={handleCredentialChange} + supportBatchUpload={false} /> )} {datasourceType === DatasourceType.onlineDrive && ( @@ -85,6 +87,7 @@ const BeforeRunForm: FC = (props) => { nodeData={datasourceNodeData} isInPipeline onCredentialChange={handleCredentialChange} + supportBatchUpload={false} /> )}
diff --git a/web/i18n/en-US/dataset-pipeline.ts b/web/i18n/en-US/dataset-pipeline.ts index c83d358eec..29237e844a 100644 --- a/web/i18n/en-US/dataset-pipeline.ts +++ b/web/i18n/en-US/dataset-pipeline.ts @@ -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', diff --git a/web/i18n/ja-JP/dataset-pipeline.ts b/web/i18n/ja-JP/dataset-pipeline.ts index 0dddb25356..5091c17807 100644 --- a/web/i18n/ja-JP/dataset-pipeline.ts +++ b/web/i18n/ja-JP/dataset-pipeline.ts @@ -137,9 +137,6 @@ const translation = { emptySearchResult: 'アイテムは見つかりませんでした', resetKeywords: 'キーワードをリセットする', }, - credentialSelector: { - name: '{{credentialName}}の{{pluginName}}', - }, configurationTip: '{{pluginName}}を設定', conversion: { confirm: { diff --git a/web/i18n/zh-Hans/dataset-pipeline.ts b/web/i18n/zh-Hans/dataset-pipeline.ts index 7fbe8a0532..0e23d7a1e0 100644 --- a/web/i18n/zh-Hans/dataset-pipeline.ts +++ b/web/i18n/zh-Hans/dataset-pipeline.ts @@ -145,9 +145,6 @@ const translation = { emptySearchResult: '未找到任何项目', resetKeywords: '重置关键词', }, - credentialSelector: { - name: '{{credentialName}} 的 {{pluginName}}', - }, configurationTip: '配置 {{pluginName}}', conversion: { title: '转换为知识流水线', diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 574897a9b4..fe4c568e46 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -156,7 +156,7 @@ export type CrawlOptions = { export type CrawlResultItem = { title: string - content: string + markdown: string description: string source_url: string } From e477e6c9286ea68100da1bd06613f89b6ecdee57 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:46:48 +0800 Subject: [PATCH 18/18] fix: harden async window open placeholder logic (#29393) --- .../components/app/app-publisher/index.tsx | 33 +++-- web/app/components/apps/app-card.tsx | 32 ++--- .../components/billing/billing-page/index.tsx | 31 ++--- .../pricing/plans/cloud-plan-item/index.tsx | 18 +-- web/hooks/use-async-window-open.spec.ts | 116 ++++++++++++++++++ web/hooks/use-async-window-open.ts | 105 +++++++--------- 6 files changed, 213 insertions(+), 122 deletions(-) create mode 100644 web/hooks/use-async-window-open.spec.ts diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 801345798b..2dc45e1337 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -21,6 +21,7 @@ import { import { useKeyPress } from 'ahooks' import Divider from '../../base/divider' import Loading from '../../base/loading' +import Toast from '../../base/toast' import Tooltip from '../../base/tooltip' import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../../workflow/utils' import AccessControl from '../app-access-control' @@ -41,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' @@ -49,7 +51,6 @@ import { AppModeEnum } from '@/types/app' import type { PublishWorkflowParams } from '@/types/workflow' import { basePath } from '@/utils/var' import UpgradeBtn from '@/app/components/billing/upgrade-btn' -import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' const ACCESS_MODE_MAP: Record = { [AccessMode.ORGANIZATION]: { @@ -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]) @@ -216,23 +218,20 @@ const AppPublisher = ({ setPublished(false) }, [disabled, onToggle, open]) - const { openAsync } = useAsyncWindowOpen() - - const handleOpenInExplore = useCallback(() => { - if (!appDetail?.id) return - - openAsync( - async () => { - const { installed_apps }: { installed_apps?: { id: string }[] } = await fetchInstalledAppList(appDetail.id) || {} - if (installed_apps && installed_apps.length > 0) - return `${basePath}/explore/installed/${installed_apps[0].id}` - throw new Error('No app found in Explore') + const handleOpenInExplore = useCallback(async () => { + 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) + 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}` }) }, - { - errorMessage: 'Failed to open app in Explore', - }, - ) - }, [appDetail?.id, openAsync]) + }) + }, [appDetail?.id, openAsyncWindow]) const handleAccessControlUpdate = useCallback(async () => { if (!appDetail) diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 407df23913..b8da0264e4 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next' import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react' import cn from '@/utils/classnames' import { type App, AppModeEnum } from '@/types/app' -import { ToastContext } from '@/app/components/base/toast' +import Toast, { ToastContext } from '@/app/components/base/toast' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import AppIcon from '@/app/components/base/app-icon' @@ -27,11 +27,11 @@ 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' import { useGetUserCanAccessApp } from '@/service/access-control' -import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import dynamic from 'next/dynamic' const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { @@ -65,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) @@ -243,24 +244,25 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { e.preventDefault() setShowAccessControl(true) } - const { openAsync } = useAsyncWindowOpen() - - const onClickInstalledApp = (e: React.MouseEvent) => { + const onClickInstalledApp = async (e: React.MouseEvent) => { e.stopPropagation() props.onClick?.() e.preventDefault() - - openAsync( - async () => { - const { installed_apps }: { installed_apps?: { id: string }[] } = await fetchInstalledAppList(app.id) || {} - if (installed_apps && installed_apps.length > 0) + try { + 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') - }, - { - errorMessage: 'Failed to open app in Explore', - }, - ) + }, { + onError: (err) => { + Toast.notify({ type: 'error', message: `${err.message || err}` }) + }, + }) + } + catch (e: any) { + Toast.notify({ type: 'error', message: `${e.message || e}` }) + } } return (
diff --git a/web/app/components/billing/billing-page/index.tsx b/web/app/components/billing/billing-page/index.tsx index adb676cde1..590219c2d5 100644 --- a/web/app/components/billing/billing-page/index.tsx +++ b/web/app/components/billing/billing-page/index.tsx @@ -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 ( diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx index 164ad9061a..52c2883b81 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx @@ -43,6 +43,7 @@ const CloudPlanItem: FC = ({ 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) @@ -55,8 +56,6 @@ const CloudPlanItem: FC = ({ })[plan] }, [isCurrent, plan, t]) - const { openAsync } = useAsyncWindowOpen() - const handleGetPayUrl = async () => { if (loading) return @@ -75,13 +74,16 @@ const CloudPlanItem: FC = ({ setLoading(true) try { if (isCurrentPaidPlan) { - await openAsync( - () => fetchBillingUrl().then(res => res.url), - { - errorMessage: 'Failed to open billing page', - windowFeatures: 'noopener,noreferrer', + 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 } diff --git a/web/hooks/use-async-window-open.spec.ts b/web/hooks/use-async-window-open.spec.ts new file mode 100644 index 0000000000..63ec9185da --- /dev/null +++ b/web/hooks/use-async-window-open.spec.ts @@ -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() + }) +}) diff --git a/web/hooks/use-async-window-open.ts b/web/hooks/use-async-window-open.ts index 582ab28be4..e3d7910217 100644 --- a/web/hooks/use-async-window-open.ts +++ b/web/hooks/use-async-window-open.ts @@ -1,72 +1,49 @@ import { useCallback } from 'react' -import Toast from '@/app/components/base/toast' -export type AsyncWindowOpenOptions = { - successMessage?: string - errorMessage?: string - windowFeatures?: string - onError?: (error: any) => void - onSuccess?: (url: string) => void +type GetUrl = () => Promise + +type AsyncWindowOpenOptions = { + immediateUrl?: string | null + target?: string + features?: string + onError?: (error: Error) => void } -export const useAsyncWindowOpen = () => { - const openAsync = useCallback(async ( - fetchUrl: () => Promise, - options: AsyncWindowOpenOptions = {}, - ) => { - const { - successMessage, - errorMessage = 'Failed to open page', - windowFeatures = 'noopener,noreferrer', - onError, - onSuccess, - } = options +export const useAsyncWindowOpen = () => useCallback(async (getUrl: GetUrl, options?: AsyncWindowOpenOptions) => { + const { + immediateUrl, + target = '_blank', + features, + onError, + } = options ?? {} - const newWindow = window.open('', '_blank', windowFeatures) + if (immediateUrl) { + window.open(immediateUrl, target, features) + return + } - if (!newWindow) { - const error = new Error('Popup blocked by browser') - onError?.(error) - Toast.notify({ - type: 'error', - message: 'Popup blocked. Please allow popups for this site.', - }) + 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 } - - try { - const url = await fetchUrl() - - if (url) { - newWindow.location.href = url - onSuccess?.(url) - - if (successMessage) { - Toast.notify({ - type: 'success', - message: successMessage, - }) - } - } - else { - newWindow.close() - const error = new Error('Invalid URL received') - onError?.(error) - Toast.notify({ - type: 'error', - message: errorMessage, - }) - } - } - catch (error) { - newWindow.close() - onError?.(error) - Toast.notify({ - type: 'error', - message: errorMessage, - }) - } - }, []) - - return { openAsync } -} + newWindow.close() + onError?.(new Error('No url resolved for new window')) + } + catch (error) { + newWindow.close() + onError?.(error instanceof Error ? error : new Error(String(error))) + } +}, [])