Merge branch 'main' into jzh

This commit is contained in:
JzoNg 2026-04-21 21:01:05 +08:00
commit 597ad8c425
100 changed files with 3928 additions and 2209 deletions

4
.gitignore vendored
View File

@ -237,6 +237,10 @@ scripts/stress-test/reports/
.playwright-mcp/
.serena/
# vitest browser mode attachments (failure screenshots, traces, etc.)
.vitest-attachments/
**/__screenshots__/
# settings
*.local.json
*.local.md

View File

@ -1,4 +1,12 @@
"""Service API endpoints for dataset document management.
The canonical Service API paths use hyphenated route segments. Legacy underscore
aliases remain registered for backward compatibility, but they must stay marked
deprecated in generated API docs so clients migrate toward the canonical paths.
"""
import json
from collections.abc import Mapping
from contextlib import ExitStack
from typing import Self
from uuid import UUID
@ -117,12 +125,137 @@ register_schema_models(
)
@service_api_ns.route(
"/datasets/<uuid:dataset_id>/document/create_by_text",
"/datasets/<uuid:dataset_id>/document/create-by-text",
)
def _create_document_by_text(tenant_id: str, dataset_id: UUID) -> tuple[Mapping[str, object], int]:
"""Create a document from text for both canonical and legacy routes."""
payload = DocumentTextCreatePayload.model_validate(service_api_ns.payload or {})
args = payload.model_dump(exclude_none=True)
dataset_id_str = str(dataset_id)
tenant_id_str = str(tenant_id)
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id_str, Dataset.id == dataset_id_str).limit(1)
)
if not dataset:
raise ValueError("Dataset does not exist.")
if not dataset.indexing_technique and not args["indexing_technique"]:
raise ValueError("indexing_technique is required.")
embedding_model_provider = payload.embedding_model_provider
embedding_model = payload.embedding_model
if embedding_model_provider and embedding_model:
DatasetService.check_embedding_model_setting(tenant_id_str, embedding_model_provider, embedding_model)
retrieval_model = payload.retrieval_model
if (
retrieval_model
and retrieval_model.reranking_model
and retrieval_model.reranking_model.reranking_provider_name
and retrieval_model.reranking_model.reranking_model_name
):
DatasetService.check_reranking_model_setting(
tenant_id_str,
retrieval_model.reranking_model.reranking_provider_name,
retrieval_model.reranking_model.reranking_model_name,
)
if not current_user:
raise ValueError("current_user is required")
upload_file = FileService(db.engine).upload_text(
text=payload.text, text_name=payload.name, user_id=current_user.id, tenant_id=tenant_id_str
)
data_source = {
"type": "upload_file",
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
}
args["data_source"] = data_source
knowledge_config = KnowledgeConfig.model_validate(args)
DocumentService.document_create_args_validate(knowledge_config)
if not current_user:
raise ValueError("current_user is required")
try:
documents, batch = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=current_user,
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
created_from="api",
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
document = documents[0]
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch}
return documents_and_batch_fields, 200
def _update_document_by_text(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Mapping[str, object], int]:
"""Update a document from text for both canonical and legacy routes."""
payload = DocumentTextUpdate.model_validate(service_api_ns.payload or {})
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == str(dataset_id)).limit(1)
)
args = payload.model_dump(exclude_none=True)
if not dataset:
raise ValueError("Dataset does not exist.")
retrieval_model = payload.retrieval_model
if (
retrieval_model
and retrieval_model.reranking_model
and retrieval_model.reranking_model.reranking_provider_name
and retrieval_model.reranking_model.reranking_model_name
):
DatasetService.check_reranking_model_setting(
tenant_id,
retrieval_model.reranking_model.reranking_provider_name,
retrieval_model.reranking_model.reranking_model_name,
)
# indexing_technique is already set in dataset since this is an update
args["indexing_technique"] = dataset.indexing_technique
if args.get("text"):
text = args.get("text")
name = args.get("name")
if not current_user:
raise ValueError("current_user is required")
upload_file = FileService(db.engine).upload_text(
text=str(text), text_name=str(name), user_id=current_user.id, tenant_id=tenant_id
)
data_source = {
"type": "upload_file",
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
}
args["data_source"] = data_source
args["original_document_id"] = str(document_id)
knowledge_config = KnowledgeConfig.model_validate(args)
DocumentService.document_create_args_validate(knowledge_config)
try:
documents, batch = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=current_user,
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
created_from="api",
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
document = documents[0]
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch}
return documents_and_batch_fields, 200
@service_api_ns.route("/datasets/<uuid:dataset_id>/document/create-by-text")
class DocumentAddByTextApi(DatasetApiResource):
"""Resource for documents."""
"""Resource for the canonical text document creation route."""
@service_api_ns.expect(service_api_ns.models[DocumentTextCreatePayload.__name__])
@service_api_ns.doc("create_document_by_text")
@ -138,81 +271,43 @@ class DocumentAddByTextApi(DatasetApiResource):
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_resource_check("documents", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id):
def post(self, tenant_id: str, dataset_id: UUID):
"""Create document by text."""
payload = DocumentTextCreatePayload.model_validate(service_api_ns.payload or {})
args = payload.model_dump(exclude_none=True)
return _create_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id)
dataset_id = str(dataset_id)
tenant_id = str(tenant_id)
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
@service_api_ns.route("/datasets/<uuid:dataset_id>/document/create_by_text")
class DeprecatedDocumentAddByTextApi(DatasetApiResource):
"""Deprecated resource alias for text document creation."""
@service_api_ns.expect(service_api_ns.models[DocumentTextCreatePayload.__name__])
@service_api_ns.doc("create_document_by_text_deprecated")
@service_api_ns.doc(deprecated=True)
@service_api_ns.doc(
description=(
"Deprecated legacy alias for creating a new document by providing text content. "
"Use /datasets/{dataset_id}/document/create-by-text instead."
)
if not dataset:
raise ValueError("Dataset does not exist.")
if not dataset.indexing_technique and not args["indexing_technique"]:
raise ValueError("indexing_technique is required.")
embedding_model_provider = payload.embedding_model_provider
embedding_model = payload.embedding_model
if embedding_model_provider and embedding_model:
DatasetService.check_embedding_model_setting(tenant_id, embedding_model_provider, embedding_model)
retrieval_model = payload.retrieval_model
if (
retrieval_model
and retrieval_model.reranking_model
and retrieval_model.reranking_model.reranking_provider_name
and retrieval_model.reranking_model.reranking_model_name
):
DatasetService.check_reranking_model_setting(
tenant_id,
retrieval_model.reranking_model.reranking_provider_name,
retrieval_model.reranking_model.reranking_model_name,
)
if not current_user:
raise ValueError("current_user is required")
upload_file = FileService(db.engine).upload_text(
text=payload.text, text_name=payload.name, user_id=current_user.id, tenant_id=tenant_id
)
data_source = {
"type": "upload_file",
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
)
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.doc(
responses={
200: "Document created successfully",
401: "Unauthorized - invalid API token",
400: "Bad request - invalid parameters",
}
args["data_source"] = data_source
knowledge_config = KnowledgeConfig.model_validate(args)
# validate args
DocumentService.document_create_args_validate(knowledge_config)
if not current_user:
raise ValueError("current_user is required")
try:
documents, batch = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=current_user,
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
created_from="api",
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
document = documents[0]
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch}
return documents_and_batch_fields, 200
)
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_resource_check("documents", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id: str, dataset_id: UUID):
"""Create document by text through the deprecated underscore alias."""
return _create_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id)
@service_api_ns.route(
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update_by_text",
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update-by-text",
)
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update-by-text")
class DocumentUpdateByTextApi(DatasetApiResource):
"""Resource for update documents."""
"""Resource for the canonical text document update route."""
@service_api_ns.expect(service_api_ns.models[DocumentTextUpdate.__name__])
@service_api_ns.doc("update_document_by_text")
@ -229,62 +324,35 @@ class DocumentUpdateByTextApi(DatasetApiResource):
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
"""Update document by text."""
payload = DocumentTextUpdate.model_validate(service_api_ns.payload or {})
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == str(dataset_id)).limit(1)
return _update_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update_by_text")
class DeprecatedDocumentUpdateByTextApi(DatasetApiResource):
"""Deprecated resource alias for text document updates."""
@service_api_ns.expect(service_api_ns.models[DocumentTextUpdate.__name__])
@service_api_ns.doc("update_document_by_text_deprecated")
@service_api_ns.doc(deprecated=True)
@service_api_ns.doc(
description=(
"Deprecated legacy alias for updating an existing document by providing text content. "
"Use /datasets/{dataset_id}/documents/{document_id}/update-by-text instead."
)
args = payload.model_dump(exclude_none=True)
if not dataset:
raise ValueError("Dataset does not exist.")
retrieval_model = payload.retrieval_model
if (
retrieval_model
and retrieval_model.reranking_model
and retrieval_model.reranking_model.reranking_provider_name
and retrieval_model.reranking_model.reranking_model_name
):
DatasetService.check_reranking_model_setting(
tenant_id,
retrieval_model.reranking_model.reranking_provider_name,
retrieval_model.reranking_model.reranking_model_name,
)
# indexing_technique is already set in dataset since this is an update
args["indexing_technique"] = dataset.indexing_technique
if args.get("text"):
text = args.get("text")
name = args.get("name")
if not current_user:
raise ValueError("current_user is required")
upload_file = FileService(db.engine).upload_text(
text=str(text), text_name=str(name), user_id=current_user.id, tenant_id=tenant_id
)
data_source = {
"type": "upload_file",
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
}
args["data_source"] = data_source
# validate args
args["original_document_id"] = str(document_id)
knowledge_config = KnowledgeConfig.model_validate(args)
DocumentService.document_create_args_validate(knowledge_config)
try:
documents, batch = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=current_user,
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
created_from="api",
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
document = documents[0]
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch}
return documents_and_batch_fields, 200
)
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc(
responses={
200: "Document updated successfully",
401: "Unauthorized - invalid API token",
404: "Document not found",
}
)
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
"""Update document by text through the deprecated underscore alias."""
return _update_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
@service_api_ns.route(

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Iterator
from collections.abc import Generator # Changed from Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
@ -32,7 +32,7 @@ def get_current_file_access_scope() -> FileAccessScope | None:
@contextmanager
def bind_file_access_scope(scope: FileAccessScope) -> Iterator[None]:
def bind_file_access_scope(scope: FileAccessScope) -> Generator[None, None, None]: # Changed from Iterator[None]
token = _current_file_access_scope.set(scope)
try:
yield

View File

@ -70,12 +70,32 @@ class ProviderManager:
Request-bound managers may carry caller identity in that runtime, and the
resulting ``ProviderConfiguration`` objects must reuse it for downstream
model-type and schema lookups.
Configuration assembly is cached per manager instance so call chains that
share one request-scoped manager can reuse the same provider graph instead
of rebuilding it for every lookup. Call ``clear_configurations_cache()``
when a long-lived manager needs to observe writes performed within the same
instance scope.
"""
decoding_rsa_key: Any | None
decoding_cipher_rsa: Any | None
_model_runtime: ModelRuntime
_configurations_cache: dict[str, ProviderConfigurations]
def __init__(self, model_runtime: ModelRuntime):
self.decoding_rsa_key = None
self.decoding_cipher_rsa = None
self._model_runtime = model_runtime
self._configurations_cache = {}
def clear_configurations_cache(self, tenant_id: str | None = None) -> None:
"""Drop assembled provider configurations cached on this manager instance."""
if tenant_id is None:
self._configurations_cache.clear()
return
self._configurations_cache.pop(tenant_id, None)
def get_configurations(self, tenant_id: str) -> ProviderConfigurations:
"""
@ -114,6 +134,10 @@ class ProviderManager:
:param tenant_id:
:return:
"""
cached_configurations = self._configurations_cache.get(tenant_id)
if cached_configurations is not None:
return cached_configurations
# Get all provider records of the workspace
provider_name_to_provider_records_dict = self._get_all_providers(tenant_id)
@ -273,6 +297,8 @@ class ProviderManager:
provider_configurations[str(provider_id_entity)] = provider_configuration
self._configurations_cache[tenant_id] = provider_configurations
# Return the encapsulated object
return provider_configurations

View File

@ -139,8 +139,10 @@ class Jieba(BaseKeyword):
"__data__": {"index_id": self.dataset.id, "summary": None, "table": keyword_table},
}
dataset_keyword_table = self.dataset.dataset_keyword_table
keyword_data_source_type = dataset_keyword_table.data_source_type
keyword_data_source_type = dataset_keyword_table.data_source_type if dataset_keyword_table else "file"
if keyword_data_source_type == "database":
if dataset_keyword_table is None:
return
dataset_keyword_table.keyword_table = dumps_with_sets(keyword_table_dict)
db.session.commit()
else:

View File

@ -1,4 +1,5 @@
import re
from collections.abc import Callable
from operator import itemgetter
from typing import cast
@ -80,12 +81,14 @@ class JiebaKeywordTableHandler:
def extract_tags(self, sentence: str, top_k: int | None = 20, **kwargs):
# Basic frequency-based keyword extraction as a fallback when TF-IDF is unavailable.
top_k = kwargs.pop("topK", top_k)
top_k = cast(int | None, kwargs.pop("topK", top_k))
if top_k is None:
top_k = 20
cut = getattr(jieba, "cut", None)
if self._lcut:
tokens = self._lcut(sentence)
elif callable(cut):
tokens = list(cut(sentence))
tokens = list(cast(Callable[[str], list[str]], cut)(sentence))
else:
tokens = re.findall(r"\w+", sentence)
@ -108,7 +111,7 @@ class JiebaKeywordTableHandler:
sentence=text,
topK=max_keywords_per_chunk,
)
# jieba.analyse.extract_tags returns list[Any] when withFlag is False by default.
# jieba.analyse.extract_tags returns an untyped list when withFlag is False by default.
keywords = cast(list[str], keywords)
return set(self._expand_tokens_with_subtokens(set(keywords)))

View File

@ -158,7 +158,7 @@ class RetrievalService:
)
if futures:
for future in concurrent.futures.as_completed(futures, timeout=3600):
for _ in concurrent.futures.as_completed(futures, timeout=3600):
if exceptions:
for f in futures:
f.cancel()

View File

@ -94,6 +94,7 @@ class ExtractProcessor:
cls, extract_setting: ExtractSetting, is_automatic: bool = False, file_path: str | None = None
) -> list[Document]:
if extract_setting.datasource_type == DatasourceType.FILE:
upload_file = extract_setting.upload_file
with tempfile.TemporaryDirectory() as temp_dir:
upload_file = extract_setting.upload_file
if not file_path:
@ -104,6 +105,7 @@ class ExtractProcessor:
storage.download(upload_file.key, file_path)
input_file = Path(file_path)
file_extension = input_file.suffix.lower()
assert upload_file is not None, "upload_file is required"
etl_type = dify_config.ETL_TYPE
extractor: BaseExtractor | None = None
if etl_type == "Unstructured":

View File

@ -28,7 +28,7 @@ class FunctionCallMultiDatasetRouter:
SystemPromptMessage(content="You are a helpful AI assistant."),
UserPromptMessage(content=query),
]
result: LLMResult = model_instance.invoke_llm(
result: LLMResult = model_instance.invoke_llm( # pyright: ignore[reportCallIssue, reportArgumentType]
prompt_messages=prompt_messages,
tools=dataset_tools,
stream=False,

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import codecs
import re
from collections.abc import Collection
from collections.abc import Set as AbstractSet
from typing import Any, Literal
from core.model_manager import ModelInstance
@ -21,8 +21,8 @@ class EnhanceRecursiveCharacterTextSplitter(RecursiveCharacterTextSplitter):
def from_encoder[T: EnhanceRecursiveCharacterTextSplitter](
cls: type[T],
embedding_model_instance: ModelInstance | None,
allowed_special: Literal["all"] | set[str] = set(),
disallowed_special: Literal["all"] | Collection[str] = "all",
allowed_special: Literal["all"] | AbstractSet[str] = frozenset(),
disallowed_special: Literal["all"] | AbstractSet[str] = "all",
**kwargs: Any,
) -> T:
def _token_encoder(texts: list[str]) -> list[int]:
@ -40,6 +40,7 @@ class EnhanceRecursiveCharacterTextSplitter(RecursiveCharacterTextSplitter):
return [len(text) for text in texts]
_ = _token_encoder # kept for future token-length wiring
return cls(length_function=_character_encoder, **kwargs)

View File

@ -4,7 +4,8 @@ import copy
import logging
import re
from abc import ABC, abstractmethod
from collections.abc import Callable, Collection, Iterable, Sequence, Set
from collections.abc import Callable, Iterable, Sequence
from collections.abc import Set as AbstractSet
from dataclasses import dataclass
from typing import Any, Literal
@ -187,8 +188,8 @@ class TokenTextSplitter(TextSplitter):
self,
encoding_name: str = "gpt2",
model_name: str | None = None,
allowed_special: Literal["all"] | Set[str] = set(),
disallowed_special: Literal["all"] | Collection[str] = "all",
allowed_special: Literal["all"] | AbstractSet[str] = frozenset(),
disallowed_special: Literal["all"] | AbstractSet[str] = "all",
**kwargs: Any,
):
"""Create a new TextSplitter."""
@ -207,8 +208,8 @@ class TokenTextSplitter(TextSplitter):
else:
enc = tiktoken.get_encoding(encoding_name)
self._tokenizer = enc
self._allowed_special = allowed_special
self._disallowed_special = disallowed_special
self._allowed_special: Literal["all"] | AbstractSet[str] = allowed_special
self._disallowed_special: Literal["all"] | AbstractSet[str] = disallowed_special
def split_text(self, text: str) -> list[str]:
def _encode(_text: str) -> list[int]:

View File

@ -105,7 +105,7 @@ class Article:
def extract_using_readabilipy(html: str):
json_article: dict[str, Any] = simple_json_from_html_string(html, use_readability=True)
json_article: dict[str, Any] = simple_json_from_html_string(html, use_readability=False)
article = Article(
title=json_article.get("title") or "",
author=json_article.get("byline") or "",

View File

@ -1,5 +1,5 @@
import contextvars
from collections.abc import Iterator
from collections.abc import Generator # Changed from Iterator
from contextlib import contextmanager
from typing import TYPE_CHECKING
@ -13,7 +13,7 @@ if TYPE_CHECKING:
def preserve_flask_contexts(
flask_app: Flask,
context_vars: contextvars.Context,
) -> Iterator[None]:
) -> Generator[None, None, None]: # Changed from Iterator[None]
"""
A context manager that handles:
1. flask-login's UserProxy copy

View File

@ -1,6 +1,6 @@
import json
import uuid
from collections.abc import Iterator
from collections.abc import Generator # Added Generator
from contextlib import contextmanager
from typing import Any
@ -75,7 +75,7 @@ class AnalyticdbVectorBySql:
)
@contextmanager
def _get_cursor(self) -> Iterator[Any]:
def _get_cursor(self) -> Generator[Any, None, None]: # Changed from Iterator[Any]
assert self.pool is not None, "Connection pool is not initialized"
conn = self.pool.getconn()
cur = conn.cursor()

View File

@ -114,10 +114,10 @@ override-dependencies = [
dev = [
"coverage>=7.13.4",
"dotenv-linter>=0.7.0",
"faker>=20.1.0",
"faker>=40.15.0",
"lxml-stubs>=0.5.1",
"basedpyright>=1.39.0",
"ruff>=0.15.10",
"basedpyright>=1.39.3",
"ruff>=0.15.11",
"pytest>=9.0.3",
"pytest-benchmark>=5.2.3",
"pytest-cov>=7.1.0",
@ -157,14 +157,14 @@ dev = [
"types-tensorflow>=2.18.0.20260408",
"types-tqdm>=4.67.3.20260408",
"types-ujson>=5.10.0",
"boto3-stubs>=1.42.88",
"boto3-stubs>=1.42.92",
"types-jmespath>=1.1.0.20260408",
"hypothesis>=6.151.12",
"hypothesis>=6.152.1",
"types_pyOpenSSL>=24.1.0",
"types_cffi>=2.0.0.20260408",
"types_setuptools>=82.0.0.20260408",
"pandas-stubs>=3.0.0",
"scipy-stubs>=1.15.3.0",
"scipy-stubs>=1.17.1.4",
"types-python-http-client>=3.3.7.20260408",
"import-linter>=2.3",
"types-redis>=4.6.0.20241004",

View File

@ -2,7 +2,7 @@ import base64
import hashlib
import os
import uuid
from collections.abc import Iterator, Sequence
from collections.abc import Generator, Sequence # Changed Iterator to Generator
from contextlib import contextmanager, suppress
from tempfile import NamedTemporaryFile
from typing import Literal
@ -324,7 +324,7 @@ class FileService:
def build_upload_files_zip_tempfile(
*,
upload_files: Sequence[UploadFile],
) -> Iterator[str]:
) -> Generator[str, None, None]: # Changed from Iterator[str]
"""
Build a ZIP from `UploadFile`s and yield a tempfile path.

View File

@ -1,10 +1,10 @@
import json
import logging
import time
from typing import Any, TypedDict
from typing import Any, TypedDict, cast
from core.app.app_config.entities import ModelConfig
from core.rag.datasource.retrieval_service import RetrievalService
from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService
from core.rag.index_processor.constant.query_type import QueryType
from core.rag.models.document import Document
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
@ -36,6 +36,10 @@ default_retrieval_model = {
}
class HitTestingRetrievalModelDict(DefaultRetrievalModelDict, total=False):
metadata_filtering_conditions: dict[str, Any]
class HitTestingService:
@classmethod
def retrieve(
@ -51,17 +55,18 @@ class HitTestingService:
start = time.perf_counter()
# get retrieval model , if the model is not setting , using default
if not retrieval_model:
retrieval_model = dataset.retrieval_model or default_retrieval_model
assert isinstance(retrieval_model, dict)
resolved_retrieval_model = cast(
HitTestingRetrievalModelDict,
retrieval_model or dataset.retrieval_model or default_retrieval_model,
)
document_ids_filter = None
metadata_filtering_conditions = retrieval_model.get("metadata_filtering_conditions", {})
if metadata_filtering_conditions and query:
metadata_filtering_conditions_raw = resolved_retrieval_model.get("metadata_filtering_conditions", {})
if metadata_filtering_conditions_raw and query:
dataset_retrieval = DatasetRetrieval()
from core.rag.entities import MetadataFilteringCondition
metadata_filtering_conditions = MetadataFilteringCondition.model_validate(metadata_filtering_conditions)
metadata_filtering_conditions = MetadataFilteringCondition.model_validate(metadata_filtering_conditions_raw)
metadata_filter_document_ids, metadata_condition = dataset_retrieval.get_metadata_filter_condition(
dataset_ids=[dataset.id],
@ -78,19 +83,21 @@ class HitTestingService:
if metadata_condition and not document_ids_filter:
return cls.compact_retrieve_response(query, [])
all_documents = RetrievalService.retrieve(
retrieval_method=RetrievalMethod(retrieval_model.get("search_method", RetrievalMethod.SEMANTIC_SEARCH)),
retrieval_method=RetrievalMethod(
resolved_retrieval_model.get("search_method", RetrievalMethod.SEMANTIC_SEARCH)
),
dataset_id=dataset.id,
query=query,
attachment_ids=attachment_ids,
top_k=retrieval_model.get("top_k", 4),
score_threshold=retrieval_model.get("score_threshold", 0.0)
if retrieval_model["score_threshold_enabled"]
top_k=resolved_retrieval_model.get("top_k", 4),
score_threshold=resolved_retrieval_model.get("score_threshold", 0.0)
if resolved_retrieval_model["score_threshold_enabled"]
else 0.0,
reranking_model=retrieval_model.get("reranking_model", None)
if retrieval_model["reranking_enable"]
reranking_model=resolved_retrieval_model.get("reranking_model", None)
if resolved_retrieval_model["reranking_enable"]
else None,
reranking_mode=retrieval_model.get("reranking_mode") or "reranking_model",
weights=retrieval_model.get("weights", None),
reranking_mode=resolved_retrieval_model.get("reranking_mode") or "reranking_model",
weights=resolved_retrieval_model.get("weights", None),
document_ids_filter=document_ids_filter,
)

View File

@ -23,7 +23,7 @@ class PluginAutoUpgradeService:
exclude_plugins: list[str],
include_plugins: list[str],
) -> bool:
with session_factory.create_session() as session:
with session_factory.create_session() as session, session.begin():
exist_strategy = session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
@ -50,7 +50,7 @@ class PluginAutoUpgradeService:
@staticmethod
def exclude_plugin(tenant_id: str, plugin_id: str) -> bool:
with session_factory.create_session() as session:
with session_factory.create_session() as session, session.begin():
exist_strategy = session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)

View File

@ -22,6 +22,8 @@ import pytest
from werkzeug.exceptions import Forbidden, NotFound
from controllers.service_api.dataset.document import (
DeprecatedDocumentAddByTextApi,
DeprecatedDocumentUpdateByTextApi,
DocumentAddByFileApi,
DocumentAddByTextApi,
DocumentApi,
@ -1005,7 +1007,7 @@ class TestDocumentAddByTextApi:
# Act
with app.test_request_context(
f"/datasets/{mock_dataset.id}/document/create_by_text",
f"/datasets/{mock_dataset.id}/document/create-by-text",
method="POST",
json={
"name": "Test Document",
@ -1037,7 +1039,7 @@ class TestDocumentAddByTextApi:
# Act & Assert
with app.test_request_context(
f"/datasets/{mock_dataset.id}/document/create_by_text",
f"/datasets/{mock_dataset.id}/document/create-by-text",
method="POST",
json={"name": "Test Document", "text": "Content"},
headers={"Authorization": "Bearer test_token"},
@ -1066,7 +1068,7 @@ class TestDocumentAddByTextApi:
# Act & Assert
with app.test_request_context(
f"/datasets/{mock_dataset.id}/document/create_by_text",
f"/datasets/{mock_dataset.id}/document/create-by-text",
method="POST",
json={"name": "Test Document", "text": "Content"},
headers={"Authorization": "Bearer test_token"},
@ -1093,6 +1095,20 @@ class TestArchivedDocumentImmutableError:
assert error.code == 403
class TestDocumentTextRouteDeprecation:
"""Test that legacy underscore text routes stay marked deprecated."""
def test_create_by_text_legacy_alias_is_deprecated(self):
"""Ensure only the legacy create-by-text alias is marked deprecated."""
assert DeprecatedDocumentAddByTextApi.post.__apidoc__["deprecated"] is True
assert DocumentAddByTextApi.post.__apidoc__.get("deprecated") is not True
def test_update_by_text_legacy_alias_is_deprecated(self):
"""Ensure only the legacy update-by-text alias is marked deprecated."""
assert DeprecatedDocumentUpdateByTextApi.post.__apidoc__["deprecated"] is True
assert DocumentUpdateByTextApi.post.__apidoc__.get("deprecated") is not True
# =============================================================================
# Endpoint tests for DocumentUpdateByTextApi, DocumentAddByFileApi,
# DocumentUpdateByFileApi.
@ -1162,7 +1178,7 @@ class TestDocumentUpdateByTextApiPost:
doc_id = str(uuid.uuid4())
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_text",
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update-by-text",
method="POST",
json={"name": "Updated Doc", "text": "New content"},
headers={"Authorization": "Bearer test_token"},
@ -1195,7 +1211,7 @@ class TestDocumentUpdateByTextApiPost:
doc_id = str(uuid.uuid4())
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_text",
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update-by-text",
method="POST",
json={"name": "Doc", "text": "Content"},
headers={"Authorization": "Bearer test_token"},

View File

@ -372,6 +372,78 @@ def test_get_configurations_binds_manager_runtime_to_provider_configuration(
provider_configuration.bind_model_runtime.assert_called_once_with(manager._model_runtime)
def test_get_configurations_reuses_cached_result_for_same_tenant(mocker: MockerFixture, mock_provider_entity):
manager = _build_provider_manager(mocker)
provider_configuration = Mock()
provider_factory = Mock()
provider_factory.get_providers.return_value = [mock_provider_entity]
custom_configuration = SimpleNamespace(provider=None, models=[])
system_configuration = SimpleNamespace(enabled=False, quota_configurations=[], current_quota_type=None)
with (
patch.object(manager, "_get_all_providers", return_value={"openai": []}) as mock_get_all_providers,
patch.object(manager, "_init_trial_provider_records", return_value={"openai": []}),
patch.object(manager, "_get_all_provider_models", return_value={"openai": []}),
patch.object(manager, "_get_all_preferred_model_providers", return_value={}),
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
patch.object(manager, "_to_model_settings", return_value=[]),
patch("core.provider_manager.ModelProviderFactory", return_value=provider_factory) as mock_factory_cls,
patch(
"core.provider_manager.ProviderConfiguration",
return_value=provider_configuration,
) as mock_provider_configuration,
):
first = manager.get_configurations("tenant-id")
second = manager.get_configurations("tenant-id")
assert first is second
mock_get_all_providers.assert_called_once_with("tenant-id")
mock_factory_cls.assert_called_once_with(model_runtime=manager._model_runtime)
mock_provider_configuration.assert_called_once()
provider_configuration.bind_model_runtime.assert_called_once_with(manager._model_runtime)
def test_clear_configurations_cache_rebuilds_requested_tenant(mocker: MockerFixture, mock_provider_entity):
manager = _build_provider_manager(mocker)
provider_factory = Mock()
provider_factory.get_providers.return_value = [mock_provider_entity]
custom_configuration = SimpleNamespace(provider=None, models=[])
system_configuration = SimpleNamespace(enabled=False, quota_configurations=[], current_quota_type=None)
provider_configuration_first = Mock()
provider_configuration_second = Mock()
with (
patch.object(manager, "_get_all_providers", return_value={"openai": []}) as mock_get_all_providers,
patch.object(manager, "_init_trial_provider_records", return_value={"openai": []}),
patch.object(manager, "_get_all_provider_models", return_value={"openai": []}),
patch.object(manager, "_get_all_preferred_model_providers", return_value={}),
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
patch.object(manager, "_to_model_settings", return_value=[]),
patch("core.provider_manager.ModelProviderFactory", return_value=provider_factory),
patch(
"core.provider_manager.ProviderConfiguration",
side_effect=[provider_configuration_first, provider_configuration_second],
) as mock_provider_configuration,
):
first = manager.get_configurations("tenant-id")
manager.clear_configurations_cache("tenant-id")
second = manager.get_configurations("tenant-id")
assert first is not second
assert mock_get_all_providers.call_count == 2
assert mock_provider_configuration.call_count == 2
provider_configuration_first.bind_model_runtime.assert_called_once_with(manager._model_runtime)
provider_configuration_second.bind_model_runtime.assert_called_once_with(manager._model_runtime)
def test_get_provider_model_bundle_returns_selected_model_type_instance(mocker: MockerFixture):
manager = _build_provider_manager(mocker)
provider_configuration = Mock()

80
api/uv.lock generated
View File

@ -469,14 +469,14 @@ wheels = [
[[package]]
name = "basedpyright"
version = "1.39.0"
version = "1.39.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/f4/4a77cc1ffb3dab7391642cde30163961d8ee973e9e6b6740c7d15aa3d3ba/basedpyright-1.39.0.tar.gz", hash = "sha256:6666f51c378c7ac45877c4c1c7041ee0b5b83d755ebc82f898f47b6fafe0cc4f", size = 25357403, upload-time = "2026-04-01T12:27:41.92Z" }
sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/47/08145d1bcc3083ed20059bdecbde404bd767f91b91e2764ec01cffec9f4b/basedpyright-1.39.0-py3-none-any.whl", hash = "sha256:91b8ad50bc85ee4a985b928f9368c35c99eee5a56c44e99b2442fa12ecc3d670", size = 12353868, upload-time = "2026-04-01T12:27:38.495Z" },
{ url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" },
]
[[package]]
@ -618,15 +618,15 @@ wheels = [
[[package]]
name = "boto3-stubs"
version = "1.42.88"
version = "1.42.92"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore-stubs" },
{ name = "types-s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c7/d4dfbb4757cd72fd350ba666902ec3ac19e04d6be639e96cdad4543d4726/boto3_stubs-1.42.88.tar.gz", hash = "sha256:85215fb4938a94d1cf83cd8632f46ae7728b5ec88187d83468f393bbe64236d6", size = 102495, upload-time = "2026-04-10T19:55:57.526Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/b4/7f472d64a89f6aa6b8e8eeadc876667b7e4edfb526c6118efe2b2c98ba17/boto3_stubs-1.42.92.tar.gz", hash = "sha256:4bc934069c5e8c7b3cdd2442569dae14e8272fe207d445bd38aa578b8463638f", size = 102696, upload-time = "2026-04-20T19:55:19.858Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/6f/3befd72080aedbb4ad26b353a6e364645668664930ce49668fd0bab8f2b5/boto3_stubs-1.42.88-py3-none-any.whl", hash = "sha256:9e74350715ca8ccd63fc250f8eca9fa3161b3d1704339554344d72e4e21c5ed1", size = 70603, upload-time = "2026-04-10T19:55:49.921Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ce/2fe2c6456f8dc0b8bb8d80e05e154c7975ec058991bedf54f3aeed634b79/boto3_stubs-1.42.92-py3-none-any.whl", hash = "sha256:b3994e60f0133b2dd3d9a88ceaeef48fa6367d9a9429426e919575768a1ad9c6", size = 70666, upload-time = "2026-04-20T19:55:16.398Z" },
]
[package.optional-dependencies]
@ -1616,13 +1616,13 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.39.0" },
{ name = "boto3-stubs", specifier = ">=1.42.88" },
{ name = "basedpyright", specifier = ">=1.39.3" },
{ name = "boto3-stubs", specifier = ">=1.42.92" },
{ name = "celery-types", specifier = ">=0.23.0" },
{ name = "coverage", specifier = ">=7.13.4" },
{ name = "dotenv-linter", specifier = ">=0.7.0" },
{ name = "faker", specifier = ">=20.1.0" },
{ name = "hypothesis", specifier = ">=6.151.12" },
{ name = "faker", specifier = ">=40.15.0" },
{ name = "hypothesis", specifier = ">=6.152.1" },
{ name = "import-linter", specifier = ">=2.3" },
{ name = "lxml-stubs", specifier = ">=0.5.1" },
{ name = "mypy", specifier = ">=1.20.1" },
@ -1635,8 +1635,8 @@ dev = [
{ name = "pytest-mock", specifier = ">=3.15.1" },
{ name = "pytest-timeout", specifier = ">=2.4.0" },
{ name = "pytest-xdist", specifier = ">=3.8.0" },
{ name = "ruff", specifier = ">=0.15.10" },
{ name = "scipy-stubs", specifier = ">=1.15.3.0" },
{ name = "ruff", specifier = ">=0.15.11" },
{ name = "scipy-stubs", specifier = ">=1.17.1.4" },
{ name = "testcontainers", specifier = ">=4.14.2" },
{ name = "types-aiofiles", specifier = ">=25.1.0" },
{ name = "types-beautifulsoup4", specifier = ">=4.12.0" },
@ -2351,14 +2351,14 @@ wheels = [
[[package]]
name = "faker"
version = "40.13.0"
version = "40.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/95/4822ffe94723553789aef783104f4f18fc20d7c4c68e1bbd633e11d09758/faker-40.13.0.tar.gz", hash = "sha256:a0751c84c3abac17327d7bb4c98e8afe70ebf7821e01dd7d0b15cd8856415525", size = 1962043, upload-time = "2026-04-06T16:44:55.68Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/13/6741787bd91c4109c7bed047d68273965cd52ce8a5f773c471b949334b6d/faker-40.15.0.tar.gz", hash = "sha256:20f3a6ec8c266b74d4c554e34118b21c3c2056c0b4a519d15c8decb3a4e6e795", size = 1967447, upload-time = "2026-04-17T20:05:27.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/8a/708103325edff16a0b0e004de0d37db8ba216a32713948c64d71f6d4a4c2/faker-40.13.0-py3-none-any.whl", hash = "sha256:c1298fd0d819b3688fb5fd358c4ba8f56c7c8c740b411fd3dbd8e30bf2c05019", size = 1994597, upload-time = "2026-04-06T16:44:53.698Z" },
{ url = "https://files.pythonhosted.org/packages/a7/a7/a600f8f30d4505e89166de51dd121bd540ab8e560e8cf0901de00a81de8c/faker-40.15.0-py3-none-any.whl", hash = "sha256:71ab3c3370da9d2205ab74ffb0fd51273063ad562b3a3bb69d0026a20923e318", size = 2004447, upload-time = "2026-04-17T20:05:25.437Z" },
]
[[package]]
@ -3317,14 +3317,14 @@ wheels = [
[[package]]
name = "hypothesis"
version = "6.151.12"
version = "6.152.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/ab/67ca321d1ab96fd3828b12142f1c258e2d4a668a025d06cd50ab3409787f/hypothesis-6.151.12.tar.gz", hash = "sha256:be485f503979af4c3dfa19e3fc2b967d0458e7f8c4e28128d7e215e0a55102e0", size = 463900, upload-time = "2026-04-08T19:40:06.205Z" }
sdist = { url = "https://files.pythonhosted.org/packages/64/b1/c32bcddb9aab9e3abc700f1f56faf14e7655c64a16ca47701a57362276ea/hypothesis-6.152.1.tar.gz", hash = "sha256:4f4ed934eee295dd84ee97592477d23e8dc03e9f12ae0ee30a4e7c9ef3fca3b0", size = 465029, upload-time = "2026-04-14T22:29:24.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/5a/6cecf134b631050a1f8605096adbe812483b60790d951470989d39b56860/hypothesis-6.151.12-py3-none-any.whl", hash = "sha256:37d4f3a768365c30571b11dfd7a6857a12173d933010b2c4ab65619f1b5952c5", size = 529656, upload-time = "2026-04-08T19:40:03.126Z" },
{ url = "https://files.pythonhosted.org/packages/5d/83/860fb3075e00b0fc19a22a2301bc3c96f00437558c3911bdd0a3573a4a53/hypothesis-6.152.1-py3-none-any.whl", hash = "sha256:40a3619d9e0cb97b018857c7986f75cf5de2e5ec0fa8a0b172d00747758f749e", size = 530752, upload-time = "2026-04-14T22:29:20.893Z" },
]
[[package]]
@ -5887,27 +5887,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.10"
version = "0.15.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
{ url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" },
{ url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
{ url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" },
{ url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" },
{ url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" },
{ url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" },
{ url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" },
{ url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" },
{ url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" },
{ url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" },
{ url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" },
{ url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" },
{ url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" },
{ url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" },
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
]
[[package]]
@ -5946,14 +5946,14 @@ wheels = [
[[package]]
name = "scipy-stubs"
version = "1.17.1.3"
version = "1.17.1.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "optype", extra = ["numpy"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/59/59c6cc3f9970154b9ed6b1aff42a0185cdd60cef54adc0404b9e77972221/scipy_stubs-1.17.1.3.tar.gz", hash = "sha256:5eb87a8d23d726706259b012ebe76a4a96a9ae9e141fc59bf55fc8eac2ed9e0f", size = 392185, upload-time = "2026-03-22T22:11:58.34Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/75/d944a11fca64aa84fbb4bfcf613b758319c6103cb30a304a0e9727009d62/scipy_stubs-1.17.1.4.tar.gz", hash = "sha256:cae00c5207aa62ceb4bcadea202d9fbbf002e958f9e4de981720436b8d5c1802", size = 396980, upload-time = "2026-04-13T11:46:54.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/d4/94304532c0a75a55526119043dd44a9bd1541a21e14483cbb54261c527d2/scipy_stubs-1.17.1.3-py3-none-any.whl", hash = "sha256:7b91d3f05aa47da06fbca14eb6c5bb4c28994e9245fd250cc847e375bab31297", size = 597933, upload-time = "2026-03-22T22:11:56.525Z" },
{ url = "https://files.pythonhosted.org/packages/92/f8/334aa5a7a482ea89cb14d92f6a4d9ffa1e193e733144d4d14c7ffcb33583/scipy_stubs-1.17.1.4-py3-none-any.whl", hash = "sha256:e6e5c390fb864745bc3d5f591de81f5cb4f84403857d4f660acb5b6339956f5b", size = 604752, upload-time = "2026-04-13T11:46:53.135Z" },
]
[[package]]

View File

@ -488,11 +488,6 @@
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -1931,11 +1926,6 @@
"count": 2
}
},
"web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/last-run-block/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 3
@ -2223,11 +2213,6 @@
"count": 3
}
},
"web/app/components/billing/usage-info/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/common/image-previewer/index.tsx": {
"no-irregular-whitespace": {
"count": 1
@ -3070,14 +3055,6 @@
"count": 3
}
},
"web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/header/account-setting/model-provider-page/declarations.ts": {
"erasable-syntax-only/enums": {
"count": 11
@ -3564,11 +3541,6 @@
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx": {
"no-restricted-imports": {
"count": 1
@ -3682,11 +3654,6 @@
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -3943,11 +3910,6 @@
"count": 1
}
},
"web/app/components/tools/labels/selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/mcp/create-card.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -4087,15 +4049,7 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/blocks.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/featured-tools.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 2
},
@ -4104,9 +4058,6 @@
}
},
"web/app/components/workflow/block-selector/featured-triggers.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 2
},
@ -4139,26 +4090,11 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/start-blocks.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tabs.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool/action-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -4170,9 +4106,6 @@
}
},
"web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -4347,14 +4280,6 @@
"count": 2
}
},
"web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx": {
"no-restricted-imports": {
"count": 3
},
"ts/no-explicit-any": {
"count": 4
}
},
"web/app/components/workflow/nodes/_base/components/agent-strategy.tsx": {
"ts/no-empty-object-type": {
"count": 1
@ -4581,22 +4506,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
},
"web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/var-type-picker.tsx": {
"no-restricted-imports": {
"count": 1
@ -4776,11 +4685,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/code/dependency-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/code/types.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -4931,16 +4835,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx": {
"no-restricted-imports": {
"count": 1
@ -4991,11 +4885,6 @@
"count": 2
}
},
"web/app/components/workflow/nodes/if-else/components/condition-add.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-input.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -5011,11 +4900,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx": {
"no-restricted-imports": {
"count": 1
@ -5119,16 +5003,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -5144,11 +5018,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -5328,11 +5197,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-add.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -5348,11 +5212,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-number-input.tsx": {
"no-restricted-imports": {
"count": 1
@ -6129,14 +5988,6 @@
"count": 5
}
},
"web/app/education-apply/search-input.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/education-apply/verify-state-modal.tsx": {
"react/set-state-in-effect": {
"count": 1

View File

@ -11,6 +11,27 @@ Shared design tokens, the `cn()` utility, a Tailwind CSS preset, and headless pr
- Props pattern: `Omit<BaseXxx.Root.Props, 'className' | ...> & VariantProps<typeof xxxVariants> & { /* custom */ }`.
- When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath.
## Overlay Primitive Selection: Tooltip vs PreviewCard vs Popover
Pick by the **trigger's purpose** and **a11y reach**, not visual richness.
| Primitive | Opens on | Trigger's purpose | Content | Reachable on touch / SR? |
| ------------- | --------------------- | -------------------------- | ------------------------- | ------------------------ |
| `Tooltip` | hover / focus | has its own action | short plain-text label | ❌ (label only) |
| `PreviewCard` | hover / focus | has a primary click target | supplementary preview | ❌ (via click target) |
| `Popover` | click / tap (+ hover) | **to open the popup** | anything, incl. long text | ✅ |
Base UI decision rule ([docs]):
> _"If the trigger's purpose is to open the popup itself, it's a popover.
> If the trigger's purpose is unrelated to opening the popup, it's a tooltip."_
Apply this first, then narrow:
- `Tooltip` — ephemeral visual label. Trigger must already carry its own `aria-label` / visible text; tooltip mirrors it for sighted mouse/keyboard users. No interactive UI, no multi-line prose. Not dwell-able.
- `PreviewCard` — hover-revealed rich supplementary preview anchored to a trigger whose click goes somewhere (link, selectable row, jumpable chip). **Hard contract:** the popup MUST NOT contain information or actions unreachable from the trigger's click destination — touch and SR users can't open it. If the info is unique to the popup, switch to `Popover` (click or `openOnHover`) or move it to the click destination. Do not hand-roll "hover to open" on top of `Popover` to evade this split.
- `Popover` — any popup with its own interactions, or any "infotip" (`?` / `(i)` glyph whose sole purpose is to reveal help text). Pass `openOnHover` on `PopoverTrigger` for the infotip case — unlike `Tooltip` / `PreviewCard`, this stays accessible to touch and SR users because the popover still opens on tap and focus.
## Border Radius: Figma Token → Tailwind Class Mapping
The Figma design system uses `--radius/*` tokens whose scale is **offset by one step** from Tailwind CSS v4 defaults. When translating Figma specs to code, always use this mapping — never use `radius-*` as a CSS class, and never extend `borderRadius` in the preset.
@ -34,3 +55,5 @@ The Figma design system uses `--radius/*` tokens whose scale is **offset by one
- **Do not** use `radius-*` as CSS class names. The old `@utility radius-*` definitions have been removed.
- When the Figma MCP returns `rounded-[var(--radius/sm, 6px)]`, convert it to the standard Tailwind class from the table above (e.g. `rounded-md`).
- For values without a standard Tailwind equivalent (10px, 20px, 28px), use arbitrary values like `rounded-[10px]`.
[docs]: https://base-ui.com/react/components/tooltip#infotips

View File

@ -49,6 +49,10 @@
"types": "./src/popover/index.tsx",
"import": "./src/popover/index.tsx"
},
"./preview-card": {
"types": "./src/preview-card/index.tsx",
"import": "./src/preview-card/index.tsx"
},
"./scroll-area": {
"types": "./src/scroll-area/index.tsx",
"import": "./src/scroll-area/index.tsx"

View File

@ -20,7 +20,7 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.',
component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.\n\nPass `openOnHover` on `PopoverTrigger` when the popup should also reveal on hover (see the **Infotip** story). Unlike `Tooltip` and `PreviewCard`, hover on `Popover` still falls back to tap/focus, so touch and screen-reader users can reach the content.',
},
},
},
@ -101,6 +101,48 @@ export const WithActions: Story = {
),
}
export const Infotip: Story = {
parameters: {
docs: {
description: {
story: [
'The **infotip** pattern from [Base UI](https://base-ui.com/react/components/tooltip#infotips): an info glyph (`?`, `(i)`) whose sole purpose is to reveal explanatory text. Use `Popover` with `openOnHover` on the trigger — never `Tooltip`.',
'',
'Why not `Tooltip`? Tooltips are disabled on touch devices and not announced to screen readers; descriptive help text hidden in them is unreachable for those users. Why not `PreviewCard`? PreviewCard\'s a11y contract requires the trigger to already own a primary click destination, but an info glyph has no other purpose.',
'',
'Base UI rule of thumb: *"If the trigger\'s purpose is to open the popup itself, it\'s a popover. If the trigger\'s purpose is unrelated to opening the popup, it\'s a tooltip."*',
'',
'Hover, tap, or focus the `?` icon to open. In the Dify app, reach for `@/app/components/base/infotip` (`<Infotip aria-label={...}>{helpText}</Infotip>`) which wraps this pattern with consistent delays (300/200), typography, and `aria-label` plumbing.',
].join('\n'),
},
},
},
render: () => (
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<span>Usage priority</span>
<Popover>
<PopoverTrigger
openOnHover
delay={300}
closeDelay={200}
aria-label="Set which resource to use first when running models."
render={(
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<PopoverContent
placement="top"
popupClassName="max-w-[300px] px-3 py-2 system-xs-regular text-text-tertiary"
>
Set which resource to use first when running models. The Trial quota will be used after the paid quota is exhausted.
</PopoverContent>
</Popover>
</div>
),
}
const PLACEMENTS: Placement[] = [
'top-start',
'top',

View File

@ -0,0 +1,127 @@
import { render } from 'vitest-browser-react'
import {
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '..'
const renderWithSafeViewport = (ui: import('react').ReactNode) => render(
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
{ui}
</div>,
)
describe('PreviewCardContent', () => {
describe('Placement', () => {
it('should use bottom placement and default offsets when placement props are not provided', async () => {
const screen = await renderWithSafeViewport(
<PreviewCard open>
<PreviewCardTrigger
render={<button type="button" aria-label="preview trigger">Open</button>}
/>
<PreviewCardContent
positionerProps={{ 'role': 'group', 'aria-label': 'default positioner' }}
popupProps={{ 'role': 'dialog', 'aria-label': 'default popup' }}
>
<span>Default content</span>
</PreviewCardContent>
</PreviewCard>,
)
await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-side', 'bottom')
await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-align', 'center')
await expect.element(screen.getByRole('dialog', { name: 'default popup' })).toHaveTextContent('Default content')
})
it('should apply parsed custom placement and custom offsets when placement props are provided', async () => {
const screen = await renderWithSafeViewport(
<PreviewCard open>
<PreviewCardTrigger
render={<button type="button" aria-label="preview trigger">Open</button>}
/>
<PreviewCardContent
placement="top-end"
sideOffset={14}
alignOffset={6}
positionerProps={{ 'role': 'group', 'aria-label': 'custom positioner' }}
popupProps={{ 'role': 'dialog', 'aria-label': 'custom popup' }}
>
<span>Custom placement content</span>
</PreviewCardContent>
</PreviewCard>,
)
await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-side', 'top')
await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-align', 'end')
await expect.element(screen.getByRole('dialog', { name: 'custom popup' })).toHaveTextContent('Custom placement content')
})
})
describe('Passthrough props', () => {
it('should forward positionerProps and popupProps when passthrough props are provided', async () => {
const onPopupClick = vi.fn()
const screen = await render(
<PreviewCard open>
<PreviewCardTrigger
render={<button type="button" aria-label="preview trigger">Open</button>}
/>
<PreviewCardContent
positionerProps={{
'role': 'group',
'aria-label': 'preview positioner',
'id': 'preview-positioner-id',
}}
popupProps={{
'id': 'preview-popup-id',
'role': 'dialog',
'aria-label': 'preview content',
'onClick': onPopupClick,
}}
>
<span>Preview body</span>
</PreviewCardContent>
</PreviewCard>,
)
const popup = screen.getByRole('dialog', { name: 'preview content' })
await popup.click()
await expect.element(screen.getByRole('group', { name: 'preview positioner' })).toHaveAttribute('id', 'preview-positioner-id')
await expect.element(popup).toHaveAttribute('id', 'preview-popup-id')
expect(onPopupClick).toHaveBeenCalledTimes(1)
})
})
describe('Trigger click behavior', () => {
it('should forward the trigger click to the consumer handler so the primary action runs', async () => {
const onPrimaryClick = vi.fn()
const screen = await renderWithSafeViewport(
<PreviewCard>
<PreviewCardTrigger
render={(
<button
type="button"
aria-label="preview trigger"
onClick={onPrimaryClick}
>
Open
</button>
)}
/>
<PreviewCardContent
popupProps={{ 'role': 'dialog', 'aria-label': 'preview content' }}
>
<span>Preview body</span>
</PreviewCardContent>
</PreviewCard>,
)
const trigger = screen.getByRole('button', { name: 'preview trigger' })
await trigger.click()
expect(onPrimaryClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,213 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Placement } from '.'
import { useState } from 'react'
import {
createPreviewCardHandle,
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '.'
const rowButtonClassName
= 'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover'
const triggerButtonClassName
= 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
const inlineLinkClassName
= 'text-text-accent underline decoration-text-accent/60 decoration-1 underline-offset-2 outline-hidden hover:decoration-text-accent focus-visible:rounded-xs focus-visible:no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-text-accent data-[popup-open]:decoration-text-accent'
const meta = {
title: 'Base/UI/PreviewCard',
component: PreviewCard,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Hover- and focus-activated rich preview for triggers whose primary click has its own destination (following a link, selecting a row, jumping to a definition). Built on Base UI PreviewCard.\n\n**A11y contract:** touch and screen-reader users cannot open the preview. Never place information or actions in the popup that are not also reachable from the trigger\'s primary click destination. If that is unavoidable, add a separate click affordance (Popover) or move the unique content onto the destination.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof PreviewCard>
export default meta
type Story = StoryObj<typeof meta>
// --- Canonical: inline link preview ---------------------------------------
// Mirrors Base UI's own PreviewCard docs demo: an inline `<a href>` in a
// paragraph, hovering reveals a rich preview (image + summary) of the link's
// destination. The Wikipedia URL and Unsplash image are the exact assets used
// in base-ui.com's public docs so the story renders a real preview.
// https://base-ui.com/react/components/preview-card
const typographyPreview = createPreviewCardHandle()
export const LinkPreview: Story = {
name: 'Link preview (canonical)',
parameters: {
docs: {
description: {
story:
'The prototypical PreviewCard use case: an inline hyperlink with a rich hover preview of the destination. Uses a detached trigger + `createPreviewCardHandle()` so the trigger can sit inline in prose while the popup content is defined elsewhere. The trigger renders a real `<a href>` — click still follows the link; the preview is strictly supplementary.',
},
},
},
render: () => (
<div className="max-w-md p-6 text-sm leading-6 text-text-secondary">
<p>
The principles of good
{' '}
<PreviewCardTrigger
handle={typographyPreview}
href="https://en.wikipedia.org/wiki/Typography"
target="_blank"
rel="noreferrer"
className={inlineLinkClassName}
>
typography
</PreviewCardTrigger>
{' '}
remain in the digital age.
</p>
<PreviewCard handle={typographyPreview}>
<PreviewCardContent popupClassName="w-[240px] p-2">
<div className="flex flex-col gap-2">
<img
width="224"
height="150"
className="block max-w-none rounded-md"
src="https://images.unsplash.com/photo-1619615391095-dfa29e1672ef?q=80&w=448&h=300"
alt="Station Hofplein signage in Rotterdam, Netherlands"
/>
<p className="m-0 text-xs leading-5 text-text-secondary">
<strong className="text-text-primary">Typography</strong>
{' '}
is the art and science of arranging type to make written language legible, readable, and visually appealing.
</p>
</div>
</PreviewCardContent>
</PreviewCard>
</div>
),
}
export const Supplementary: Story = {
name: 'Supplementary preview on a button trigger',
parameters: {
docs: {
description: {
story:
'Application-level adaptation of the same semantic: the trigger is a `<button>` that owns a primary action (selecting a model row) rather than an `<a>`. The preview still only shows supplementary info reachable from the selection destination, so the a11y contract holds.',
},
},
},
render: () => (
<PreviewCard>
<PreviewCardTrigger
render={(
<button type="button" className={rowButtonClassName}>
<span className="i-ri-sparkling-fill h-4 w-4 text-text-accent" />
<span>gpt-4o</span>
</button>
)}
/>
<PreviewCardContent
placement="right"
popupClassName="w-[220px] p-3"
>
<div className="flex flex-col gap-2">
<div className="text-sm font-medium text-text-primary">gpt-4o</div>
<div className="text-xs text-text-tertiary">
Multimodal flagship model. Vision, audio and 128k context.
</div>
</div>
</PreviewCardContent>
</PreviewCard>
),
}
const PLACEMENTS: Placement[] = [
'top-start',
'top',
'top-end',
'right-start',
'right',
'right-end',
'bottom-start',
'bottom',
'bottom-end',
'left-start',
'left',
'left-end',
]
const PlacementsDemo = () => {
const [placement, setPlacement] = useState<Placement>('bottom')
return (
<div className="flex flex-col items-center gap-4 p-20">
<div className="grid grid-cols-3 gap-2 text-xs">
{PLACEMENTS.map(value => (
<button
key={value}
type="button"
onClick={() => setPlacement(value)}
className={`rounded-md border border-divider-subtle px-2 py-1 text-text-secondary ${
placement === value ? 'bg-state-base-hover' : 'bg-components-button-secondary-bg'
}`}
>
{value}
</button>
))}
</div>
<PreviewCard open>
<PreviewCardTrigger
render={<button type="button" className={triggerButtonClassName}>Hover me</button>}
/>
<PreviewCardContent placement={placement} popupClassName="w-56 p-3">
<div className="flex flex-col gap-1">
<div className="text-sm font-semibold text-text-primary">
placement="
{placement}
"
</div>
<div className="text-xs text-text-secondary">
Preview positions itself relative to the trigger.
</div>
</div>
</PreviewCardContent>
</PreviewCard>
</div>
)
}
export const Placements: Story = {
parameters: {
layout: 'fullscreen',
},
render: () => <PlacementsDemo />,
}
const CustomDelayDemo = () => (
<PreviewCard>
<PreviewCardTrigger
delay={100}
closeDelay={100}
render={<button type="button" className={triggerButtonClassName}>Snappy trigger</button>}
/>
<PreviewCardContent popupClassName="w-64 p-3">
<div className="flex flex-col gap-1">
<div className="text-sm font-semibold text-text-primary">Fast hover</div>
<div className="text-xs text-text-secondary">
Base UI defaults (600ms / 300ms) are tuned for link previews. Override per trigger for denser UIs.
</div>
</div>
</PreviewCardContent>
</PreviewCard>
)
export const CustomDelays: Story = {
render: () => <CustomDelayDemo />,
}

View File

@ -0,0 +1,81 @@
'use client'
import type { ReactNode } from 'react'
import type { Placement } from '../placement'
import { PreviewCard as BasePreviewCard } from '@base-ui/react/preview-card'
import { cn } from '../cn'
import { parsePlacement } from '../placement'
export type { Placement }
/**
* PreviewCard is a hover/focus-triggered rich preview intended to supplement a
* trigger whose primary action is its own click destination (e.g. a link, a
* selectable row, a chip that jumps to a definition).
*
* A11y contract match Base UI's guidance:
* - The popup MUST NOT contain information or actions that are not also
* reachable from the trigger's primary click destination. Touch and screen
* reader users cannot open the card and must be able to get the same
* information/actions without it.
* - If content is unique to the popup, either (a) add a separate click-triggered
* affordance (Popover) next to the trigger, or (b) move the unique content
* onto the click destination.
*/
export const PreviewCard = BasePreviewCard.Root
export const PreviewCardTrigger = BasePreviewCard.Trigger
export const createPreviewCardHandle = BasePreviewCard.createHandle
type PreviewCardContentProps = {
children: ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: Omit<
BasePreviewCard.Positioner.Props,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
BasePreviewCard.Popup.Props,
'children' | 'className'
>
}
export function PreviewCardContent({
children,
placement = 'bottom',
sideOffset = 8,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: PreviewCardContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BasePreviewCard.Portal>
<BasePreviewCard.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-1002 outline-hidden', className)}
{...positionerProps}
>
<BasePreviewCard.Popup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
popupClassName,
)}
{...popupProps}
>
{children}
</BasePreviewCard.Popup>
</BasePreviewCard.Positioner>
</BasePreviewCard.Portal>
)
}

View File

@ -46,20 +46,7 @@ describe('TooltipContent', () => {
})
})
describe('Variant and popup props', () => {
it('should render popup content when variant is plain', async () => {
const screen = await render(
<Tooltip open>
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
<TooltipContent variant="plain" role="tooltip" aria-label="plain tooltip">
Plain tooltip body
</TooltipContent>
</Tooltip>,
)
await expect.element(screen.getByRole('tooltip', { name: 'plain tooltip' })).toHaveTextContent('Plain tooltip body')
})
describe('Popup props', () => {
it('should forward popup props and handlers when popup props are provided', async () => {
const onMouseEnter = vi.fn()
@ -83,7 +70,11 @@ describe('TooltipContent', () => {
await expect.element(popup).toHaveAttribute('id', 'tooltip-popup-id')
await expect.element(popup).toHaveAttribute('data-track-id', 'tooltip-track')
expect(onMouseEnter).toHaveBeenCalledTimes(1)
// Intent of the assertion is "handler is wired up". The exact call count
// depends on vitest-browser's pointer simulation and Base UI's internal
// pointer tracking (both of which may fire more than one enter event for
// a single `.hover()` action), so assert presence, not count.
expect(onMouseEnter).toHaveBeenCalled()
})
it('should apply className to the popup and positionerClassName to the positioner', async () => {

View File

@ -8,8 +8,8 @@ import {
TooltipTrigger,
} from '.'
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
const iconButtonClassName = 'inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-subtle bg-components-button-secondary-bg text-text-secondary shadow-xs hover:bg-state-base-hover'
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
const meta = {
title: 'Base/UI/Tooltip',
@ -25,7 +25,7 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement, offsets, and two style variants.',
component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement and offsets.\n\n**Usage contract** (mirrors the [Base UI tooltip guidelines](https://base-ui.com/react/components/tooltip#alternatives-to-tooltips)):\n\n- Tooltips are **supplementary visual labels** for sighted mouse and keyboard users. They are disabled on touch devices and are not announced to screen readers.\n- The trigger **must carry its own `aria-label` or visible text** that matches the tooltip — the tooltip does not replace labeling.\n- Keep content short and non-interactive (an icon-button label, a keyboard shortcut, one-word clarification).\n- **Do not** place descriptions, prose, links, or interactive controls inside a tooltip — touch and screen-reader users cannot reach them.\n- For hover-triggered rich previews that users move their cursor onto, use `PreviewCard` (dwell-able, structured content).\n- For an info icon that explains a concept (an "infotip"), or for any hover popup that needs interactive content or to reach touch/assistive-tech users, use `Popover` with `openOnHover` on the trigger.',
},
},
},
@ -35,47 +35,58 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Hover me
</TooltipTrigger>
<TooltipContent>
Tooltips describe interactive elements without a click.
</TooltipContent>
</Tooltip>
),
}
const ICON_ACTIONS = [
{ icon: 'i-ri-pencil-line', label: 'Edit' },
{ icon: 'i-ri-file-copy-line', label: 'Duplicate' },
{ icon: 'i-ri-archive-line', label: 'Archive' },
{ icon: 'i-ri-delete-bin-line', label: 'Delete' },
] as const
export const Plain: Story = {
export const IconButton: Story = {
name: 'Icon button (canonical)',
parameters: {
docs: {
description: {
story: 'Use `variant="plain"` to render the popup without default chrome (background, padding, typography). Apply your own styling via `className` on `TooltipContent`.',
story: 'The canonical tooltip use case: an icon-only button surfaces its accessible label as a tooltip for sighted mouse and keyboard users. The trigger already carries `aria-label` — the tooltip mirrors that label visually; it does **not** replace it.',
},
},
},
render: () => (
<div className="flex items-center gap-3">
{ICON_ACTIONS.map(({ icon, label }) => (
<Tooltip key={label}>
<TooltipTrigger
render={(
<button type="button" aria-label={label} className={iconButtonClassName}>
<span aria-hidden className={`${icon} h-4 w-4`} />
</button>
)}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
))}
</div>
),
}
export const KeyboardShortcut: Story = {
parameters: {
docs: {
description: {
story: 'A short, supplementary hint that surfaces a keyboard shortcut next to a visible button label. The trigger is fully self-describing ("Save"); the tooltip only adds non-essential extra clarity for mouse/keyboard users.',
},
},
},
render: () => (
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Preview details
</TooltipTrigger>
<TooltipContent
variant="plain"
className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-lg"
>
<div className="flex w-64 flex-col gap-1">
<span className="text-sm font-semibold text-text-primary">Dataset preview</span>
<span className="text-xs text-text-secondary">
32 documents Last indexed 2 minutes ago
</span>
</div>
</TooltipContent>
render={(
<button type="button" className={triggerButtonClassName}>
Save
</button>
)}
/>
<TooltipContent>S</TooltipContent>
</Tooltip>
),
}
@ -116,14 +127,10 @@ const PlacementsDemo = () => {
</div>
<Tooltip open>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Anchor
</TooltipTrigger>
render={<button type="button" aria-label="Placement anchor" className={iconButtonClassName}><span aria-hidden className="i-ri-pushpin-line h-4 w-4" /></button>}
/>
<TooltipContent placement={placement}>
placement="
{placement}
"
{`placement="${placement}"`}
</TooltipContent>
</Tooltip>
</div>
@ -133,113 +140,45 @@ const PlacementsDemo = () => {
export const Placements: Story = {
parameters: {
layout: 'fullscreen',
docs: {
description: {
story: 'Placement reference. `placement` accepts the 12 standard side/align combinations; Base UI flips automatically if the tooltip would overflow the viewport.',
},
},
},
render: () => <PlacementsDemo />,
}
export const OnIconButtons: Story = {
parameters: {
docs: {
description: {
story: 'Tooltips are essential for icon-only buttons. The trigger is the button; the tooltip provides the accessible label and hover hint.',
},
},
},
render: () => (
<div className="flex items-center gap-3">
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label="Edit" className={iconButtonClassName}>
<span aria-hidden className="i-ri-pencil-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label="Duplicate" className={iconButtonClassName}>
<span aria-hidden className="i-ri-file-copy-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>Duplicate</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label="Archive" className={iconButtonClassName}>
<span aria-hidden className="i-ri-archive-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>Archive</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label="Delete" className={iconButtonClassName}>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
),
}
export const LongContent: Story = {
render: () => (
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
What are tokens?
</TooltipTrigger>
<TooltipContent>
Tokens are the basic units a model reads. English text averages ~4 characters per token; non-Latin scripts often use more tokens per character. Both input and output count toward your quota.
</TooltipContent>
</Tooltip>
),
}
const DELAY_PRESETS: Array<{ label: string, delay: number }> = [
{ label: 'Instant (0ms)', delay: 0 },
{ label: 'Fast (150ms)', delay: 150 },
{ label: 'Default (600ms)', delay: 600 },
{ label: 'Instant', delay: 0 },
{ label: 'Fast', delay: 150 },
{ label: 'Default', delay: 600 },
]
const DelayDemo = () => {
return (
<div className="flex items-center gap-3">
{DELAY_PRESETS.map(({ label, delay }) => (
<TooltipProvider key={delay} delay={delay}>
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
{label}
</TooltipTrigger>
<TooltipContent>
Appeared after
{delay}
ms hover delay.
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
)
}
const DelayDemo = () => (
<div className="flex items-center gap-3">
{DELAY_PRESETS.map(({ label, delay }) => (
<TooltipProvider key={delay} delay={delay}>
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label={`${label} (${delay}ms)`} className={iconButtonClassName}>
<span aria-hidden className="i-ri-timer-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>{`${label} (${delay}ms)`}</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
)
export const WithDelay: Story = {
parameters: {
docs: {
description: {
story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown.',
story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown. The Dify app root sets `delay={300} closeDelay={200}` — override locally only when the surrounding UX demands it.',
},
},
},

View File

@ -8,7 +8,28 @@ import { parsePlacement } from '../placement'
export type { Placement }
type TooltipContentVariant = 'default' | 'plain'
/**
* Tooltip is an **ephemeral hint** tied to a trigger (typically an icon button,
* badge, or short label). It follows Base UI's Tooltip semantics:
*
* - Opens on pointer hover or keyboard focus on the trigger.
* - Closes as soon as the pointer leaves the trigger the popup itself is
* **not dwell-able**; users cannot move their cursor onto the tooltip.
* - Must contain only short, non-interactive text. No links, buttons, form
* controls, or structured panels.
*
* If you need any of the following, use `PreviewCard` instead (hover-triggered
* rich preview that users can move their cursor onto):
*
* - Multi-line or structured content (icon + title + metadata)
* - Content the user needs to "stop and read" for more than ~1 second
* - Content wider than ~300px
*
* If you need interactive affordances (buttons, links, forms) use `Popover`.
*/
export const TooltipProvider = BaseTooltip.Provider
export const Tooltip = BaseTooltip.Root
export const TooltipTrigger = BaseTooltip.Trigger
type TooltipContentProps = {
children: ReactNode
@ -17,7 +38,6 @@ type TooltipContentProps = {
alignOffset?: number
positionerClassName?: string
className?: string
variant?: TooltipContentVariant
} & Omit<BaseTooltip.Popup.Props, 'children' | 'className'>
export function TooltipContent({
@ -27,7 +47,6 @@ export function TooltipContent({
alignOffset = 0,
positionerClassName,
className,
variant = 'default',
...props
}: TooltipContentProps) {
const { side, align } = parsePlacement(placement)
@ -43,7 +62,7 @@ export function TooltipContent({
>
<BaseTooltip.Popup
className={cn(
variant === 'default' && 'max-w-[300px] rounded-md bg-components-panel-bg px-3 py-2 text-left system-xs-regular wrap-break-word text-text-tertiary shadow-lg',
'max-w-[300px] rounded-md bg-components-panel-bg px-3 py-2 text-left system-xs-regular wrap-break-word text-text-tertiary shadow-lg',
'origin-(--transform-origin) transition-opacity data-ending-style:opacity-0 data-instant:transition-none data-starting-style:opacity-0 motion-reduce:transition-none',
className,
)}
@ -55,7 +74,3 @@ export function TooltipContent({
</BaseTooltip.Portal>
)
}
export const TooltipProvider = BaseTooltip.Provider
export const Tooltip = BaseTooltip.Root
export const TooltipTrigger = BaseTooltip.Trigger

410
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -42,15 +42,15 @@ overrides:
svgo@>=3.0.0 <3.3.3: 3.3.3
tar@<=7.5.10: 7.5.11
undici@>=7.0.0 <7.24.0: 7.24.0
vite: npm:@voidzero-dev/vite-plus-core@0.1.18
vitest: npm:@voidzero-dev/vite-plus-test@0.1.18
vite: npm:@voidzero-dev/vite-plus-core@0.1.19
vitest: npm:@voidzero-dev/vite-plus-test@0.1.19
yaml@>=2.0.0 <2.8.3: 2.8.3
yauzl@<3.2.1: 3.2.1
catalog:
'@amplitude/analytics-browser': 2.39.0
'@amplitude/plugin-session-replay-browser': 1.27.7
'@antfu/eslint-config': 8.2.0
'@base-ui/react': 1.4.0
'@base-ui/react': 1.4.1
'@chromatic-com/storybook': 5.1.2
'@cucumber/cucumber': 12.8.0
'@egoist/tailwindcss-icons': 1.9.2
@ -222,10 +222,10 @@ catalog:
use-context-selector: 2.0.0
uuid: 13.0.0
vinext: 0.0.41
vite: npm:@voidzero-dev/vite-plus-core@0.1.18
vite: npm:@voidzero-dev/vite-plus-core@0.1.19
vite-plugin-inspect: 12.0.0-beta.1
vite-plus: 0.1.18
vitest: npm:@voidzero-dev/vite-plus-test@0.1.18
vite-plus: 0.1.19
vitest: npm:@voidzero-dev/vite-plus-test@0.1.19
vitest-browser-react: 2.2.0
vitest-canvas-mock: 1.1.4
zod: 4.3.6

View File

@ -39,7 +39,7 @@ const jsonResponse = (
...init,
headers: {
"content-type": "application/json",
...(init.headers ?? {}),
...init.headers,
},
});
@ -47,7 +47,7 @@ const textResponse = (body: string, init: ResponseInit = {}): Response =>
new Response(body, {
...init,
headers: {
...(init.headers ?? {}),
...init.headers,
},
});

View File

@ -14,7 +14,7 @@ const jsonResponse = (body: unknown, init: ResponseInit = {}): Response =>
...init,
headers: {
"content-type": "application/json",
...(init.headers ?? {}),
...init.headers,
},
});

View File

@ -4,6 +4,7 @@ export default defineConfig({
pack: {
entry: ["src/index.ts"],
format: ["esm"],
platform: "node",
dts: true,
clean: true,
sourcemap: true,

View File

@ -10,6 +10,72 @@ vi.mock('@/next/navigation', () => ({
usePathname: () => '/test',
}))
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div
data-testid="popover-trigger"
onClick={() => setOpen(!open)}
>
{render}
</div>
)
}
const PopoverContent = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => {
const { open } = React.useContext(PopoverContext)
if (!open)
return null
return (
<div data-testid="popover-content" {...props}>
{children}
</div>
)
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
type PortalToFollowElemProps = {
children: React.ReactNode
open?: boolean
@ -209,20 +275,17 @@ describe('ContextVar', () => {
// Act
render(<ContextVar {...props} />)
const triggers = screen.getAllByTestId('portal-trigger')
const varPickerTrigger = triggers[triggers.length - 1]
const varPickerTrigger = screen.getByTestId('popover-trigger')
await user.click(varPickerTrigger!)
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
// Select a different option
const options = screen.getAllByText('var2')
expect(options.length).toBeGreaterThan(0)
await user.click(options[0]!)
await user.click(screen.getByText('var2'))
// Assert
expect(onChange).toHaveBeenCalledWith('var2')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
it('should toggle dropdown when clicking the trigger button', async () => {
@ -233,16 +296,15 @@ describe('ContextVar', () => {
// Act
render(<ContextVar {...props} />)
const triggers = screen.getAllByTestId('portal-trigger')
const varPickerTrigger = triggers[triggers.length - 1]
const varPickerTrigger = screen.getByTestId('popover-trigger')
// Open dropdown
await user.click(varPickerTrigger!)
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
// Close dropdown
await user.click(varPickerTrigger!)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
})

View File

@ -18,18 +18,21 @@ type PortalToFollowElemProps = {
type PortalToFollowElemTriggerProps = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode, asChild?: boolean }
type PortalToFollowElemContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const PortalContext = React.createContext({ open: false })
vi.mock('@langgenius/dify-ui/popover', () => {
const PortalContext = React.createContext({
open: false,
onOpenChange: undefined as ((open: boolean) => void) | undefined,
})
const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
const Popover = ({ children, open, onOpenChange }: PortalToFollowElemProps) => {
return (
<PortalContext.Provider value={{ open: !!open }}>
<PortalContext.Provider value={{ open: !!open, onOpenChange }}>
<div data-testid="portal">{children}</div>
</PortalContext.Provider>
)
}
const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
const PopoverContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
const { open } = React.useContext(PortalContext)
if (!open)
return null
@ -40,24 +43,41 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => {
)
}
const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
const PopoverTrigger = ({ children, asChild, render, ...props }: PortalToFollowElemTriggerProps & { render?: React.ReactNode }) => {
const { open, onOpenChange } = React.useContext(PortalContext)
const content = render ?? children
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
props.onClick?.(e)
if (!props.onClick)
onOpenChange?.(!open)
}
if (React.isValidElement(content)) {
return React.cloneElement(content, {
...props,
'onClick': handleClick,
'data-testid': 'portal-trigger',
} as React.HTMLAttributes<HTMLElement>)
}
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
...props,
'onClick': handleClick,
'data-testid': 'portal-trigger',
} as React.HTMLAttributes<HTMLElement>)
}
return (
<div data-testid="portal-trigger" {...props}>
{children}
<div data-testid="portal-trigger" {...props} onClick={handleClick}>
{content}
</div>
)
}
return {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
Popover,
PopoverContent,
PopoverTrigger,
}
})

View File

@ -3,15 +3,15 @@ import type { FC } from 'react'
import type { IInputTypeIconProps } from '@/app/components/app/configuration/config-var/input-type-icon'
import { ChevronDownIcon } from '@heroicons/react/24/outline'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import IconTypeIcon from '@/app/components/app/configuration/config-var/input-type-icon'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type Option = { name: string, value: string, type: string }
export type Props = {
@ -33,6 +33,7 @@ const VarItem: FC<{ item: Option }> = ({ item }) => (
</div>
</div>
)
const VarPicker: FC<Props> = ({
triggerClassName,
className,
@ -45,47 +46,51 @@ const VarPicker: FC<Props> = ({
const [open, setOpen] = useState(false)
const currItem = options.find(item => item.value === value)
const notSetVar = !currItem
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 8,
}}
>
<PortalToFollowElemTrigger className={cn(triggerClassName)} onClick={() => setOpen(v => !v)}>
<div className={cn(
className,
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
`
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
font-medium shadow-xs
`,
)}
>
<div>
{value
? (
<VarItem item={currItem as Option} />
)
: (
<div>
{notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })}
</div>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
nativeButton={false}
render={(
<div className={cn(triggerClassName)}>
<div className={cn(
className,
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
`
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
font-medium shadow-xs
`,
)}
>
<div>
{currItem
? (
<VarItem item={currItem} />
)
: (
<div>
{notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })}
</div>
)}
</div>
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'h-3.5 w-3.5')} />
</div>
</div>
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'h-3.5 w-3.5')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={8}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
{options.length > 0
? (
<div className="max-h-[50vh] w-[240px] overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg">
{options.map(({ name, value, type }, index) => (
{options.map(({ name, value, type }) => (
<div
key={index}
key={value}
className="flex cursor-pointer rounded-lg px-3 py-1 hover:bg-state-base-hover"
onClick={() => {
onChange(value)
@ -103,9 +108,9 @@ const VarPicker: FC<Props> = ({
<div className="text-xs leading-normal text-text-tertiary">{t('feature.dataSet.queryVariable.noVarTip', { ns: 'appDebug' })}</div>
</div>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
export default React.memo(VarPicker)

View File

@ -0,0 +1,82 @@
'use client'
import type { Placement } from '@langgenius/dify-ui/popover'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
/**
* Infotip a `?` icon that reveals a long-form explanation on hover / focus / tap.
*
* Implements the pattern Base UI calls an "infotip":
* https://base-ui.com/react/components/tooltip#infotips
*
* > "Popups that open when hovering an info icon should use Popover with the
* > `openOnHover` prop on the trigger instead of a tooltip. This way, touch
* > users and screen reader users can access the content."
*
* Use whenever the trigger is an info glyph whose sole purpose is to open a
* popup (help text, documentation-style explanation). Do NOT use `Tooltip` for
* this Tooltip is reserved for ephemeral, non-interactive visual labels that
* are unreachable on touch devices and by screen readers.
*
* Base UI rule of thumb:
*
* > "If the trigger's purpose is to open the popup itself, it's a popover.
* > If the trigger's purpose is unrelated to opening the popup, it's a tooltip."
*
* For hover-revealed supplementary previews of a link / row trigger that has
* its own primary click destination, use `PreviewCard` instead.
*/
type InfotipProps = {
/** Popup content. Rich nodes are allowed. */
'children': ReactNode
/** Accessible name for the trigger. Required; should match the popup text. */
'aria-label': string
/** Placement of the popup relative to the trigger. Defaults to `top`. */
'placement'?: Placement
/** Extra classes on the outer trigger wrapper (layout / margin). */
'className'?: string
/** Extra classes on the `?` icon itself (size / color overrides). */
'iconClassName'?: string
/** Extra classes on the popup body (width / padding / whitespace overrides). */
'popupClassName'?: string
/** Hover open delay in ms. Defaults to 300 to match the app-wide Tooltip delay. */
'delay'?: number
/** Hover close delay in ms. Defaults to 200 to match the app-wide Tooltip delay. */
'closeDelay'?: number
}
export function Infotip({
children,
'aria-label': ariaLabel,
placement = 'top',
className,
iconClassName,
popupClassName,
delay = 300,
closeDelay = 200,
}: InfotipProps) {
return (
<Popover>
<PopoverTrigger
openOnHover
delay={delay}
closeDelay={closeDelay}
aria-label={ariaLabel}
render={(
<span className={cn('inline-flex h-4 w-4 shrink-0 items-center justify-center', className)}>
<span aria-hidden className={cn('i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary', iconClassName)} />
</span>
)}
/>
<PopoverContent
placement={placement}
popupClassName={cn('max-w-[300px] px-3 py-2 system-xs-regular text-text-tertiary', popupClassName)}
>
{children}
</PopoverContent>
</Popover>
)
}

View File

@ -423,11 +423,11 @@ describe('prompt-editor/hooks', () => {
maxLength: 5,
}))
const match = result.current('prefix @..', {} as LexicalEditor)
const match = result.current('prefix @ab', {} as LexicalEditor)
expect(match).toEqual({
leadOffset: 7,
matchingString: '..',
replaceableString: '@..',
matchingString: 'ab',
replaceableString: '@ab',
})
})
@ -437,7 +437,7 @@ describe('prompt-editor/hooks', () => {
maxLength: 5,
}))
expect(result.current('prefix @.', {} as LexicalEditor)).toBeNull()
expect(result.current('prefix @a', {} as LexicalEditor)).toBeNull()
})
it('should return null when matching text exceeds maxLength', () => {
@ -445,7 +445,7 @@ describe('prompt-editor/hooks', () => {
minLength: 1,
maxLength: 2,
}))
expect(result.current('prefix @...', {} as LexicalEditor)).toBeNull()
expect(result.current('prefix @abc', {} as LexicalEditor)).toBeNull()
})
it('should return null when text has no trigger character', () => {

View File

@ -154,17 +154,18 @@ type TriggerFn = (
text: string,
editor: LexicalEditor,
) => MenuTextMatch | null
const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
const escapeForCharacterClass = (value: string) => value.replace(/[[\]\\^-]/g, '\\$&')
export function useBasicTypeaheadTriggerMatch(
trigger: string,
{ minLength = 1, maxLength = 75 }: { minLength?: number, maxLength?: number },
): TriggerFn {
return useCallback(
(text: string) => {
const validChars = `[${PUNCTUATION}\\s]`
const escapedTrigger = escapeForCharacterClass(trigger)
const validChars = `[^${escapedTrigger}\\n\\r]`
const TypeaheadTriggerRegex = new RegExp(
'(.*)('
+ `[${trigger}]`
+ `[${escapedTrigger}]`
+ `((?:${validChars}){0,${maxLength}})`
+ ')$',
)

View File

@ -521,6 +521,84 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
await waitFor(() => expect(readEditorText(editor)).not.toContain('{'))
})
it('filters workflow variables from slash input and matches child paths', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const user = userEvent.setup()
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
makeWorkflowVarNode('node-1', 'Node 1', [
makeWorkflowNodeVar('payload', VarType.object, [makeWorkflowNodeVar('child_name', VarType.string)]),
makeWorkflowNodeVar('other_value', VarType.string),
]),
])
render((
<MinimalEditor
triggerString="/"
contextBlock={makeContextBlock()}
workflowVariableBlock={workflowVariableBlock}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
const dispatchSpy = vi.spyOn(editor, 'dispatchCommand')
await setEditorText(editor, '/child', true)
await flushNextTick()
expect(screen.queryByPlaceholderText('workflow.common.searchVar')).not.toBeInTheDocument()
expect(await screen.findByText('payload')).toBeInTheDocument()
expect(screen.queryByText('other_value')).not.toBeInTheDocument()
const label = document.querySelector('[title="payload"]')
expect(label).not.toBeNull()
const row = (label as HTMLElement).parentElement?.parentElement
expect(row).not.toBeNull()
await user.hover(row as HTMLElement)
const childField = await screen.findByText('child_name')
fireEvent.mouseDown(childField)
await user.unhover(row as HTMLElement)
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'payload', 'child_name'])
await waitFor(() => expect(readEditorText(editor)).not.toContain('/child'))
})
it('filters workflow variables on the first character after slash and does not highlight context by default', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
makeWorkflowVarNode('node-1', 'Node 1', [
makeWorkflowNodeVar('child_value', VarType.string),
makeWorkflowNodeVar('other_value', VarType.string),
]),
])
render((
<MinimalEditor
triggerString="/"
contextBlock={makeContextBlock()}
workflowVariableBlock={workflowVariableBlock}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '/c', true)
await flushNextTick()
expect(await screen.findByText('child_value')).toBeInTheDocument()
expect(screen.queryByText('other_value')).not.toBeInTheDocument()
const contextTitle = screen.getByText('common.promptEditor.context.item.title')
expect(contextTitle.closest('[tabindex="-1"]')).not.toHaveClass('bg-state-base-hover!')
await waitFor(() => {
expect(readEditorText(editor)).toContain('/c')
})
})
it('skips removing the trigger when selection is null (needRemove is null) and still dispatches', async () => {
const captures: Captures = { editor: null, eventEmitter: null }

View File

@ -1,5 +1,5 @@
import type { MenuRenderFn } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import type { TextNode } from 'lexical'
import type { LexicalEditor, TextNode } from 'lexical'
import type {
ContextBlockType,
CurrentBlockType,
@ -89,10 +89,16 @@ const ComponentPicker = ({
],
})
const [editor] = useLexicalComposerContext()
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, {
const triggerMatchRef = useRef<string | null>(null)
const baseCheckForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, {
minLength: 0,
maxLength: 0,
maxLength: 75,
})
const checkForTriggerMatch = useCallback((text: string, editor: LexicalEditor) => {
const match = baseCheckForTriggerMatch(text, editor)
triggerMatchRef.current = match?.matchingString ?? null
return match
}, [baseCheckForTriggerMatch])
const [queryString, setQueryString] = useState<string | null>(null)
const [blurHidden, setBlurHidden] = useState(false)
@ -155,6 +161,7 @@ const ComponentPicker = ({
currentBlock,
errorMessageBlock,
lastRunBlock,
queryString || undefined,
)
const onSelectOption = useCallback(
@ -207,6 +214,8 @@ const ComponentPicker = ({
anchorElementRef,
{ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) => {
const effectiveQueryString = triggerMatchRef.current ?? queryString
if (blurHidden)
return null
if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show)))
@ -237,6 +246,8 @@ const ComponentPicker = ({
workflowVariableBlock?.show && (
<div className="p-1">
<VarReferenceVars
hideSearch={triggerString === '/'}
searchText={triggerString === '/' ? (effectiveQueryString || '') : undefined}
searchBoxClassName="mt-1"
vars={workflowVariableOptions}
onChange={(variables: string[]) => {
@ -270,8 +281,8 @@ const ComponentPicker = ({
)
}
{option.renderMenuOption({
queryString,
isSelected: selectedIndex === index,
queryString: effectiveQueryString,
isSelected: workflowVariableBlock?.show ? false : selectedIndex === index,
onSelect: () => {
selectOptionAndCleanUp(option)
},

View File

@ -302,8 +302,8 @@ describe('HITLInputVariableBlockComponent', () => {
})
})
describe('Tooltip payload', () => {
it('should call getVarType with rag selector and use rag node id mapping', () => {
describe('Full-path preview payload', () => {
it('should resolve the rag node via isRagVar offset and skip the full-path preview', () => {
const getVarType = vi.fn(() => Type.number)
const { container } = renderVariableBlock({
variables: ['rag', 'node-rag', 'chunk'],
@ -314,10 +314,9 @@ describe('HITLInputVariableBlockComponent', () => {
expect(screen.getByText('chunk')).toBeInTheDocument()
expect(hasErrorIcon(container)).toBe(false)
expect(getVarType).toHaveBeenCalledWith({
nodeId: 'rag',
valueSelector: ['rag', 'node-rag', 'chunk'],
})
// Rag selectors always have `isShowAPart === false`, so the full-path
// preview is not rendered and `getVarType` is not invoked.
expect(getVarType).not.toHaveBeenCalled()
})
it('should use shortened display name for deep non-rag selectors', () => {

View File

@ -1,6 +1,7 @@
import type { UpdateWorkflowNodesMapPayload } from '../workflow-variable-block'
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
@ -13,7 +14,6 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import {
isConversationVar,
isENV,
@ -119,13 +119,13 @@ const HITLInputVariableBlockComponent = ({
/>
)
if (!node)
if (!node || !isShowAPart)
return Item
return (
<Tooltip
noDecoration
popupContent={(
<PreviewCard>
<PreviewCardTrigger delay={300} closeDelay={200} render={<div>{Item}</div>} />
<PreviewCardContent popupClassName="border-0 bg-transparent p-0 shadow-none">
<VarFullPathPanel
nodeName={node.title}
path={variables.slice(1)}
@ -137,11 +137,8 @@ const HITLInputVariableBlockComponent = ({
: Type.string}
nodeType={node?.type}
/>
)}
disabled={!isShowAPart}
>
<div>{Item}</div>
</Tooltip>
</PreviewCardContent>
</PreviewCard>
)
}

View File

@ -3,7 +3,7 @@ import type {
} from './index'
import type { WorkflowNodesMap } from './node'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
@ -151,13 +151,13 @@ const WorkflowVariableBlockComponent = ({
/>
)
if (!node)
if (!node || !isShowAPart)
return Item
return (
<Tooltip>
<TooltipTrigger disabled={!isShowAPart} render={<div>{Item}</div>} />
<TooltipContent variant="plain">
<PreviewCard>
<PreviewCardTrigger delay={300} closeDelay={200} render={<div>{Item}</div>} />
<PreviewCardContent popupClassName="border-0 bg-transparent p-0 shadow-none">
<VarFullPathPanel
nodeName={node.title}
path={variables.slice(1)}
@ -169,8 +169,8 @@ const WorkflowVariableBlockComponent = ({
: Type.string}
nodeType={node?.type}
/>
</TooltipContent>
</Tooltip>
</PreviewCardContent>
</PreviewCard>
)
}

View File

@ -309,7 +309,7 @@ describe('UsageInfo', () => {
/>,
)
expect(container.querySelector('[data-state]')).toBeInTheDocument()
expect(container.querySelectorAll('.cursor-default').length).toBeGreaterThan(0)
})
})
})

View File

@ -3,7 +3,8 @@ import { defaultPlan } from '../../config'
import { Plan } from '../../type'
import VectorSpaceInfo from '../vector-space-info'
const queryPlaceholder = () => document.body.querySelector('[aria-hidden="true"]')
const queryPlaceholder = () =>
document.body.querySelector('[aria-hidden="true"].bg-components-progress-bar-bg')
// Mock provider context with configurable plan
let mockPlanType = Plan.sandbox

View File

@ -3,9 +3,10 @@ import type { MeterTone } from '@langgenius/dify-ui/meter'
import type { ComponentType, FC, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { MeterIndicator, MeterRoot, MeterTrack } from '@langgenius/dify-ui/meter'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import { NUM_INFINITE } from '../config'
type Props = {
@ -159,11 +160,11 @@ const UsageInfo: FC<Props> = ({
const wrapWithStorageTooltip = (children: ReactNode) => {
if (storageMode && storageTooltip) {
return (
<Tooltip
popupContent={<div className="w-[200px]">{storageTooltip}</div>}
asChild={false}
>
<div className="cursor-default">{children}</div>
<Tooltip>
<TooltipTrigger render={<div className="cursor-default">{children}</div>} />
<TooltipContent className="w-[200px] max-w-[200px]">
{storageTooltip}
</TooltipContent>
</Tooltip>
)
}
@ -178,13 +179,9 @@ const UsageInfo: FC<Props> = ({
<div className="flex items-center gap-1">
<div className="system-xs-medium text-text-tertiary">{name}</div>
{tooltip && (
<Tooltip
popupContent={(
<div className="w-[180px]">
{tooltip}
</div>
)}
/>
<Infotip aria-label={tooltip} popupClassName="w-[180px] max-w-[180px]">
{tooltip}
</Infotip>
)}
</div>
<div className="flex items-center gap-1 system-md-semibold text-text-primary">

View File

@ -2,16 +2,20 @@
import type { FC } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { useMembers } from '@/service/use-common'
type Props = {
value?: any
onSelect: (value: any) => void
value?: string
onSelect: (value: string) => void
exclude?: string[]
}
@ -27,12 +31,9 @@ const MemberSelector: FC<Props> = ({
const { data } = useMembers()
const currentValue = useMemo(() => {
if (!data?.accounts)
if (!data?.accounts || !value)
return null
const accounts = data.accounts || []
if (!value)
return null
return accounts.find(account => account.id === value)
return data.accounts.find(account => account.id === value) ?? null
}, [data, value])
const filteredList = useMemo(() => {
@ -47,37 +48,36 @@ const MemberSelector: FC<Props> = ({
return name.toLowerCase().includes(searchValue.toLowerCase())
|| email.toLowerCase().includes(searchValue.toLowerCase())
}).filter(account => !exclude.includes(account.id))
}, [data, searchValue, exclude])
}, [data, exclude, searchValue])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom"
offset={4}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={() => setOpen(v => !v)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<div
data-testid="member-selector-trigger"
className={cn('group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}
>
{!currentValue && (
<div className="grow p-1 system-sm-regular text-components-input-text-placeholder">{t('members.transferModal.transferPlaceholder', { ns: 'common' })}</div>
)}
{currentValue && (
<>
<Avatar avatar={currentValue.avatar_url} size="sm" name={currentValue.name} />
<div className="grow truncate system-sm-medium text-text-secondary">{currentValue.name}</div>
<div className="system-xs-regular text-text-quaternary">{currentValue.email}</div>
</>
)}
<div className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
</div>
)}
/>
<PopoverContent
placement="bottom"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1002 } }}
>
<div
data-testid="member-selector-trigger"
className={cn('group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}
>
{!currentValue && (
<div className="grow p-1 system-sm-regular text-components-input-text-placeholder">{t('members.transferModal.transferPlaceholder', { ns: 'common' })}</div>
)}
{currentValue && (
<>
<Avatar avatar={currentValue.avatar_url} size="sm" name={currentValue.name} />
<div className="grow truncate system-sm-medium text-text-secondary">{currentValue.name}</div>
<div className="system-xs-regular text-text-quaternary">{currentValue.email}</div>
</>
)}
<div className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
<div className="min-w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<Input
@ -105,8 +105,9 @@ const MemberSelector: FC<Props> = ({
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
export default MemberSelector

View File

@ -7,9 +7,9 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select'
import { Slider } from '@langgenius/dify-ui/slider'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import PromptEditor from '@/app/components/base/prompt-editor'
import Radio from '@/app/components/base/radio'
import TagInput from '@/app/components/base/tag-input'
@ -349,18 +349,13 @@ function ParameterItem({
</div>
{
parameterRule.help && (
<Tooltip>
<TooltipTrigger
render={(
<span className="mr-1 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary" />
</span>
)}
/>
<TooltipContent className="mr-1">
<div className="w-[150px] whitespace-pre-wrap">{parameterRule.help[language] || parameterRule.help.en_US}</div>
</TooltipContent>
</Tooltip>
<Infotip
aria-label={parameterRule.help[language] || parameterRule.help.en_US}
className="mr-1"
popupClassName="w-[150px] whitespace-pre-wrap"
>
{parameterRule.help[language] || parameterRule.help.en_US}
</Infotip>
)
}
</div>

View File

@ -10,7 +10,11 @@ import {
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
@ -160,8 +164,13 @@ const PopupItem: FC<PopupItemProps> = ({
</Popover>
</div>
{!collapsed && model.models.map(modelItem => (
<Tooltip key={modelItem.model}>
<TooltipTrigger
// Preview is supplementary: every field in it (name / type / mode / context size / capabilities)
// is reachable from the model's own configuration surface once the row is selected.
// Touch + screen reader users rely on the button's primary onClick, not the preview.
<PreviewCard key={modelItem.model}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
render={(
<button
type="button"
@ -197,10 +206,9 @@ const PopupItem: FC<PopupItemProps> = ({
</button>
)}
/>
<TooltipContent
<PreviewCardContent
placement="right"
variant="plain"
className="w-[206px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 backdrop-blur-xs"
popupClassName="w-[206px] bg-components-panel-bg-blur p-3 shadow-none backdrop-blur-xs"
>
<div className="flex flex-col gap-1">
<div className="flex flex-col items-start gap-2">
@ -245,8 +253,8 @@ const PopupItem: FC<PopupItemProps> = ({
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</PreviewCardContent>
</PreviewCard>
))}
</div>
)

View File

@ -225,7 +225,7 @@ const Popup: FC<PopupProps> = ({
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<div className="px-1 pb-1">
<div className="pr-1 pb-1 pl-3">
{
filteredModelList.map(model => (
<PopupItem

View File

@ -1,7 +1,7 @@
import type { UsagePriority } from '../use-credential-panel-state'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { PreferredProviderTypeEnum } from '../../declarations'
type UsagePrioritySectionProps = {
@ -20,6 +20,7 @@ export default function UsagePrioritySection({ value, disabled, onSelect }: Usag
const selectedKey = value === 'credits'
? PreferredProviderTypeEnum.system
: PreferredProviderTypeEnum.custom
const usagePriorityTip = t('modelProvider.card.usagePriorityTip', { ns: 'common' })
return (
<div className="p-1">
@ -31,19 +32,9 @@ export default function UsagePrioritySection({ value, disabled, onSelect }: Usag
<span className="truncate system-sm-medium text-text-secondary">
{t('modelProvider.card.usagePriority', { ns: 'common' })}
</span>
<Tooltip>
<TooltipTrigger
aria-label={t('modelProvider.card.usagePriorityTip', { ns: 'common' })}
render={(
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
{t('modelProvider.card.usagePriorityTip', { ns: 'common' })}
</TooltipContent>
</Tooltip>
<Infotip aria-label={usagePriorityTip}>
{usagePriorityTip}
</Infotip>
</div>
<div className="flex shrink-0 items-center gap-1">
{options.map(option => (

View File

@ -9,6 +9,7 @@ import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Loading from '@/app/components/base/loading'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import useTimestamp from '@/hooks/use-timestamp'
@ -100,19 +101,9 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
<div className="relative">
<div className="mb-2 flex h-4 items-center system-xs-medium-uppercase text-text-tertiary">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip>
<TooltipTrigger
aria-label={tipText}
render={(
<span className="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
{tipText}
</TooltipContent>
</Tooltip>
<Infotip aria-label={tipText} className="ml-0.5">
{tipText}
</Infotip>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">

View File

@ -11,13 +11,9 @@ import {
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { updateDefaultModel } from '@/service/common'
@ -138,21 +134,13 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
return (
<div className="flex min-h-6 items-center text-[13px] font-medium text-text-secondary">
{t(labelKey, { ns: 'common' })}
<Tooltip>
<TooltipTrigger
aria-label={tipText}
render={(
<span className="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
<div className="w-[261px] text-text-tertiary">
{tipText}
</div>
</TooltipContent>
</Tooltip>
<Infotip
aria-label={tipText}
className="ml-0.5"
popupClassName="w-[261px]"
>
{tipText}
</Infotip>
</div>
)
}

View File

@ -4,6 +4,59 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { SubscriptionSelectorEntry } from '../selector-entry'
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
const PopoverContent = ({ children }: { children: React.ReactNode }) => {
const { open } = React.useContext(PopoverContext)
return open ? <div data-testid="popover-content">{children}</div> : null
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
let mockSubscriptions: TriggerSubscription[] = []
const mockRefetch = vi.fn()
@ -92,6 +145,6 @@ describe('SubscriptionSelectorEntry', () => {
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'sub-1', name: 'Subscription One' }), expect.any(Function))
expect(screen.queryByText('Subscription One')).not.toBeInTheDocument()
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
})

View File

@ -1,28 +1,26 @@
'use client'
import type { SimpleSubscription } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine, RiWebhookLine } from '@remixicon/react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { SubscriptionList } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
import { SubscriptionListMode } from './types'
import { useSubscriptionList } from './use-subscription-list'
type SubscriptionTriggerButtonProps = {
selectedId?: string
onClick?: () => void
isOpen?: boolean
className?: string
}
const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({
selectedId,
onClick,
isOpen = false,
className,
}) => {
@ -44,7 +42,7 @@ const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({
}
if (subscriptions && subscriptions.length > 0) {
const selectedSubscription = subscriptions?.find(sub => sub.id === selectedId)
const selectedSubscription = subscriptions.find(sub => sub.id === selectedId)
if (!selectedSubscription) {
return {
@ -67,13 +65,13 @@ const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({
return (
<button
type="button"
className={cn(
'flex h-8 items-center gap-1 rounded-lg px-2 transition-colors',
'hover:bg-state-base-hover-alt',
isOpen && 'bg-state-base-hover-alt',
className,
)}
onClick={onClick}
>
<RiWebhookLine className={cn('h-3.5 w-3.5 shrink-0 text-text-secondary', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')} />
<span className={cn('truncate system-xs-medium text-components-button-ghost-text', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')}>
@ -97,22 +95,23 @@ export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: {
const [isOpen, setIsOpen] = useState(false)
return (
<PortalToFollowElem
placement="bottom-start"
offset={4}
open={isOpen}
onOpenChange={setIsOpen}
>
<PortalToFollowElemTrigger asChild>
<div>
<SubscriptionTriggerButton
selectedId={selectedId}
onClick={() => setIsOpen(!isOpen)}
isOpen={isOpen}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger
render={(
<div>
<SubscriptionTriggerButton
selectedId={selectedId}
isOpen={isOpen}
/>
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 11 } }}
>
<div className="rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg">
<SubscriptionList
mode={SubscriptionListMode.SELECTOR}
@ -123,7 +122,7 @@ export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: {
}}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -61,6 +61,9 @@ const setupMocks = (plugins: PluginStatus[] = []) => {
return { mockMutateAsync, mockHandleRefetch }
}
const getTaskMenuTrigger = () =>
document.getElementById('plugin-task-trigger')!.closest('[role="button"]') as HTMLElement
describe('usePluginTaskStatus Hook', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -637,7 +640,7 @@ describe('PluginTasks Component', () => {
render(<PluginTasks />)
// Click to open
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
// The popover content should be visible (PluginTaskList)
// The popover content should be visible (PluginTaskList)
@ -666,7 +669,7 @@ describe('PluginTasks Component', () => {
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
// Wait for popover content to render
await waitFor(() => {
@ -692,7 +695,7 @@ describe('PluginTasks Component', () => {
render(<PluginTasks />)
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
@ -713,16 +716,14 @@ describe('PluginTasks Component', () => {
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]'))!.toBeInTheDocument()
})
// Find and click the clear all button in error section
const clearButtons = screen.getAllByRole('button')
if (clearButtons.length > 0)
fireEvent.click(clearButtons[0]!)
fireEvent.click(screen.getByRole('button', { name: /task\.clearAll/i }))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
@ -741,7 +742,7 @@ describe('PluginTasks Component', () => {
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]'))!.toBeInTheDocument()
@ -813,7 +814,7 @@ describe('PluginTasks Component', () => {
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
expect(document.querySelector('.w-\\[360px\\]'))!.toBeInTheDocument()
})
@ -825,7 +826,7 @@ describe('PluginTasks Component', () => {
])
render(<PluginTasks />)
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
@ -837,7 +838,7 @@ describe('PluginTasks Component', () => {
])
render(<PluginTasks />)
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
@ -892,7 +893,7 @@ describe('PluginTasks Integration', () => {
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
// All sections should be visible
const sections = document.querySelectorAll('.max-h-\\[300px\\]')

View File

@ -97,6 +97,7 @@ const PluginTasks = () => {
onOpenChange={setOpen}
>
<DropdownMenuTrigger
nativeButton={false}
render={<div />}
disabled={!canOpenMenu}
>

View File

@ -61,35 +61,78 @@ vi.mock('@/service/use-plugins', () => ({
}),
}))
// Mock portal component for ToolPicker and StrategyPicker
// Mock popover component for ToolPicker and StrategyPicker
let mockPortalOpen = false
let forcePortalContentVisible = false // Allow tests to force content visibility
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: {
let mockPortalOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({ children, open = false, onOpenChange }: {
children: React.ReactNode
open: boolean
onOpenChange: (open: boolean) => void
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
mockPortalOpen = open
mockPortalOnOpenChange = onOpenChange
return (
<div data-testid="portal-elem" data-open={open}>{children}</div>
)
},
PopoverTrigger: ({ children, render, onClick, className }: {
children?: React.ReactNode
render?: React.ReactNode
onClick?: (e: React.MouseEvent) => void
className?: string
}) => (
<div
data-testid="portal-trigger"
onClick={(e) => {
onClick?.(e)
if (!onClick)
mockPortalOnOpenChange?.(!mockPortalOpen)
}}
className={className}
>
{render ?? children}
</div>
),
PopoverContent: ({ children, className, popupClassName }: {
children: React.ReactNode
className?: string
popupClassName?: string
}) => {
if (!mockPortalOpen && !forcePortalContentVisible)
return null
return <div data-testid="portal-content" className={[className, popupClassName].filter(Boolean).join(' ')}>{children}</div>
},
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open = false, onOpenChange }: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
mockPortalOpen = open
mockPortalOnOpenChange = onOpenChange
return <div data-testid="portal-elem" data-open={open}>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick, className }: {
children: React.ReactNode
onClick: (e: React.MouseEvent) => void
children?: React.ReactNode
onClick?: (e: React.MouseEvent) => void
className?: string
}) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: {
PortalToFollowElemContent: ({ children, className, popupClassName }: {
children: React.ReactNode
className?: string
popupClassName?: string
}) => {
// Allow forcing content visibility for testing option selection
if (!mockPortalOpen && !forcePortalContentVisible)
return null
return <div data-testid="portal-content" className={className}>{children}</div>
return <div data-testid="portal-content" className={[className, popupClassName].filter(Boolean).join(' ')}>{children}</div>
},
}))
@ -319,6 +362,7 @@ describe('auto-update-setting', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpen = false
mockPortalOnOpenChange = undefined
forcePortalContentVisible = false
mockPluginsData.plugins = []
})

View File

@ -4,8 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '@/app/components/plugins/types'
import ToolPicker from '../tool-picker'
let portalOpen = false
const mockInstalledPluginList = vi.hoisted(() => ({
data: {
plugins: [] as PluginDetail[],
@ -21,33 +19,51 @@ vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const _React = await import('react')
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => (
<PopoverContext.Provider value={{ open: !!open, setOpen: (nextOpen: boolean) => onOpenChange?.(nextOpen) }}>
{children}
</PopoverContext.Provider>
)
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
const PopoverContent = ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => {
const { open } = React.useContext(PopoverContext)
return open ? <div data-testid="popover-content" className={className}>{children}</div> : null
}
return {
PortalToFollowElem: ({
open,
children,
}: {
open: boolean
children: React.ReactNode
}) => {
portalOpen = open
return <div>{children}</div>
},
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) => <button data-testid="trigger" onClick={onClick}>{children}</button>,
PortalToFollowElemContent: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => portalOpen ? <div data-testid="portal-content" className={className}>{children}</div> : null,
Popover,
PopoverTrigger,
PopoverContent,
}
})
@ -118,7 +134,6 @@ const createPlugin = (
describe('ToolPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpen = false
mockInstalledPluginList.data = {
plugins: [],
}
@ -137,7 +152,7 @@ describe('ToolPicker', () => {
/>,
)
fireEvent.click(screen.getByTestId('trigger'))
fireEvent.click(screen.getByText('trigger'))
expect(onShowChange).toHaveBeenCalledWith(true)
})

View File

@ -2,15 +2,15 @@
import type { FC } from 'react'
import type { ActivePluginType } from '../../marketplace/constants'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import { useInstalledPluginList } from '@/service/use-plugins'
import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/constants'
@ -24,7 +24,6 @@ type Props = {
onChange: (value: string[]) => void
isShow: boolean
onShowChange: (isShow: boolean) => void
}
const ToolPicker: FC<Props> = ({
@ -35,43 +34,16 @@ const ToolPicker: FC<Props> = ({
onShowChange,
}) => {
const { t } = useTranslation()
const toggleShowPopup = useCallback(() => {
onShowChange(!isShow)
}, [onShowChange, isShow])
const tabs = [
{
key: PLUGIN_TYPE_SEARCH_MAP.all,
name: t('category.all', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.model,
name: t('category.models', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.tool,
name: t('category.tools', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.agent,
name: t('category.agents', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.extension,
name: t('category.extensions', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.datasource,
name: t('category.datasources', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.trigger,
name: t('category.triggers', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.bundle,
name: t('category.bundles', { ns: 'plugin' }),
},
{ key: PLUGIN_TYPE_SEARCH_MAP.all, name: t('category.all', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.model, name: t('category.models', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.tool, name: t('category.tools', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.agent, name: t('category.agents', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.extension, name: t('category.extensions', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.datasource, name: t('category.datasources', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.trigger, name: t('category.triggers', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.bundle, name: t('category.bundles', { ns: 'plugin' }) },
]
const [pluginType, setPluginType] = useState<ActivePluginType>(PLUGIN_TYPE_SEARCH_MAP.all)
@ -89,14 +61,13 @@ const ToolPicker: FC<Props> = ({
)
})
}, [data, pluginType, query, tags])
const handleCheckChange = useCallback((pluginId: string) => {
return () => {
const newValue = value.includes(pluginId)
? value.filter(id => id !== pluginId)
: [...value, pluginId]
onChange(newValue)
}
}, [onChange, value])
const handleCheckChange = (pluginId: string) => {
const newValue = value.includes(pluginId)
? value.filter(id => id !== pluginId)
: [...value, pluginId]
onChange(newValue)
}
const listContent = (
<div className="max-h-[396px] overflow-y-auto">
@ -105,7 +76,7 @@ const ToolPicker: FC<Props> = ({
key={item.plugin_id}
payload={item}
isChecked={value.includes(item.plugin_id)}
onCheckChange={handleCheckChange(item.plugin_id)}
onCheckChange={() => handleCheckChange(item.plugin_id)}
/>
))}
</div>
@ -121,21 +92,18 @@ const ToolPicker: FC<Props> = ({
<NoDataPlaceholder className="h-[396px]" noPlugins={!query} />
)
const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div>
return (
<PortalToFollowElem
placement="top"
offset={0}
open={isShow}
onOpenChange={onShowChange}
>
<PortalToFollowElemTrigger
className="block w-full"
onClick={toggleShowPopup}
<Popover open={isShow} onOpenChange={onShowChange}>
<PopoverTrigger render={resolvedTrigger} />
<PopoverContent
placement="top"
sideOffset={0}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<div className={cn('relative min-h-20 w-full rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-2 shadow-lg backdrop-blur-xs')}>
<div className="relative min-h-20 w-full rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-2 shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<SearchBox
search={query}
@ -148,29 +116,27 @@ const ToolPicker: FC<Props> = ({
</div>
<div className="flex items-center justify-between border-b-[0.5px] border-divider-subtle bg-background-default-hover px-3 shadow-xs">
<div className="flex h-8 items-center space-x-1">
{
tabs.map(tab => (
<div
className={cn(
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
'text-xs font-medium text-text-secondary',
pluginType === tab.key && 'bg-state-base-hover-alt',
)}
key={tab.key}
onClick={() => setPluginType(tab.key)}
>
{tab.name}
</div>
))
}
{tabs.map(tab => (
<div
className={cn(
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
'text-xs font-medium text-text-secondary',
pluginType === tab.key && 'bg-state-base-hover-alt',
)}
key={tab.key}
onClick={() => setPluginType(tab.key)}
>
{tab.name}
</div>
))}
</div>
</div>
{!isLoading && filteredList.length > 0 && listContent}
{!isLoading && filteredList.length === 0 && noData}
{isLoading && loadingContent}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -2,6 +2,65 @@ import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LabelSelector from '../selector'
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
const PopoverContent = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => {
const { open } = React.useContext(PopoverContext)
if (!open)
return null
return <div {...props}>{children}</div>
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
// Mock useTags hook with controlled test data
const mockTags = [
{ name: 'agent', label: 'Agent' },

View File

@ -1,6 +1,11 @@
import type { FC } from 'react'
import type { Label } from '@/app/components/tools/labels/constant'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import { noop } from 'es-toolkit/function'
@ -9,17 +14,13 @@ import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import { Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useTags } from '@/app/components/plugins/hooks'
type LabelSelectorProps = {
value: string[]
onChange: (v: string[]) => void
}
const LabelSelector: FC<LabelSelectorProps> = ({
value,
onChange,
@ -34,6 +35,7 @@ const LabelSelector: FC<LabelSelectorProps> = ({
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
@ -55,32 +57,31 @@ const LabelSelector: FC<LabelSelectorProps> = ({
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<Popover open={open} onOpenChange={setOpen}>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<div className={cn(
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 hover:bg-components-input-bg-hover',
open && '!hover:bg-components-input-bg-hover hover:bg-components-input-bg-hover',
<PopoverTrigger
render={(
<div className={cn(
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 hover:bg-components-input-bg-hover',
open && '!hover:bg-components-input-bg-hover hover:bg-components-input-bg-hover',
)}
>
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary', !value.length && 'text-text-quaternary!')}>
{!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
{!!value.length && selectedLabels}
</div>
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
<RiArrowDownSLine className="h-4 w-4" />
</div>
</div>
)}
>
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary', !value.length && 'text-text-quaternary!')}>
{!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
{!!value.length && selectedLabels}
</div>
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
<RiArrowDownSLine className="h-4 w-4" />
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1040">
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1040 } }}
>
<div className="relative w-[591px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="border-b-[0.5px] border-divider-regular p-2">
<Input
@ -114,9 +115,9 @@ const LabelSelector: FC<LabelSelectorProps> = ({
)}
</div>
</div>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
)
}

View File

@ -231,5 +231,9 @@ describe('CustomEdge', () => {
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-normal)')
expect(screen.getByTestId('block-selector')).toHaveAttribute('data-trigger-class', 'hover:scale-150 transition-all')
expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({
opacity: '0',
pointerEvents: 'none',
})
})
})

View File

@ -455,12 +455,12 @@ describe('ToolPicker', () => {
it('should create a custom collection from the add button and refresh custom tools', async () => {
const user = userEvent.setup()
const { container } = renderToolPicker({
renderToolPicker({
isShow: true,
supportAddCustomTool: true,
})
const addCustomToolButton = Array.from(container.querySelectorAll('button')).find((button) => {
const addCustomToolButton = Array.from(document.querySelectorAll('button')).find((button) => {
return button.className.includes('bg-components-button-primary-bg')
})

View File

@ -1,5 +1,10 @@
import type { NodeDefault } from '../types'
import type { BlockClassificationEnum } from './types'
import {
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { groupBy } from 'es-toolkit/compat'
import {
memo,
@ -10,7 +15,6 @@ import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip'
import { filterEvaluationWorkflowRestrictedBlockTypes, isEvaluationWorkflow } from '@/app/components/workflow/utils/evaluation-workflow'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
@ -101,13 +105,40 @@ const Blocks = ({
)
}
{
// Preview is supplementary: icon/title/description are all reachable
// from the node that gets added on click (inspector + canvas), so
// hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
filteredList.map(block => (
<Tooltip
key={block.metaData.type}
position="right"
popupClassName="w-[200px] rounded-xl"
needsDelay={false}
popupContent={(
<PreviewCard key={block.metaData.type}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
render={(
<div
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(block.metaData.type)}
>
<BlockIcon
className="mr-2 shrink-0"
type={block.metaData.type}
/>
<div className="grow text-sm text-text-secondary">{block.metaData.title}</div>
{
block.metaData.type === BlockEnum.LoopEnd && (
<Badge
text={t('nodes.loop.loopNode', { ns: 'workflow' })}
className="ml-2 shrink-0"
/>
)
}
</div>
)}
/>
<PreviewCardContent
placement="right"
popupClassName="w-[200px] border-none px-3 py-2"
>
<div>
<BlockIcon
size="md"
@ -117,28 +148,8 @@ const Blocks = ({
<div className="mb-1 system-md-medium text-text-primary">{block.metaData.title}</div>
<div className="system-xs-regular text-text-tertiary">{block.metaData.description}</div>
</div>
)}
>
<div
key={block.metaData.type}
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(block.metaData.type)}
>
<BlockIcon
className="mr-2 shrink-0"
type={block.metaData.type}
/>
<div className="grow text-sm text-text-secondary">{block.metaData.title}</div>
{
block.metaData.type === BlockEnum.LoopEnd && (
<Badge
text={t('nodes.loop.loopNode', { ns: 'workflow' })}
className="ml-2 shrink-0"
/>
)
}
</div>
</Tooltip>
</PreviewCardContent>
</PreviewCard>
))
}
</div>

View File

@ -3,12 +3,12 @@ import type { ToolWithProvider } from '../types'
import type { ToolDefaultValue, ToolValue } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { RiMoreLine } from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import { useGetLanguage } from '@/context/i18n'
@ -235,7 +235,6 @@ function FeaturedToolUninstalledItem({
const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief
const installCountLabel = t('install', { ns: 'plugin', num: formatNumber(plugin.install_count || 0) })
const [actionOpen, setActionOpen] = useState(false)
const [isActionHovered, setIsActionHovered] = useState(false)
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false)
useEffect(() => {
@ -244,7 +243,6 @@ function FeaturedToolUninstalledItem({
const handleScroll = () => {
setActionOpen(false)
setIsActionHovered(false)
}
window.addEventListener('scroll', handleScroll, true)
@ -254,77 +252,72 @@ function FeaturedToolUninstalledItem({
}
}, [actionOpen])
const row = (
<div
className="group flex h-8 w-full items-center rounded-lg pr-1 pl-3 hover:bg-state-base-hover"
>
<div className="flex h-full min-w-0 items-center">
<BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} />
<div className="ml-2 min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
</div>
</div>
<div className="ml-auto flex h-full items-center gap-1 pl-1">
<span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span>
<div
className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
>
<button
type="button"
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
onClick={() => {
setActionOpen(false)
setIsInstallModalOpen(true)
}}
>
{t('installAction', { ns: 'plugin' })}
</button>
<Action
open={actionOpen}
onOpenChange={setActionOpen}
author={plugin.org}
name={plugin.name}
version={plugin.latest_version}
/>
</div>
</div>
</div>
)
return (
<>
<Tooltip
position="right"
needsDelay={false}
popupClassName="p-0! px-3! py-2.5! w-[224px]! leading-[18px]! text-xs! text-gray-700! border-[0.5px]! border-black/5! rounded-xl! shadow-lg!"
popupContent={(
<div>
<BlockIcon size="md" className="mb-2" type={BlockEnum.Tool} toolIcon={plugin.icon} />
<div className="mb-1 text-sm leading-5 text-text-primary">{label}</div>
<div className="text-xs leading-[18px] text-text-secondary">{description}</div>
</div>
)}
disabled={!description || isActionHovered || actionOpen || isInstallModalOpen}
>
<div
className="group flex h-8 w-full items-center rounded-lg pr-1 pl-3 hover:bg-state-base-hover"
>
<div className="flex h-full min-w-0 items-center">
<BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} />
<div className="ml-2 min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
</div>
</div>
<div className="ml-auto flex h-full items-center gap-1 pl-1">
<span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span>
<div
className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
onMouseEnter={() => setIsActionHovered(true)}
onMouseLeave={() => {
if (!actionOpen)
setIsActionHovered(false)
}}
>
<button
type="button"
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
onClick={() => {
setActionOpen(false)
setIsInstallModalOpen(true)
setIsActionHovered(true)
}}
>
{t('installAction', { ns: 'plugin' })}
</button>
<Action
open={actionOpen}
onOpenChange={(value) => {
setActionOpen(value)
setIsActionHovered(value)
}}
author={plugin.org}
name={plugin.name}
version={plugin.latest_version}
/>
</div>
</div>
</div>
</Tooltip>
{description
? (
// Preview is supplementary: icon / label / brief are all reachable from
// the InstallFromMarketplace modal that opens on click, so hover/focus-only
// activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCard>
<PreviewCardTrigger delay={150} closeDelay={150} render={row} />
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon size="md" className="mb-2" type={BlockEnum.Tool} toolIcon={plugin.icon} />
<div className="mb-1 text-sm leading-5 text-text-primary">{label}</div>
<div className="text-xs leading-[18px] text-text-secondary">{description}</div>
</div>
</PreviewCardContent>
</PreviewCard>
)
: row}
{isInstallModalOpen && (
<InstallFromMarketplace
uniqueIdentifier={plugin.latest_package_identifier}
manifest={plugin}
onSuccess={async () => {
setIsInstallModalOpen(false)
setIsActionHovered(false)
await onInstallSuccess?.()
}}
onClose={() => {
setIsInstallModalOpen(false)
setIsActionHovered(false)
}}
/>
)}

View File

@ -2,12 +2,12 @@
import type { TriggerDefaultValue, TriggerWithProvider } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { RiMoreLine } from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import { useGetLanguage } from '@/context/i18n'
@ -230,7 +230,6 @@ function FeaturedTriggerUninstalledItem({
const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief
const installCountLabel = t('install', { ns: 'plugin', num: formatNumber(plugin.install_count || 0) })
const [actionOpen, setActionOpen] = useState(false)
const [isActionHovered, setIsActionHovered] = useState(false)
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false)
useEffect(() => {
@ -239,7 +238,6 @@ function FeaturedTriggerUninstalledItem({
const handleScroll = () => {
setActionOpen(false)
setIsActionHovered(false)
}
window.addEventListener('scroll', handleScroll, true)
@ -249,77 +247,72 @@ function FeaturedTriggerUninstalledItem({
}
}, [actionOpen])
const row = (
<div
className="group flex h-8 w-full items-center rounded-lg pr-1 pl-3 hover:bg-state-base-hover"
>
<div className="flex h-full min-w-0 items-center">
<BlockIcon type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
<div className="ml-2 min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
</div>
</div>
<div className="ml-auto flex h-full items-center gap-1 pl-1">
<span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span>
<div
className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
>
<button
type="button"
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
onClick={() => {
setActionOpen(false)
setIsInstallModalOpen(true)
}}
>
{t('installAction', { ns: 'plugin' })}
</button>
<Action
open={actionOpen}
onOpenChange={setActionOpen}
author={plugin.org}
name={plugin.name}
version={plugin.latest_version}
/>
</div>
</div>
</div>
)
return (
<>
<Tooltip
position="right"
needsDelay={false}
popupClassName="p-0! px-3! py-2.5! w-[224px]! leading-[18px]! text-xs! text-gray-700! border-[0.5px]! border-black/5! rounded-xl! shadow-lg!"
popupContent={(
<div>
<BlockIcon size="md" className="mb-2" type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
<div className="mb-1 text-sm leading-5 text-text-primary">{label}</div>
<div className="text-xs leading-[18px] text-text-secondary">{description}</div>
</div>
)}
disabled={!description || isActionHovered || actionOpen || isInstallModalOpen}
>
<div
className="group flex h-8 w-full items-center rounded-lg pr-1 pl-3 hover:bg-state-base-hover"
>
<div className="flex h-full min-w-0 items-center">
<BlockIcon type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
<div className="ml-2 min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
</div>
</div>
<div className="ml-auto flex h-full items-center gap-1 pl-1">
<span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span>
<div
className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
onMouseEnter={() => setIsActionHovered(true)}
onMouseLeave={() => {
if (!actionOpen)
setIsActionHovered(false)
}}
>
<button
type="button"
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
onClick={() => {
setActionOpen(false)
setIsInstallModalOpen(true)
setIsActionHovered(true)
}}
>
{t('installAction', { ns: 'plugin' })}
</button>
<Action
open={actionOpen}
onOpenChange={(value) => {
setActionOpen(value)
setIsActionHovered(value)
}}
author={plugin.org}
name={plugin.name}
version={plugin.latest_version}
/>
</div>
</div>
</div>
</Tooltip>
{description
? (
// Preview is supplementary: icon / label / brief are all reachable from
// the InstallFromMarketplace modal that opens on click, so hover/focus-only
// activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCard>
<PreviewCardTrigger delay={150} closeDelay={150} render={row} />
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon size="md" className="mb-2" type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
<div className="mb-1 text-sm leading-5 text-text-primary">{label}</div>
<div className="text-xs leading-[18px] text-text-secondary">{description}</div>
</div>
</PreviewCardContent>
</PreviewCard>
)
: row}
{isInstallModalOpen && (
<InstallFromMarketplace
uniqueIdentifier={plugin.latest_package_identifier}
manifest={plugin}
onSuccess={async () => {
setIsInstallModalOpen(false)
setIsActionHovered(false)
await onInstallSuccess?.()
}}
onClose={() => {
setIsInstallModalOpen(false)
setIsActionHovered(false)
}}
/>
)}

View File

@ -1,5 +1,10 @@
import type { BlockEnum, CommonNodeType } from '../types'
import type { TriggerDefaultValue } from './types'
import {
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import {
memo,
useCallback,
@ -7,9 +12,7 @@ import {
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { useAvailableNodesMetaData } from '../../workflow-app/hooks'
import BlockIcon from '../block-icon'
import { BlockEnum as BlockEnumValues } from '../types'
// import { useNodeMetaData } from '../hooks'
@ -33,7 +36,6 @@ const StartBlocks = ({
const { t } = useTranslation()
const nodes = useNodes()
// const nodeMetaData = useNodeMetaData()
const availableNodesMetaData = useAvailableNodesMetaData()
const filteredBlocks = useMemo(() => {
// Check if Start node already exists in workflow
@ -67,13 +69,34 @@ const StartBlocks = ({
onContentStateChange?.(!isEmpty)
}, [isEmpty, onContentStateChange])
// Preview is supplementary: the block icon, title and description all become
// reachable from the inspector + canvas once the row is clicked to insert
// the start node, so hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
const renderBlock = useCallback((block: typeof START_BLOCKS[number]) => (
<Tooltip
key={block.type}
position="right"
popupClassName="w-[224px] rounded-xl"
needsDelay={false}
popupContent={(
<PreviewCard key={block.type}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
render={(
<div
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(block.type)}
>
<BlockIcon
className="mr-2 shrink-0"
type={block.type}
/>
<div className="flex w-0 grow items-center justify-between text-sm text-text-secondary">
<span className="truncate">{t(`blocks.${block.type}`, { ns: 'workflow' })}</span>
{block.type === BlockEnumValues.Start && (
<span className="ml-2 shrink-0 system-xs-regular text-text-quaternary">{t('blocks.originalStartNode', { ns: 'workflow' })}</span>
)}
</div>
</div>
)}
/>
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
@ -96,25 +119,9 @@ const StartBlocks = ({
</div>
)}
</div>
)}
>
<div
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(block.type)}
>
<BlockIcon
className="mr-2 shrink-0"
type={block.type}
/>
<div className="flex w-0 grow items-center justify-between text-sm text-text-secondary">
<span className="truncate">{t(`blocks.${block.type}`, { ns: 'workflow' })}</span>
{block.type === BlockEnumValues.Start && (
<span className="ml-2 shrink-0 system-xs-regular text-text-quaternary">{t('blocks.originalStartNode', { ns: 'workflow' })}</span>
)}
</div>
</div>
</Tooltip>
), [availableNodesMetaData, onSelect, t])
</PreviewCardContent>
</PreviewCard>
), [onSelect, t])
if (isEmpty)
return null

View File

@ -8,17 +8,17 @@ import type { ToolDefaultValue, ToolValue } from './types'
import type { CustomCollectionBackend } from '@/app/components/tools/types'
import type { BlockEnum, OnSelectBlock } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import AllTools from '@/app/components/workflow/block-selector/all-tools'
@ -43,7 +43,7 @@ type Props = {
disabled: boolean
trigger: React.ReactNode
placement?: Placement
offset?: OffsetOptions
offset?: OffsetOptions | number
isShow: boolean
onShowChange: (isShow: boolean) => void
onSelect: (tool: ToolDefaultValue) => void
@ -120,12 +120,6 @@ const ToolPicker: FC<Props> = ({
const handleAddedCustomTool = invalidateCustomTools
const handleTriggerClick = () => {
if (disabled)
return
onShowChange(true)
}
const handleSelect = (_type: BlockEnum, tool?: ToolDefaultValue) => {
onSelect(tool!)
}
@ -139,6 +133,11 @@ const ToolPicker: FC<Props> = ({
setTrue: showEditCustomCollectionModal,
}] = useBoolean(false)
const handleShowAddCustomCollectionModal = useCallback(() => {
onShowChange(false)
showEditCustomCollectionModal()
}, [onShowChange, showEditCustomCollectionModal])
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
toast.success(t('api.actionSuccess', { ns: 'common' }))
@ -157,20 +156,35 @@ const ToolPicker: FC<Props> = ({
)
}
return (
<PortalToFollowElem
placement={placement}
offset={offset}
open={isShow}
onOpenChange={onShowChange}
>
<PortalToFollowElemTrigger
onClick={handleTriggerClick}
>
{trigger}
</PortalToFollowElemTrigger>
const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div>
const resolvedOffset = typeof offset === 'object' && offset !== null
? offset as { mainAxis?: number, crossAxis?: number, alignmentAxis?: number | null }
: undefined
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
<PortalToFollowElemContent className="z-1002">
return (
<Popover
open={isShow}
onOpenChange={(nextOpen) => {
if (disabled && nextOpen)
return
onShowChange(nextOpen)
}}
>
<PopoverTrigger
render={resolvedTrigger}
onClick={(e) => {
if (disabled)
e.preventDefault()
}}
/>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className={cn('relative min-h-20 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs', panelClassName)}>
<div className="p-2 pb-1">
<SearchBox
@ -181,7 +195,7 @@ const ToolPicker: FC<Props> = ({
placeholder={t('searchTools', { ns: 'plugin' })!}
supportAddCustomTool={supportAddCustomTool}
onAddedCustomTool={handleAddedCustomTool}
onShowAddCustomCollectionModal={showEditCustomCollectionModal}
onShowAddCustomCollectionModal={handleShowAddCustomCollectionModal}
inputClassName="grow"
/>
</div>
@ -209,8 +223,8 @@ const ToolPicker: FC<Props> = ({
}}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -4,11 +4,11 @@ import type { ToolWithProvider } from '../../types'
import type { ToolDefaultValue } from '../types'
import type { Tool } from '@/app/components/tools/types'
import { cn } from '@langgenius/dify-ui/cn'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Tooltip from '@/app/components/base/tooltip'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
@ -57,13 +57,59 @@ const ToolItem: FC<Props> = ({
return normalizedIcon
}, [theme, normalizedIcon, normalizedIconDark])
return (
<Tooltip
const row = (
<div
key={payload.name}
position="right"
needsDelay={false}
popupClassName="p-0! px-3! py-2.5! w-[200px]! leading-[18px]! text-xs! text-gray-700! border-[0.5px]! border-black/5! rounded-xl! shadow-lg!"
popupContent={(
className="flex cursor-pointer items-center justify-between rounded-lg pr-1 pl-[21px] hover:bg-state-base-hover"
onClick={() => {
if (disabled)
return
const params: Record<string, string> = {}
if (payload.parameters) {
payload.parameters.forEach((item) => {
params[item.name] = ''
})
}
onSelect(BlockEnum.Tool, {
provider_id: provider.id,
provider_type: provider.type,
provider_name: provider.name,
plugin_id: provider.plugin_id,
plugin_unique_identifier: provider.plugin_unique_identifier,
provider_icon: normalizedIcon,
provider_icon_dark: normalizedIconDark,
tool_name: payload.name,
tool_label: payload.label[language]!,
tool_description: payload.description[language],
title: payload.label[language]!,
is_team_authorization: provider.is_team_authorization,
paramSchemas: payload.parameters,
params,
meta: provider.meta,
})
trackEvent('tool_selected', {
tool_name: payload.name,
plugin_id: provider.plugin_id,
})
}}
>
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary')}>
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
<div className="mr-4 system-xs-regular text-text-tertiary">{t('addToolModal.added', { ns: 'tools' })}</div>
)}
</div>
)
return (
// Preview is supplementary: provider icon, tool label and description are all
// reachable from the node inspector after the row is clicked to add the tool,
// so hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCard key={payload.name}>
<PreviewCardTrigger delay={150} closeDelay={150} render={row} />
<PreviewCardContent placement="right" popupClassName="w-[200px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
@ -74,51 +120,8 @@ const ToolItem: FC<Props> = ({
<div className="mb-1 text-sm leading-5 text-text-primary">{payload.label[language]}</div>
<div className="text-xs leading-[18px] text-text-secondary">{payload.description[language]}</div>
</div>
)}
>
<div
key={payload.name}
className="flex cursor-pointer items-center justify-between rounded-lg pr-1 pl-[21px] hover:bg-state-base-hover"
onClick={() => {
if (disabled)
return
const params: Record<string, string> = {}
if (payload.parameters) {
payload.parameters.forEach((item) => {
params[item.name] = ''
})
}
onSelect(BlockEnum.Tool, {
provider_id: provider.id,
provider_type: provider.type,
provider_name: provider.name,
plugin_id: provider.plugin_id,
plugin_unique_identifier: provider.plugin_unique_identifier,
provider_icon: normalizedIcon,
provider_icon_dark: normalizedIconDark,
tool_name: payload.name,
tool_label: payload.label[language]!,
tool_description: payload.description[language],
title: payload.label[language]!,
is_team_authorization: provider.is_team_authorization,
paramSchemas: payload.parameters,
params,
meta: provider.meta,
})
trackEvent('tool_selected', {
tool_name: payload.name,
plugin_id: provider.plugin_id,
})
}}
>
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary')}>
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
<div className="mr-4 system-xs-regular text-text-tertiary">{t('addToolModal.added', { ns: 'tools' })}</div>
)}
</div>
</Tooltip>
</PreviewCardContent>
</PreviewCard>
)
}
export default React.memo(ToolItem)

View File

@ -3,9 +3,9 @@ import type { FC } from 'react'
import type { TriggerDefaultValue, TriggerWithProvider } from '../types'
import type { Event } from '@/app/components/tools/types'
import { cn } from '@langgenius/dify-ui/cn'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { useGetLanguage } from '@/context/i18n'
import BlockIcon from '../../block-icon'
import { BlockEnum } from '../../types'
@ -28,13 +28,54 @@ const TriggerPluginActionItem: FC<Props> = ({
const { t } = useTranslation()
const language = useGetLanguage()
return (
<Tooltip
const row = (
<div
key={payload.name}
position="right"
needsDelay={false}
popupClassName="p-0! px-3! py-2.5! w-[224px]! leading-[18px]! text-xs! text-gray-700! border-[0.5px]! border-black/5! rounded-xl! shadow-lg!"
popupContent={(
className="flex cursor-pointer items-center justify-between rounded-lg pr-1 pl-[21px] hover:bg-state-base-hover"
onClick={() => {
if (disabled)
return
const params: Record<string, string> = {}
if (payload.parameters) {
payload.parameters.forEach((item: any) => {
params[item.name] = ''
})
}
onSelect(BlockEnum.TriggerPlugin, {
plugin_id: provider.plugin_id,
provider_id: provider.name,
provider_type: provider.type as string,
provider_name: provider.name,
event_name: payload.name,
event_label: payload.label[language]!,
event_description: payload.description[language]!,
plugin_unique_identifier: provider.plugin_unique_identifier,
title: payload.label[language]!,
is_team_authorization: provider.is_team_authorization,
output_schema: payload.output_schema || {},
paramSchemas: payload.parameters,
params,
meta: provider.meta,
})
}}
>
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary')}>
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
<div className="mr-4 system-xs-regular text-text-tertiary">{t('addToolModal.added', { ns: 'tools' })}</div>
)}
</div>
)
return (
// Preview is supplementary: provider icon, event label and description are all
// reachable from the node inspector after the row is clicked to add the trigger,
// so hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCard key={payload.name}>
<PreviewCardTrigger delay={150} closeDelay={150} render={row} />
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
@ -45,46 +86,8 @@ const TriggerPluginActionItem: FC<Props> = ({
<div className="mb-1 text-sm leading-5 text-text-primary">{payload.label[language]}</div>
<div className="text-xs leading-[18px] text-text-secondary">{payload.description[language]}</div>
</div>
)}
>
<div
key={payload.name}
className="flex cursor-pointer items-center justify-between rounded-lg pr-1 pl-[21px] hover:bg-state-base-hover"
onClick={() => {
if (disabled)
return
const params: Record<string, string> = {}
if (payload.parameters) {
payload.parameters.forEach((item: any) => {
params[item.name] = ''
})
}
onSelect(BlockEnum.TriggerPlugin, {
plugin_id: provider.plugin_id,
provider_id: provider.name,
provider_type: provider.type as string,
provider_name: provider.name,
event_name: payload.name,
event_label: payload.label[language]!,
event_description: payload.description[language]!,
plugin_unique_identifier: provider.plugin_unique_identifier,
title: payload.label[language]!,
is_team_authorization: provider.is_team_authorization,
output_schema: payload.output_schema || {},
paramSchemas: payload.parameters,
params,
meta: provider.meta,
})
}}
>
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary')}>
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
<div className="mr-4 system-xs-regular text-text-tertiary">{t('addToolModal.added', { ns: 'tools' })}</div>
)}
</div>
</Tooltip>
</PreviewCardContent>
</PreviewCard>
)
}
export default React.memo(TriggerPluginActionItem)

View File

@ -63,6 +63,7 @@ const CustomEdge = ({
_sourceRunningStatus,
_targetRunningStatus,
} = data
const isTriggerVisible = !!(data?._hovering || isTriggerHovered || open)
const linearGradientId = useMemo(() => {
if (
@ -144,16 +145,15 @@ const CustomEdge = ({
<div
className={cn(
'nopan nodrag',
(data?._hovering || isTriggerHovered) ? 'block' : 'hidden',
open && 'block!',
'transition-opacity duration-150',
data.isInIteration && `z-[${ITERATION_CHILDREN_Z_INDEX}]`,
data.isInLoop && `z-[${LOOP_CHILDREN_Z_INDEX}]`,
)}
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
opacity: data._waitingRun ? 0.7 : 1,
pointerEvents: isTriggerVisible ? 'all' : 'none',
opacity: isTriggerVisible ? (data._waitingRun ? 0.7 : 1) : 0,
}}
onMouseEnter={() => setIsTriggerHovered(true)}
onMouseLeave={() => setIsTriggerHovered(false)}

View File

@ -0,0 +1,446 @@
import type { ReactNode } from 'react'
import type { StrategyPluginDetail } from '@/app/components/plugins/types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { AgentStrategySelector } from '../agent-strategy-selector'
const mocks = vi.hoisted(() => ({
useSuspenseQuery: vi.fn(),
useStrategyProviders: vi.fn(),
useMarketplacePlugins: vi.fn(),
useStrategyInfo: vi.fn(),
refetchStrategyInfo: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
}))
vi.mock('@tanstack/react-query', () => ({
useSuspenseQuery: mocks.useSuspenseQuery,
}))
vi.mock('@/service/system-features', () => ({
systemFeaturesQueryOptions: () => ({}),
}))
vi.mock('@/service/use-strategy', () => ({
useStrategyProviders: mocks.useStrategyProviders,
}))
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplacePlugins: mocks.useMarketplacePlugins,
}))
vi.mock('@/app/components/workflow/nodes/agent/use-config', () => ({
useStrategyInfo: mocks.useStrategyInfo,
}))
vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
default: () => ({
getIconUrl: (icon: string) => `https://example.com/${icon}`,
}),
}))
vi.mock('@/app/components/base/search-input', () => ({
default: ({
value,
onChange,
placeholder,
}: {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
}) => (
<input
aria-label={placeholder}
value={value}
onChange={e => onChange(e.target.value)}
/>
),
}))
vi.mock('@/app/components/workflow/block-selector/view-type-select', () => ({
default: ({
onChange,
}: {
viewType: string
onChange: (value: string) => void
}) => (
<button type="button" onClick={() => onChange('grid')}>
view-type
</button>
),
ViewType: {
flat: 'flat',
grid: 'grid',
},
}))
vi.mock('@/app/components/workflow/block-selector/tools', () => ({
default: ({
tools,
onSelect,
}: {
tools: Array<{
id: string
name: string
meta?: unknown
tools: Array<{
name: string
label: string | { en_US?: string }
output_schema?: Record<string, unknown>
}>
}>
onSelect: (value: unknown, tool: {
tool_name: string
provider_name: string
tool_label: string
output_schema?: Record<string, unknown>
provider_id: string
meta?: unknown
}) => void
}) => (
<div data-testid="tools-list">
{tools.map(tool => (
<div key={tool.id}>
<span>{tool.name}</span>
<button
type="button"
onClick={() => onSelect(undefined, {
tool_name: tool.tools[0]!.name,
provider_name: tool.id,
tool_label: typeof tool.tools[0]!.label === 'string'
? tool.tools[0]!.label
: tool.tools[0]!.label.en_US || '',
output_schema: tool.tools[0]!.output_schema,
provider_id: tool.id,
meta: tool.meta,
})}
>
{`select-${tool.name}`}
</button>
</div>
))}
</div>
),
}))
vi.mock('@/app/components/workflow/block-selector/market-place-plugin/list', () => ({
default: ({
list,
searchText,
}: {
list: Array<{ plugin_id: string }>
searchText: string
}) => (
<div data-testid="plugin-list">
{`${searchText}:${list.map(item => item.plugin_id).join(',')}`}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
InstallPluginButton: ({
onClick,
}: {
onClick?: (event: { stopPropagation: () => void }) => void
uniqueIdentifier: string
size: string
}) => (
<button
type="button"
data-testid="install-plugin-button"
onClick={() => onClick?.({ stopPropagation: vi.fn() })}
>
install-plugin
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
SwitchPluginVersion: ({
onChange,
}: {
onChange: () => void
uniqueIdentifier: string
tooltip: ReactNode
}) => (
<button
type="button"
data-testid="switch-plugin-version"
onClick={onChange}
>
switch-plugin-version
</button>
),
}))
vi.mock('@/next/link', () => ({
default: ({
href,
children,
className,
}: {
href: string
children: ReactNode
className?: string
}) => <a href={href} className={className}>{children}</a>,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div data-testid="agent-strategy-trigger" onClick={() => setOpen(!open)}>
{render}
</div>
)
}
const PopoverContent = ({ children }: { children: React.ReactNode }) => {
const { open } = React.useContext(PopoverContext)
return open ? <div data-testid="agent-strategy-popover">{children}</div> : null
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ render }: { render: ReactNode }) => <div>{render}</div>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
const createStrategyDetail = (
name: string,
strategyName: string,
strategyLabel: string,
): StrategyPluginDetail => ({
plugin_unique_identifier: `provider/${name}`,
plugin_id: `plugin-${name}`,
declaration: {
identity: {
author: 'Dify',
name,
description: { en_US: `${name} description` },
icon: `${name}.png`,
label: { en_US: `${name} label` },
tags: [],
},
strategies: [{
identity: {
name: strategyName,
author: 'Dify',
label: { en_US: strategyLabel },
},
description: { en_US: `${strategyLabel} description` },
parameters: [],
output_schema: { result: { type: 'string' } },
}],
},
meta: { version: '1.0.0' },
} as unknown as StrategyPluginDetail)
describe('AgentStrategySelector', () => {
const alphaDetail = createStrategyDetail('alpha', 'alpha-strategy', 'Alpha Strategy')
const betaDetail = createStrategyDetail('beta', 'beta-strategy', 'Beta Strategy')
beforeEach(() => {
vi.clearAllMocks()
mocks.useSuspenseQuery.mockReturnValue({ data: true })
mocks.useStrategyProviders.mockReturnValue({ data: [alphaDetail, betaDetail] })
mocks.useMarketplacePlugins.mockReturnValue({
queryPluginsWithDebounced: mocks.queryPluginsWithDebounced,
plugins: [{ plugin_id: 'market-agent' }],
})
mocks.useStrategyInfo.mockReturnValue({
strategyStatus: undefined,
refetch: mocks.refetchStrategyInfo,
})
})
it('filters strategies and queries marketplace when searching', async () => {
const user = userEvent.setup()
render(
<AgentStrategySelector
onChange={vi.fn()}
/>,
)
await user.click(screen.getByTestId('agent-strategy-trigger'))
expect(screen.getByText('alpha')).toBeInTheDocument()
expect(screen.getByText('beta')).toBeInTheDocument()
expect(screen.getByTestId('plugin-list')).toHaveTextContent(':market-agent')
await user.type(
screen.getByRole('textbox', { name: 'nodes.agent.strategy.searchPlaceholder' }),
'alp',
)
await waitFor(() => {
expect(mocks.queryPluginsWithDebounced).toHaveBeenLastCalledWith({
query: 'alp',
category: PluginCategoryEnum.agent,
})
})
expect(screen.getByText('alpha')).toBeInTheDocument()
expect(screen.queryByText('beta')).not.toBeInTheDocument()
})
it('maps the selected tool and closes the popover', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<AgentStrategySelector
onChange={onChange}
/>,
)
await user.click(screen.getByTestId('agent-strategy-trigger'))
await user.click(screen.getByRole('button', { name: 'select-alpha' }))
expect(onChange).toHaveBeenCalledWith({
agent_strategy_name: 'alpha-strategy',
agent_strategy_provider_name: 'provider/alpha',
agent_strategy_label: 'Alpha Strategy',
agent_output_schema: { result: { type: 'string' } },
plugin_unique_identifier: 'provider/alpha',
meta: { version: '1.0.0' },
})
expect(screen.queryByTestId('agent-strategy-popover')).not.toBeInTheDocument()
})
it('renders the plugin-not-installed warning for external strategies', () => {
mocks.useStrategyInfo.mockReturnValue({
strategyStatus: {
plugin: {
source: 'external',
installed: false,
},
isExistInPlugin: true,
},
refetch: mocks.refetchStrategyInfo,
})
render(
<AgentStrategySelector
value={{
agent_strategy_provider_name: 'provider/alpha',
agent_strategy_name: 'alpha-strategy',
agent_strategy_label: 'Alpha Strategy',
agent_output_schema: {},
plugin_unique_identifier: 'provider/alpha',
}}
onChange={vi.fn()}
/>,
)
expect(screen.getByText('nodes.agent.pluginNotInstalled')).toBeInTheDocument()
expect(screen.getByText('nodes.agent.pluginNotInstalledDesc')).toBeInTheDocument()
})
it('renders install and switch-version actions for marketplace strategies', async () => {
const user = userEvent.setup()
mocks.useStrategyInfo.mockReturnValueOnce({
strategyStatus: {
plugin: {
source: 'marketplace',
installed: false,
},
isExistInPlugin: false,
},
refetch: mocks.refetchStrategyInfo,
})
const { rerender } = render(
<AgentStrategySelector
value={{
agent_strategy_provider_name: 'provider/alpha',
agent_strategy_name: 'alpha-strategy',
agent_strategy_label: 'Alpha Strategy',
agent_output_schema: {},
plugin_unique_identifier: 'provider/alpha',
}}
onChange={vi.fn()}
/>,
)
expect(screen.getByTestId('install-plugin-button')).toBeInTheDocument()
mocks.useStrategyInfo.mockReturnValue({
strategyStatus: {
plugin: {
source: 'marketplace',
installed: true,
},
isExistInPlugin: false,
},
refetch: mocks.refetchStrategyInfo,
})
rerender(
<AgentStrategySelector
value={{
agent_strategy_provider_name: 'provider/alpha',
agent_strategy_name: 'alpha-strategy',
agent_strategy_label: 'Alpha Strategy',
agent_output_schema: {},
plugin_unique_identifier: 'provider/alpha',
}}
onChange={vi.fn()}
/>,
)
await user.click(screen.getByTestId('switch-plugin-version'))
expect(mocks.refetchStrategyInfo).toHaveBeenCalled()
})
})

View File

@ -4,14 +4,21 @@ import type { Strategy } from './agent-strategy'
import type { StrategyPluginDetail } from '@/app/components/plugins/types'
import type { ListProps, ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import { RiArrowDownSLine, RiErrorWarningFill } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import SearchInput from '@/app/components/base/search-input'
import Tooltip from '@/app/components/base/tooltip'
import { ToolTipContent } from '@/app/components/base/tooltip/content'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
@ -36,8 +43,11 @@ const NotFoundWarn = (props: {
const { t } = useTranslation()
return (
<Tooltip
popupContent={(
<Tooltip>
<TooltipTrigger
render={<div><RiErrorWarningFill className="size-4 text-text-destructive" /></div>}
/>
<TooltipContent className="w-[180px]">
<div className="space-y-1 text-xs">
<h3 className="font-semibold text-text-primary">
{title}
@ -51,11 +61,7 @@ const NotFoundWarn = (props: {
</Link>
</p>
</div>
)}
>
<div>
<RiErrorWarningFill className="size-4 text-text-destructive" />
</div>
</TooltipContent>
</Tooltip>
)
}
@ -66,18 +72,18 @@ function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => s
id: item.plugin_unique_identifier,
author: item.declaration.identity.author,
name: item.declaration.identity.name,
description: item.declaration.identity.description as any,
description: item.declaration.identity.description as ToolWithProvider['description'],
plugin_id: item.plugin_id,
icon: getIcon(item.declaration.identity.icon),
label: item.declaration.identity.label as any,
label: item.declaration.identity.label as ToolWithProvider['label'],
type: CollectionType.all,
meta: item.meta,
tools: item.declaration.strategies.map(strategy => ({
name: strategy.identity.name,
author: strategy.identity.author,
label: strategy.identity.label as any,
label: strategy.identity.label as ToolWithProvider['tools'][number]['label'],
description: strategy.description,
parameters: strategy.parameters as any,
parameters: strategy.parameters as unknown as ToolWithProvider['tools'][number]['parameters'],
output_schema: strategy.output_schema,
labels: [],
})),
@ -151,76 +157,82 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
category: PluginCategoryEnum.agent,
})
}
}, [query])
}, [enable_marketplace, fetchPlugins, query])
const pluginRef = useRef<ListRef>(null)
return (
<PortalToFollowElem open={open} onOpenChange={setOpen} placement="bottom">
<PortalToFollowElemTrigger className="w-full">
<div
className="flex h-8 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 select-none hover:bg-state-base-hover-alt"
onClick={() => setOpen(o => !o)}
>
{ }
{icon && (
<div className="flex h-6 w-6 items-center justify-center">
<img
src={icon}
width={20}
height={20}
className="rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge"
alt="icon"
/>
</div>
)}
<p
className={cn(value ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder', 'px-1 text-xs')}
>
{value?.agent_strategy_label || t('nodes.agent.strategy.selectTip', { ns: 'workflow' })}
</p>
<div className="ml-auto flex items-center gap-1">
{showInstallButton && value && (
<InstallPluginButton
onClick={e => e.stopPropagation()}
size="small"
uniqueIdentifier={value.plugin_unique_identifier}
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<div className="flex h-8 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 select-none hover:bg-state-base-hover-alt">
{icon && (
<div className="flex h-6 w-6 items-center justify-center">
<img
src={icon}
width={20}
height={20}
className="rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge"
alt="icon"
/>
</div>
)}
{showPluginNotInstalledWarn
? (
<NotFoundWarn
title={t('nodes.agent.pluginNotInstalled', { ns: 'workflow' })}
description={t('nodes.agent.pluginNotInstalledDesc', { ns: 'workflow' })}
/>
)
: showUnsupportedStrategy
<p
className={cn(value ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder', 'px-1 text-xs')}
>
{value?.agent_strategy_label || t('nodes.agent.strategy.selectTip', { ns: 'workflow' })}
</p>
<div className="ml-auto flex items-center gap-1">
{showInstallButton && value && (
<InstallPluginButton
onClick={e => e.stopPropagation()}
size="small"
uniqueIdentifier={value.plugin_unique_identifier}
/>
)}
{showPluginNotInstalledWarn
? (
<NotFoundWarn
title={t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })}
description={t('nodes.agent.strategyNotFoundDesc', { ns: 'workflow' })}
title={t('nodes.agent.pluginNotInstalled', { ns: 'workflow' })}
description={t('nodes.agent.pluginNotInstalledDesc', { ns: 'workflow' })}
/>
)
: <RiArrowDownSLine className="size-4 text-text-tertiary" />}
{showSwitchVersion && (
<SwitchPluginVersion
uniqueIdentifier={value.plugin_unique_identifier}
tooltip={(
<ToolTipContent
title={t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })}
>
{t('nodes.agent.strategyNotFoundDescAndSwitchVersion', { ns: 'workflow' })}
</ToolTipContent>
)}
onChange={() => {
refetchStrategyInfo()
}}
/>
)}
: showUnsupportedStrategy
? (
<NotFoundWarn
title={t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })}
description={t('nodes.agent.strategyNotFoundDesc', { ns: 'workflow' })}
/>
)
: <RiArrowDownSLine className="size-4 text-text-tertiary" />}
{showSwitchVersion && value && (
<SwitchPluginVersion
uniqueIdentifier={value.plugin_unique_identifier}
tooltip={(
<div className="w-[180px] space-y-1 text-xs">
<h3 className="font-semibold text-text-primary">
{t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })}
</h3>
<p className="text-text-tertiary">
{t('nodes.agent.strategyNotFoundDescAndSwitchVersion', { ns: 'workflow' })}
</p>
</div>
)}
onChange={() => {
refetchStrategyInfo()
}}
/>
)}
</div>
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
)}
/>
<PopoverContent
placement="bottom"
sideOffset={0}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 10 } }}
>
<div className="w-[388px] overflow-hidden rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow">
<header className="flex gap-1 p-2">
<SearchInput placeholder={t('nodes.agent.strategy.searchPlaceholder', { ns: 'workflow' })} value={query} onChange={setQuery} className="w-full" />
@ -260,8 +272,8 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
)}
</main>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
})

View File

@ -1,4 +1,8 @@
import type { ComponentProps } from 'react'
import {
Popover,
PopoverContent,
} from '@langgenius/dify-ui/popover'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { VarType as VarKindType } from '../../../../tool/types'
@ -28,7 +32,7 @@ const createProps = (
readonly: false,
setControlFocus: vi.fn(),
setOpen: vi.fn(),
tooltipPopup: null,
hoverPopup: null,
triggerRef: { current: null },
value: [],
varKindType: VarKindType.constant,
@ -38,46 +42,53 @@ const createProps = (
],
varName: '',
variableCategory: 'system',
WrapElem: 'div',
VarPickerWrap: 'div',
...overrides,
})
const renderWithPopover = (
overrides: Partial<ComponentProps<typeof VarReferencePickerTrigger>> = {},
) => {
const onOpenChange = vi.fn()
render(
<Popover onOpenChange={onOpenChange}>
<VarReferencePickerTrigger
{...createProps(overrides)}
/>
<PopoverContent popupClassName="border-none bg-transparent p-0 shadow-none">
<div>picker-content</div>
</PopoverContent>
</Popover>,
)
return { onOpenChange }
}
describe('VarReferencePickerTrigger', () => {
it('should show the placeholder state and open the picker for variable mode', () => {
const setOpen = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
placeholder: 'Pick variable',
setOpen,
})}
/>,
)
const { onOpenChange } = renderWithPopover({
placeholder: 'Pick variable',
})
expect(screen.getByText('Pick variable'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
expect(setOpen).toHaveBeenCalledWith(true)
expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything())
})
it('should render the selected variable state and clear it', () => {
const handleClearVar = vi.fn()
const handleVariableJump = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
handleClearVar,
handleVariableJump,
hasValue: true,
outputVarNode: { title: 'Source Node', desc: '', type: BlockEnum.Code },
outputVarNodeId: 'node-a',
type: VarType.string,
value: ['node-a', 'answer'],
varName: 'answer',
})}
/>,
)
renderWithPopover({
handleClearVar,
handleVariableJump,
hasValue: true,
outputVarNode: { title: 'Source Node', desc: '', type: BlockEnum.Code },
outputVarNodeId: 'node-a',
type: VarType.string,
value: ['node-a', 'answer'],
varName: 'answer',
})
expect(screen.getByText('Source Node'))!.toBeInTheDocument()
expect(screen.getByText('answer'))!.toBeInTheDocument()
@ -93,20 +104,16 @@ describe('VarReferencePickerTrigger', () => {
const setControlFocus = vi.fn()
const setOpen = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
isConstant: true,
isSupportConstantValue: true,
schemaWithDynamicSelect: {
type: 'text-input',
} as never,
setOpen,
setControlFocus,
value: 'constant-value',
})}
/>,
)
renderWithPopover({
isConstant: true,
isSupportConstantValue: true,
schemaWithDynamicSelect: {
type: 'text-input',
} as never,
setOpen,
setControlFocus,
value: 'constant-value',
})
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
expect(setControlFocus).toHaveBeenCalledTimes(1)
@ -116,38 +123,27 @@ describe('VarReferencePickerTrigger', () => {
})
it('should render add button trigger in table mode', () => {
render(
<VarReferencePickerTrigger
{...createProps({
hasValue: true,
isAddBtnTrigger: true,
isInTable: true,
value: ['node-a', 'answer'],
varName: 'answer',
})}
/>,
)
renderWithPopover({
hasValue: true,
isAddBtnTrigger: true,
isInTable: true,
value: ['node-a', 'answer'],
varName: 'answer',
})
expect(document.querySelector('button'))!.toBeInTheDocument()
expect(screen.getByTestId('add-button'))!.toBeInTheDocument()
})
it('should stay inert in readonly mode and show value type placeholder badge', () => {
const setOpen = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
placeholder: 'Readonly placeholder',
readonly: true,
setOpen,
typePlaceHolder: 'string',
valueTypePlaceHolder: 'text',
})}
/>,
)
const { onOpenChange } = renderWithPopover({
placeholder: 'Readonly placeholder',
readonly: true,
typePlaceHolder: 'string',
valueTypePlaceHolder: 'text',
})
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
expect(setOpen).not.toHaveBeenCalled()
expect(onOpenChange).not.toHaveBeenCalled()
expect(screen.getByText('string'))!.toBeInTheDocument()
expect(screen.getByText('text'))!.toBeInTheDocument()
})
@ -155,17 +151,13 @@ describe('VarReferencePickerTrigger', () => {
it('should show loading placeholder and remove rows in table mode', () => {
const onRemove = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
hasValue: false,
isInTable: true,
isLoading: true,
onRemove,
placeholder: 'Loading variable',
})}
/>,
)
renderWithPopover({
hasValue: false,
isInTable: true,
isLoading: true,
onRemove,
placeholder: 'Loading variable',
})
expect(screen.getByText('Loading variable'))!.toBeInTheDocument()

View File

@ -81,4 +81,27 @@ describe('var-reference-vars helpers', () => {
expect(vars[0]!.title).toBe('Node B')
expect(vars[0]!.vars).toEqual([expect.objectContaining({ variable: 'another_value' })])
})
it('should keep parent vars when search text matches a child variable', () => {
const vars = filterReferenceVars([
{
title: 'Node A',
nodeId: 'node-a',
vars: [{
variable: 'payload',
type: VarType.object,
children: [{ variable: 'child_name', type: VarType.string }],
}],
},
{
title: 'Node B',
nodeId: 'node-b',
vars: [{ variable: 'other_value', type: VarType.string }],
},
] as NodeOutPutVar[], 'child')
expect(vars).toHaveLength(1)
expect(vars[0]!.title).toBe('Node A')
expect(vars[0]!.vars).toEqual([expect.objectContaining({ variable: 'payload' })])
})
})

View File

@ -199,6 +199,34 @@ describe('VarReferenceVars', () => {
}))
})
it('should filter by externally controlled search text and match child variables', () => {
render(
<VarReferenceVars
hideSearch
searchText="child"
vars={createVars([
{
title: 'Object vars',
nodeId: 'node-obj',
vars: [{
variable: 'payload',
type: VarType.object,
children: [{ variable: 'child_name', type: VarType.string }],
}, {
variable: 'other_value',
type: VarType.string,
}],
},
])}
onChange={vi.fn()}
/>,
)
expect(screen.queryByPlaceholderText('workflow.common.searchVar')).not.toBeInTheDocument()
expect(screen.getByText('payload')).toBeInTheDocument()
expect(screen.queryByText('other_value')).not.toBeInTheDocument()
})
it('should ignore file vars when file support is disabled and forward blur-sm events', () => {
const onChange = vi.fn()
const onBlur = vi.fn()

View File

@ -1,12 +1,14 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { FC, ReactElement } from 'react'
import type { VarType as VarKindType } from '../../../tool/types'
import type { CredentialFormSchema, CredentialFormSchemaSelect } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { Node, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { PopoverTrigger } from '@langgenius/dify-ui/popover'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiArrowDownSLine, RiCloseLine, RiErrorWarningFill, RiLoader4Line, RiMoreLine } from '@remixicon/react'
import Badge from '@/app/components/base/badge'
@ -18,6 +20,10 @@ import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/com
import RemoveButton from '../remove-button'
import ConstantField from './constant-field'
export type HoverPopup
= | { kind: 'full-path', panel: ReactElement }
| { kind: 'invalid-variable', message: string }
type Props = {
className?: string
controlFocus: number
@ -53,7 +59,7 @@ type Props = {
setControlFocus: (value: number) => void
setOpen: (value: boolean) => void
showErrorIcon?: boolean
tooltipPopup: ReactNode
hoverPopup: HoverPopup | null
triggerRef: React.RefObject<HTMLDivElement | null>
type?: string
typePlaceHolder?: string
@ -63,8 +69,6 @@ type Props = {
varKindTypes: Array<{ label: string, value: VarKindType }>
varName: string
variableCategory: string
WrapElem: React.ElementType
VarPickerWrap: React.ElementType
}
const VarReferencePickerTrigger: FC<Props> = ({
@ -99,7 +103,7 @@ const VarReferencePickerTrigger: FC<Props> = ({
setControlFocus,
setOpen,
showErrorIcon = false,
tooltipPopup,
hoverPopup,
triggerRef,
type,
typePlaceHolder,
@ -109,21 +113,139 @@ const VarReferencePickerTrigger: FC<Props> = ({
varKindTypes,
varName,
variableCategory,
VarPickerWrap,
WrapElem,
}) => {
return (
<WrapElem
onClick={() => {
if (readonly)
return
if (!isConstant)
setOpen(!open)
else
setControlFocus(Date.now())
}}
const handleTriggerReadonlyClick = (e: React.MouseEvent<HTMLElement>) => {
if (!readonly)
return
e.preventDefault()
e.stopPropagation()
}
const pill = (
<div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}>
{hasValue
? (
<>
{isShowNodeName && (
<div
className="flex items-center"
onClick={(e) => {
if (e.metaKey || e.ctrlKey)
handleVariableJump(outputVarNodeId || '')
}}
>
<div className="h-3 px-px">
{'type' in (outputVarNode || {}) && outputVarNode?.type && (
<VarBlockIcon
type={outputVarNode.type}
className="text-text-primary"
/>
)}
</div>
<div
className="mx-0.5 truncate text-xs font-medium text-text-secondary"
title={outputVarNode?.title as string | undefined}
style={{ maxWidth: maxNodeNameWidth }}
>
{outputVarNode?.title as string | undefined}
</div>
<Line3 className="mr-0.5"></Line3>
</div>
)}
{isShowAPart && (
<div className="flex items-center">
<RiMoreLine className="h-3 w-3 text-text-secondary" />
<Line3 className="mr-0.5 text-divider-deep"></Line3>
</div>
)}
<div className="flex items-center text-text-accent">
{isLoading && <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />}
<VariableIconWithColor
variables={value as ValueSelector}
variableCategory={variableCategory}
isExceptionVariable={isException}
/>
<div
className={cn('ml-0.5 truncate text-xs font-medium', isException && 'text-text-warning')}
title={varName}
style={{ maxWidth: maxVarNameWidth }}
>
{varName}
</div>
</div>
<div
className="ml-0.5 truncate text-center system-xs-regular text-text-tertiary capitalize"
title={type}
style={{ maxWidth: maxTypeWidth }}
>
{type}
</div>
{showErrorIcon && <RiErrorWarningFill data-testid="var-reference-picker-error-icon" className="ml-0.5 h-3 w-3 text-text-destructive" />}
</>
)
: (
<div className={`overflow-hidden ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'} system-sm-regular text-ellipsis`}>
{isLoading
? (
<div className="flex items-center">
<RiLoader4Line className="mr-1 h-3.5 w-3.5 animate-spin text-text-secondary" />
<span>{placeholder}</span>
</div>
)
: placeholder}
</div>
)}
</div>
)
const hoveredPill = hoverPopup?.kind === 'full-path'
? (
<PreviewCard>
<PreviewCardTrigger delay={300} closeDelay={200} render={pill} />
<PreviewCardContent popupClassName="border-0 bg-transparent p-0 shadow-none">
{hoverPopup.panel}
</PreviewCardContent>
</PreviewCard>
)
: hoverPopup?.kind === 'invalid-variable'
? (
<Tooltip>
<TooltipTrigger render={pill} />
<TooltipContent>{hoverPopup.message}</TooltipContent>
</Tooltip>
)
: pill
const variablePicker = (
<div className="h-full grow">
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
{hoveredPill}
</div>
</div>
)
const resolvedVariablePicker = isSupportConstantValue
? (
readonly
? variablePicker
: (
<PopoverTrigger
render={variablePicker}
onClick={handleTriggerReadonlyClick}
/>
)
)
: variablePicker
const triggerContent = (
<div
className={cn(className, 'group/picker-trigger-wrap relative flex!', !readonly && 'cursor-pointer')}
data-testid="var-reference-picker-trigger"
onClick={() => {
if (!isConstant || readonly)
return
setControlFocus(Date.now())
}}
>
<>
{isAddBtnTrigger
@ -178,109 +300,7 @@ const VarReferencePickerTrigger: FC<Props> = ({
isLoading={isLoading}
/>
)
: (
<VarPickerWrap
onClick={() => {
if (readonly)
return
if (!isConstant)
setOpen(!open)
else
setControlFocus(Date.now())
}}
className="h-full grow"
>
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
<Tooltip>
<TooltipTrigger
disabled={!tooltipPopup}
render={(
<div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}>
{hasValue
? (
<>
{isShowNodeName && (
<div
className="flex items-center"
onClick={(e) => {
if (e.metaKey || e.ctrlKey)
handleVariableJump(outputVarNodeId || '')
}}
>
<div className="h-3 px-px">
{'type' in (outputVarNode || {}) && outputVarNode?.type && (
<VarBlockIcon
type={outputVarNode.type}
className="text-text-primary"
/>
)}
</div>
<div
className="mx-0.5 truncate text-xs font-medium text-text-secondary"
title={outputVarNode?.title as string | undefined}
style={{ maxWidth: maxNodeNameWidth }}
>
{outputVarNode?.title as string | undefined}
</div>
<Line3 className="mr-0.5"></Line3>
</div>
)}
{isShowAPart && (
<div className="flex items-center">
<RiMoreLine className="h-3 w-3 text-text-secondary" />
<Line3 className="mr-0.5 text-divider-deep"></Line3>
</div>
)}
<div className="flex items-center text-text-accent">
{isLoading && <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />}
<VariableIconWithColor
variables={value as ValueSelector}
variableCategory={variableCategory}
isExceptionVariable={isException}
/>
<div
className={cn('ml-0.5 truncate text-xs font-medium', isException && 'text-text-warning')}
title={varName}
style={{ maxWidth: maxVarNameWidth }}
>
{varName}
</div>
</div>
<div
className="ml-0.5 truncate text-center system-xs-regular text-text-tertiary capitalize"
title={type}
style={{ maxWidth: maxTypeWidth }}
>
{type}
</div>
{showErrorIcon && <RiErrorWarningFill data-testid="var-reference-picker-error-icon" className="ml-0.5 h-3 w-3 text-text-destructive" />}
</>
)
: (
<div className={`overflow-hidden ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'} system-sm-regular text-ellipsis`}>
{isLoading
? (
<div className="flex items-center">
<RiLoader4Line className="mr-1 h-3.5 w-3.5 animate-spin text-text-secondary" />
<span>{placeholder}</span>
</div>
)
: placeholder}
</div>
)}
</div>
)}
/>
{tooltipPopup !== null && tooltipPopup !== undefined && (
<TooltipContent variant="plain">
{tooltipPopup}
</TooltipContent>
)}
</Tooltip>
</div>
</VarPickerWrap>
)}
: resolvedVariablePicker}
{(hasValue && !readonly && !isInTable && !isJustShowValue) && (
<div
className="group invisible absolute top-[50%] right-1 h-5 translate-y-[-50%] cursor-pointer rounded-md p-1 group-hover/wrap:visible hover:bg-state-base-hover"
@ -315,8 +335,22 @@ const VarReferencePickerTrigger: FC<Props> = ({
)}
</>
<input ref={inputRef} className="sr-only" value={controlFocus} readOnly />
</WrapElem>
</div>
)
if (!isSupportConstantValue) {
if (readonly)
return triggerContent
return (
<PopoverTrigger
render={triggerContent}
onClick={handleTriggerReadonlyClick}
/>
)
}
return triggerContent
}
export default VarReferencePickerTrigger

View File

@ -1,10 +1,16 @@
'use client'
import type { FC } from 'react'
import type { HoverPopup } from './var-reference-picker.trigger'
import type { CredentialFormSchema, CredentialFormSchemaSelect, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { CommonNodeType, Node, NodeOutPutVar, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import * as React from 'react'
@ -15,11 +21,6 @@ import {
useReactFlow,
useStoreApi,
} from 'reactflow'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
useIsChatMode,
@ -140,10 +141,10 @@ const VarReferencePicker: FC<Props> = ({
})
const node = nodes.find(n => n.id === nodeId)
const isInIteration = !!(node?.data as any)?.isInIteration
const isInIteration = !!node?.data.isInIteration
const iterationNode = isInIteration ? (nodes.find(n => n.id === node?.parentId) ?? null) : null
const isInLoop = !!(node?.data as any)?.isInLoop
const isInLoop = !!node?.data.isInLoop
const loopNode = isInLoop ? (nodes.find(n => n.id === node?.parentId) ?? null) : null
const triggerRef = useRef<HTMLDivElement>(null)
@ -209,13 +210,11 @@ const VarReferencePicker: FC<Props> = ({
}, [onChange])
const inputRef = useRef<HTMLInputElement>(null)
const [isFocus, setIsFocus] = useState(false)
const [controlFocus, setControlFocus] = useState(0)
const isFocus = controlFocus > 0
useEffect(() => {
if (controlFocus && inputRef.current) {
if (controlFocus && inputRef.current)
inputRef.current.focus()
setIsFocus(true)
}
}, [controlFocus])
const handleVarReferenceChange = useCallback((value: ValueSelector, varInfo: Var) => {
@ -263,7 +262,7 @@ const VarReferencePicker: FC<Props> = ({
}, [availableNodes, reactflow, store])
const type = getCurrentVariableType({
parentNode: (isInIteration ? iterationNode : loopNode) as any,
parentNode: isInIteration ? iterationNode : loopNode,
valueSelector: value as ValueSelector,
availableNodes,
isChatMode,
@ -288,23 +287,23 @@ const VarReferencePicker: FC<Props> = ({
maxVarNameWidth,
} = getWidthAllocations(triggerWidth, outputVarNode?.title || '', varName || '', type || '')
const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const VarPickerWrap = !isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const tooltipPopup = useMemo(() => {
const hoverPopup = useMemo<HoverPopup | null>(() => {
const tooltipType = getTooltipContent(hasValue, isShowAPart, isValidVar)
if (tooltipType === 'full-path') {
return (
<VarFullPathPanel
nodeName={outputVarNode?.title}
path={(value as ValueSelector).slice(1)}
varType={varTypeToStructType(type)}
nodeType={outputVarNode?.type}
/>
)
return {
kind: 'full-path',
panel: (
<VarFullPathPanel
nodeName={outputVarNode?.title}
path={(value as ValueSelector).slice(1)}
varType={varTypeToStructType(type)}
nodeType={outputVarNode?.type}
/>
),
}
}
if (tooltipType === 'invalid-variable')
return t('errorMsg.invalidVariable', { ns: 'workflow' })
return { kind: 'invalid-variable', message: t('errorMsg.invalidVariable', { ns: 'workflow' }) }
return null
}, [isValidVar, isShowAPart, hasValue, t, outputVarNode?.title, outputVarNode?.type, value, type])
@ -345,15 +344,23 @@ const VarReferencePicker: FC<Props> = ({
)
const triggerPlaceholder = placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })
const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div>
return (
<div className={cn(className)}>
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement={isAddBtnTrigger ? 'bottom-end' : 'bottom-start'}
>
{!!trigger && <PortalToFollowElemTrigger onClick={() => setOpen(!open)}>{trigger}</PortalToFollowElemTrigger>}
{!!trigger && (
<PopoverTrigger
render={resolvedTrigger}
onClick={(e) => {
if (readonly)
e.preventDefault()
}}
/>
)}
{!trigger && (
<VarReferencePickerTrigger
className={className}
@ -389,7 +396,7 @@ const VarReferencePicker: FC<Props> = ({
setControlFocus={setControlFocus}
setOpen={setOpen}
showErrorIcon={showErrorIcon}
tooltipPopup={tooltipPopup}
hoverPopup={hoverPopup}
triggerRef={triggerRef}
type={type}
typePlaceHolder={typePlaceHolder}
@ -399,15 +406,18 @@ const VarReferencePicker: FC<Props> = ({
varKindTypes={varKindTypes}
varName={varName}
variableCategory={variableCategory}
VarPickerWrap={VarPickerWrap}
WrapElem={WrapElem}
/>
)}
<PortalToFollowElemContent
style={{
zIndex: zIndex || 100,
}}
<PopoverContent
placement={isAddBtnTrigger ? 'bottom-end' : 'bottom-start'}
sideOffset={0}
className="mt-1"
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{
style: {
zIndex: zIndex || 100,
},
}}
>
{!isConstant && (
<VarReferencePopup
@ -420,8 +430,8 @@ const VarReferencePicker: FC<Props> = ({
preferSchemaType={preferSchemaType}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
</div>
)
}

View File

@ -1,3 +1,4 @@
import type { Field, StructuredOutput } from '@/app/components/workflow/nodes/llm/types'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
import { checkKeys } from '@/utils/var'
@ -76,6 +77,51 @@ const getVisibleChildren = (vars: Var[]) => {
return vars.filter(variable => checkKeys([variable.variable], false).isValid || isSpecialVar(variable.variable.split('.')[0]!))
}
const includesSearchText = (value: string | undefined, searchTextLower: string) => {
if (!value)
return false
return value.toLowerCase().includes(searchTextLower)
}
const isStructuredOutputChildren = (children: Var['children']): children is StructuredOutput => {
return !!children && !Array.isArray(children) && 'schema' in children
}
const matchesStructuredField = (fieldName: string, field: Field, searchTextLower: string): boolean => {
if (includesSearchText(fieldName, searchTextLower))
return true
if (field.properties)
return Object.entries(field.properties).some(([childName, childField]) => matchesStructuredField(childName, childField, searchTextLower))
if (field.items)
return matchesStructuredField(field.items.type, field.items, searchTextLower)
return false
}
const matchesVariableSearch = (variable: Var, searchTextLower: string): boolean => {
if (
includesSearchText(variable.variable, searchTextLower)
|| includesSearchText(variable.des, searchTextLower)
|| includesSearchText(variable.schemaType, searchTextLower)
) {
return true
}
if (!variable.children)
return false
if (Array.isArray(variable.children))
return getVisibleChildren(variable.children).some(child => matchesVariableSearch(child, searchTextLower))
if (isStructuredOutputChildren(variable.children))
return Object.entries(variable.children.schema.properties).some(([fieldName, field]) => matchesStructuredField(fieldName, field, searchTextLower))
return false
}
export const filterReferenceVars = (vars: NodeOutPutVar[], searchText: string) => {
const searchTextLower = searchText.toLowerCase()
@ -85,7 +131,7 @@ export const filterReferenceVars = (vars: NodeOutPutVar[], searchText: string) =
.filter((node) => {
if (!searchText)
return true
return node.vars.some(variable => variable.variable.toLowerCase().includes(searchTextLower))
return node.vars.some(variable => matchesVariableSearch(variable, searchTextLower))
|| node.title.toLowerCase().includes(searchTextLower)
})
.map((node) => {
@ -94,7 +140,7 @@ export const filterReferenceVars = (vars: NodeOutPutVar[], searchText: string) =
return {
...node,
vars: node.vars.filter(variable => variable.variable.toLowerCase().includes(searchTextLower)),
vars: node.vars.filter(variable => matchesVariableSearch(variable, searchTextLower)),
}
})
}

View File

@ -4,6 +4,11 @@ import type { StructuredOutput } from '../../../llm/types'
import type { Field } from '@/app/components/workflow/nodes/llm/types'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useHover } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
@ -13,11 +18,6 @@ import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows
import { CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { VarType } from '@/app/components/workflow/types'
@ -143,7 +143,7 @@ const Item: FC<ItemProps> = ({
const open = (isObj || isStructureOutput) && isHovering
useEffect(() => {
onHovering?.(isHovering)
}, [isHovering])
}, [isHovering, onHovering])
const handleChosen = (e: React.MouseEvent) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
@ -167,62 +167,70 @@ const Item: FC<ItemProps> = ({
() => getVariableCategory({ isEnv, isChatVar, isLoopVar, isRagVariable }),
[isEnv, isChatVar, isLoopVar, isRagVariable],
)
const itemTrigger = (
<div
ref={itemRef}
className={cn(
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
isHovering && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
className,
)}
onClick={handleChosen}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}}
>
<div className="flex w-0 grow items-center">
{!isFlat && (
<VariableIconWithColor
variables={itemData.variable.split('.')}
variableCategory={variableCategory}
isExceptionVariable={isException}
/>
)}
{isFlat && flatVarIcon}
{!isEnv && !isChatVar && !isRagVariable && (
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{varName}</div>
)}
{isEnv && (
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('env.', '')}</div>
)}
{isChatVar && (
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('conversation.', '')}</div>
)}
{isRagVariable && (
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.split('.').slice(-1)[0]}</div>
)}
</div>
<div className="ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize">{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</div>
{
(isObj || isStructureOutput) && (
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
)
}
</div>
)
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={noop}
placement="left-start"
>
<PortalToFollowElemTrigger className="w-full">
<div
ref={itemRef}
className={cn(
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
isHovering && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
className,
)}
onClick={handleChosen}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}}
>
<div className="flex w-0 grow items-center">
{!isFlat && (
<VariableIconWithColor
variables={itemData.variable.split('.')}
variableCategory={variableCategory}
isExceptionVariable={isException}
/>
)}
{isFlat && flatVarIcon}
{!isEnv && !isChatVar && !isRagVariable && (
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{varName}</div>
)}
{isEnv && (
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('env.', '')}</div>
)}
{isChatVar && (
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('conversation.', '')}</div>
)}
{isRagVariable && (
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.split('.').slice(-1)[0]}</div>
)}
</div>
<div className="ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize">{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</div>
{
(isObj || isStructureOutput) && (
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
)
}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{
zIndex: zIndex || 100,
}}
<PopoverTrigger render={itemTrigger} />
<PopoverContent
placement="left-start"
sideOffset={0}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{
style: {
zIndex: zIndex || 100,
},
}}
>
{(isStructureOutput || isObj) && (
<PickerStructurePanel
@ -234,13 +242,14 @@ const Item: FC<ItemProps> = ({
}}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
type Props = {
hideSearch?: boolean
searchText?: string
searchBoxClassName?: string
vars: NodeOutPutVar[]
isSupportFileVar?: boolean
@ -258,6 +267,7 @@ type Props = {
}
const VarReferenceVars: FC<Props> = ({
hideSearch,
searchText,
searchBoxClassName,
vars,
isSupportFileVar,
@ -274,7 +284,8 @@ const VarReferenceVars: FC<Props> = ({
preferSchemaType,
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const [internalSearchValue, setInternalSearchValue] = useState('')
const searchValue = searchText ?? internalSearchValue
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
@ -283,7 +294,7 @@ const VarReferenceVars: FC<Props> = ({
}
}
const filteredVars = useMemo(() => filterReferenceVars(vars, searchText), [vars, searchText])
const filteredVars = useMemo(() => filterReferenceVars(vars, searchValue), [vars, searchValue])
return (
<>
@ -295,11 +306,11 @@ const VarReferenceVars: FC<Props> = ({
className="var-search-input"
showLeftIcon
showClearIcon
value={searchText}
value={searchValue}
placeholder={t('common.searchVar', { ns: 'workflow' }) || ''}
onChange={e => setSearchText(e.target.value)}
onChange={e => setInternalSearchValue(e.target.value)}
onKeyDown={handleKeyDown}
onClear={() => setSearchText('')}
onClear={() => setInternalSearchValue('')}
onBlur={onBlur}
autoFocus={autoFocus}
/>

View File

@ -1,5 +1,10 @@
import type { FC } from 'react'
import type { CodeDependency } from './types'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
RiArrowDownSLine,
} from '@remixicon/react'
@ -8,7 +13,6 @@ import * as React from 'react'
import { useCallback, useState } from 'react'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import Input from '@/app/components/base/input'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
type Props = {
value: CodeDependency
@ -32,21 +36,22 @@ const DependencyPicker: FC<Props> = ({
}, [onChange])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<PortalToFollowElemTrigger onClick={() => setOpen(!open)} className="grow cursor-pointer">
<div className="flex h-8 items-center justify-between rounded-lg border-0 bg-gray-100 px-2.5 text-[13px] text-gray-900">
<div className="w-0 grow truncate" title={value.name}>{value.name}</div>
<RiArrowDownSLine className="h-3.5 w-3.5 shrink-0 text-gray-700" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{
zIndex: 100,
}}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<div className="grow cursor-pointer">
<div className="flex h-8 items-center justify-between rounded-lg border-0 bg-gray-100 px-2.5 text-[13px] text-gray-900">
<div className="w-0 grow truncate" title={value.name}>{value.name}</div>
<RiArrowDownSLine className="h-3.5 w-3.5 shrink-0 text-gray-700" />
</div>
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 100 } }}
>
<div
className="rounded-lg bg-white p-1 shadow-sm"
@ -82,8 +87,8 @@ const DependencyPicker: FC<Props> = ({
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -2,6 +2,59 @@ import type { Member } from '@/models/common'
import { fireEvent, render, screen } from '@testing-library/react'
import MemberSelector from '../member-selector'
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: import('react').ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: import('react').ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
const PopoverContent = ({ children }: { children: import('react').ReactNode }) => {
const { open } = React.useContext(PopoverContext)
return open ? <div data-testid="popover-content">{children}</div> : null
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
const mockMemberList = vi.hoisted(() => vi.fn())
vi.mock('../member-list', () => ({

View File

@ -1,14 +1,13 @@
import type { Recipient as RecipientItem } from '../../../types'
import type { Member } from '@/models/common'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import EmailItem from './email-item'
import MemberList from './member-list'
@ -58,8 +57,7 @@ const EmailInput = ({
if (disabled)
return
setIsFocus(true)
const input = inputRef.current?.children[0] as HTMLInputElement
input?.focus()
inputRef.current?.focus()
}
const handleValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -141,28 +139,29 @@ const EmailInput = ({
/>
))}
{!disabled && (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: -40,
}}
>
<PortalToFollowElemTrigger className="block h-6 min-w-[166px]">
<input
ref={inputRef}
className="h-6 min-w-[166px] appearance-none bg-transparent p-1 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder"
placeholder={placeholder}
onFocus={() => setIsFocus(true)}
onBlur={handleInputBlur}
value={searchKey}
onChange={handleValueChange}
onKeyDown={handleKeyDown}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<Popover open={open} onOpenChange={setOpen}>
<input
ref={inputRef}
className="h-6 min-w-[166px] appearance-none bg-transparent p-1 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder"
placeholder={placeholder}
onFocus={() => setIsFocus(true)}
onBlur={handleInputBlur}
value={searchKey}
onChange={handleValueChange}
onKeyDown={handleKeyDown}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
alignOffset={-40}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{
anchor: inputRef,
style: {
zIndex: 1000,
},
}}
>
<MemberList
searchValue={searchKey}
list={list}
@ -172,8 +171,8 @@ const EmailInput = ({
email={email}
hideSearch
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)}
</div>
</div>

View File

@ -4,12 +4,16 @@ import type { Recipient } from '@/app/components/workflow/nodes/human-input/type
import type { Member } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
RiContactsBookLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import MemberList from './member-list'
const i18nPrefix = 'nodes.humanInput'
@ -31,39 +35,42 @@ const MemberSelector: FC<Props> = ({
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
const handleSelect = useCallback((memberId: string) => {
onSelect(memberId)
setOpen(false)
}, [onSelect])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 35,
}}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={() => setOpen(v => !v)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
className={cn('w-full justify-between', open && 'bg-state-accent-hover')}
variant="ghost-accent"
>
<RiContactsBookLine className="mr-1 h-4 w-4" />
<div>{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.trigger`, { ns: 'workflow' })}</div>
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
alignOffset={35}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<Button
className={cn('w-full justify-between', open && 'bg-state-accent-hover')}
variant="ghost-accent"
>
<RiContactsBookLine className="mr-1 h-4 w-4" />
<div className="">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.trigger`, { ns: 'workflow' })}</div>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<MemberList
searchValue={searchValue}
list={list}
value={value}
onSearchChange={setSearchValue}
onSelect={onSelect}
onSelect={handleSelect}
email={email}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
export default MemberSelector

View File

@ -5,17 +5,17 @@ import type {
Var,
} from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiAddLine } from '@remixicon/react'
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
type ConditionAddProps = {
@ -25,6 +25,7 @@ type ConditionAddProps = {
onSelectVariable: HandleAddCondition
disabled?: boolean
}
const ConditionAdd = ({
className,
caseId,
@ -38,29 +39,32 @@ const ConditionAdd = ({
const handleSelectVariable = useCallback((valueSelector: ValueSelector, varItem: Var) => {
onSelectVariable(caseId, valueSelector, varItem)
setOpen(false)
}, [caseId, onSelectVariable, setOpen])
}, [caseId, onSelectVariable])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
<Button
size="small"
className={className}
disabled={disabled}
>
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('nodes.ifElse.addCondition', { ns: 'workflow' })}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
size="small"
className={className}
disabled={disabled}
>
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('nodes.ifElse.addCondition', { ns: 'workflow' })}
</Button>
)}
onClick={(e) => {
if (disabled)
e.preventDefault()
}}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars
vars={variables}
@ -68,8 +72,8 @@ const ConditionAdd = ({
onChange={handleSelectVariable}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -1,5 +1,9 @@
import type { Node, NodeOutPutVar, ValueSelector, Var, VarType } from '@/app/components/workflow/types'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
@ -23,26 +27,25 @@ const ConditionVarSelector = ({
onChange,
}: ConditionVarSelectorProps) => {
return (
<PortalToFollowElem
open={open}
onOpenChange={onOpenChange}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger asChild onClick={() => onOpenChange(!open)}>
<div className="w-full cursor-pointer">
<VariableTag
valueSelector={valueSelector}
varType={varType}
availableNodes={availableNodes}
isShort
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
render={(
<div className="w-full cursor-pointer">
<VariableTag
valueSelector={valueSelector}
varType={varType}
availableNodes={availableNodes}
isShort
/>
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars
vars={nodesOutputVars}
@ -50,8 +53,8 @@ const ConditionVarSelector = ({
onChange={onChange}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -2,8 +2,11 @@ import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-re
import type { MetadataInDoc } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import {
RiAddLine,
} from '@remixicon/react'
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiAddLine } from '@remixicon/react'
import {
useCallback,
useMemo,
@ -11,11 +14,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import MetadataIcon from './metadata-icon'
const AddCondition = ({
@ -36,25 +34,24 @@ const AddCondition = ({
}, [handleAddCondition])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 3,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
<Button
size="small"
variant="secondary"
>
<RiAddLine className="h-3.5 w-3.5" />
{t('nodes.knowledgeRetrieval.metadata.panel.add', { ns: 'workflow' })}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
size="small"
variant="secondary"
>
<RiAddLine className="h-3.5 w-3.5" />
{t('nodes.knowledgeRetrieval.metadata.panel.add', { ns: 'workflow' })}
</Button>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={12}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1002 } }}
>
<div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<div className="p-2 pb-1">
<Input
@ -65,30 +62,28 @@ const AddCondition = ({
/>
</div>
<div className="p-1">
{
filteredMetadataList?.map(metadata => (
<div
key={metadata.name}
className="flex h-6 cursor-pointer items-center rounded-md px-3 system-sm-medium text-text-secondary hover:bg-state-base-hover"
>
<div className="mr-1 p-px">
<MetadataIcon type={metadata.type} />
</div>
<div
className="grow truncate"
title={metadata.name}
onClick={() => handleAddConditionWrapped(metadata)}
>
{metadata.name}
</div>
<div className="shrink-0 system-xs-regular text-text-tertiary">{metadata.type}</div>
{filteredMetadataList?.map(metadata => (
<div
key={metadata.name}
className="flex h-6 cursor-pointer items-center rounded-md px-3 system-sm-medium text-text-secondary hover:bg-state-base-hover"
>
<div className="mr-1 p-px">
<MetadataIcon type={metadata.type} />
</div>
))
}
<div
className="grow truncate"
title={metadata.name}
onClick={() => handleAddConditionWrapped(metadata)}
>
{metadata.name}
</div>
<div className="shrink-0 system-xs-regular text-text-tertiary">{metadata.type}</div>
</div>
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -1,12 +1,12 @@
import type { VarType } from '@/app/components/workflow/types'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type ConditionCommonVariableSelectorProps = {
variables?: { name: string, type: string, value: string }[]
@ -31,34 +31,17 @@ const ConditionCommonVariableSelector = ({
}, [onChange])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger
asChild
onClick={() => {
if (!variables.length)
return
setOpen(!open)
}}
>
<div className="flex h-6 grow cursor-pointer items-center">
{
selected && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<div className="flex h-6 grow cursor-pointer items-center">
{selected && (
<div className="inline-flex h-6 items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark pr-1.5 pl-[5px] system-xs-medium text-text-secondary shadow-xs">
<Variable02 className="mr-1 h-3.5 w-3.5 text-text-accent" />
{selected.value}
</div>
)
}
{
!selected && (
)}
{!selected && (
<>
<div className="flex grow items-center system-sm-regular text-components-input-text-placeholder">
<Variable02 className="mr-1 h-4 w-4" />
@ -68,27 +51,34 @@ const ConditionCommonVariableSelector = ({
{varType}
</div>
</>
)
}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
)}
</div>
)}
onClick={(e) => {
if (!variables.length)
e.preventDefault()
}}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<div className="w-[200px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{
variables.map(v => (
<div
key={v.value}
className="flex h-6 cursor-pointer items-center rounded-md px-2 system-xs-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => handleChange(v.value)}
>
<Variable02 className="mr-1 h-4 w-4 text-text-accent" />
{v.value}
</div>
))
}
{variables.map(v => (
<div
key={v.value}
className="flex h-6 cursor-pointer items-center rounded-md px-2 system-xs-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => handleChange(v.value)}
>
<Variable02 className="mr-1 h-4 w-4 text-text-accent" />
{v.value}
</div>
))}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -4,14 +4,14 @@ import type {
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { VarType } from '@/app/components/workflow/types'
@ -34,35 +34,25 @@ const ConditionVariableSelector = ({
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleChange = useCallback((valueSelector: ValueSelector, varItem: Var) => {
onChange(valueSelector, varItem)
const handleChange = useCallback((nextValueSelector: ValueSelector, varItem: Var) => {
onChange(nextValueSelector, varItem)
setOpen(false)
}, [onChange])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger asChild onClick={() => setOpen(!open)}>
<div className="flex h-6 grow cursor-pointer items-center">
{
!!valueSelector.length && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<div className="flex h-6 grow cursor-pointer items-center">
{!!valueSelector.length && (
<VariableTag
valueSelector={valueSelector}
varType={varType}
availableNodes={availableNodes}
isShort
/>
)
}
{
!valueSelector.length && (
)}
{!valueSelector.length && (
<>
<div className="flex grow items-center system-sm-regular text-components-input-text-placeholder">
<Variable02 className="mr-1 h-4 w-4" />
@ -72,11 +62,16 @@ const ConditionVariableSelector = ({
{varType}
</div>
</>
)
}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
)}
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars
vars={nodesOutputVars}
@ -84,8 +79,8 @@ const ConditionVariableSelector = ({
onChange={handleChange}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -5,17 +5,17 @@ import type {
Var,
} from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiAddLine } from '@remixicon/react'
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
type ConditionAddProps = {
@ -24,6 +24,7 @@ type ConditionAddProps = {
onSelectVariable: HandleAddCondition
disabled?: boolean
}
const ConditionAdd = ({
className,
variables,
@ -36,29 +37,32 @@ const ConditionAdd = ({
const handleSelectVariable = useCallback((valueSelector: ValueSelector, varItem: Var) => {
onSelectVariable(valueSelector, varItem)
setOpen(false)
}, [onSelectVariable, setOpen])
}, [onSelectVariable])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
<Button
size="small"
className={className}
disabled={disabled}
>
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('nodes.ifElse.addCondition', { ns: 'workflow' })}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
size="small"
className={className}
disabled={disabled}
>
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('nodes.ifElse.addCondition', { ns: 'workflow' })}
</Button>
)}
onClick={(e) => {
if (disabled)
e.preventDefault()
}}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars
vars={variables}
@ -66,8 +70,8 @@ const ConditionAdd = ({
onChange={handleSelectVariable}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -1,5 +1,9 @@
import type { Node, NodeOutPutVar, ValueSelector, Var, VarType } from '@/app/components/workflow/types'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
@ -23,26 +27,25 @@ const ConditionVarSelector = ({
onChange,
}: ConditionVarSelectorProps) => {
return (
<PortalToFollowElem
open={open}
onOpenChange={onOpenChange}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => onOpenChange(!open)}>
<div className="cursor-pointer">
<VariableTag
valueSelector={valueSelector}
varType={varType}
availableNodes={availableNodes}
isShort
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
render={(
<div className="cursor-pointer">
<VariableTag
valueSelector={valueSelector}
varType={varType}
availableNodes={availableNodes}
isShort
/>
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars
vars={nodesOutputVars}
@ -50,8 +53,8 @@ const ConditionVarSelector = ({
onChange={onChange}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,159 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SearchInput from '../search-input'
const educationMocks = vi.hoisted(() => ({
schools: ['Alpha University', 'Beta College'],
setSchools: vi.fn(),
querySchoolsWithDebounced: vi.fn(),
handleUpdateSchools: vi.fn(),
hasNext: false,
}))
vi.mock('../hooks', () => ({
useEducation: () => educationMocks,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/input', () => ({
default: ({
value,
onChange,
placeholder,
className,
}: {
value?: string
onChange: (event: { target: { value: string } }) => void
placeholder?: string
className?: string
}) => (
<input
className={className}
placeholder={placeholder}
value={value}
onChange={e => onChange({ target: { value: e.target.value } })}
/>
),
}))
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: ReactNode }) => <>{render}</>
const PopoverContent = ({ children }: { children: ReactNode }) => {
const { open } = React.useContext(PopoverContext)
return open ? <div data-testid="education-search-popover">{children}</div> : null
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
const ControlledSearchInput = () => {
const [value, setValue] = useState('')
return <SearchInput value={value} onChange={setValue} />
}
describe('education-apply/search-input', () => {
beforeEach(() => {
vi.clearAllMocks()
educationMocks.schools = ['Alpha University', 'Beta College']
educationMocks.hasNext = false
})
it('opens the popover, queries schools, and closes after selection', async () => {
const user = userEvent.setup()
render(<ControlledSearchInput />)
const input = screen.getByPlaceholderText('form.schoolName.placeholder')
await user.type(input, 'A')
expect(educationMocks.setSchools).toHaveBeenCalledWith([])
expect(educationMocks.querySchoolsWithDebounced).toHaveBeenLastCalledWith({
keywords: 'A',
page: 0,
})
expect(screen.getByTestId('education-search-popover')).toBeInTheDocument()
expect(screen.getByText('Alpha University')).toBeInTheDocument()
await user.click(screen.getByText('Beta College'))
expect(screen.getByDisplayValue('Beta College')).toBeInTheDocument()
expect(screen.queryByTestId('education-search-popover')).not.toBeInTheDocument()
})
it('loads the next page when the dropdown is scrolled to the bottom', async () => {
const user = userEvent.setup()
educationMocks.hasNext = true
render(<ControlledSearchInput />)
await user.type(screen.getByPlaceholderText('form.schoolName.placeholder'), 'A')
const scrollContainer = screen.getByText('Alpha University').parentElement as HTMLDivElement
Object.defineProperties(scrollContainer, {
scrollTop: {
value: 60,
configurable: true,
},
scrollHeight: {
value: 100,
configurable: true,
},
clientHeight: {
value: 40,
configurable: true,
},
})
fireEvent.scroll(scrollContainer)
expect(educationMocks.handleUpdateSchools).toHaveBeenCalledWith({
keywords: 'A',
page: 1,
})
})
})

View File

@ -1,4 +1,9 @@
import type { ChangeEventHandler } from 'react'
import type { ChangeEventHandler, UIEventHandler } from 'react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
useCallback,
useRef,
@ -6,17 +11,13 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useEducation } from './hooks'
type SearchInputProps = {
value?: string
onChange: (value: string) => void
}
const SearchInput = ({
value,
onChange,
@ -48,7 +49,7 @@ const SearchInput = ({
keywords,
page,
})
}, [querySchoolsWithDebounced, handleUpdateSchools])
}, [handleUpdateSchools, querySchoolsWithDebounced])
const handleValueChange: ChangeEventHandler<HTMLInputElement> = useCallback((e) => {
setOpen(true)
@ -58,10 +59,10 @@ const SearchInput = ({
valueRef.current = inputValue
onChange(inputValue)
handleSearch(true)
}, [onChange, handleSearch, setSchools])
}, [handleSearch, onChange, setSchools])
const handleScroll = useCallback((e: Event) => {
const target = e.target as HTMLDivElement
const handleScroll: UIEventHandler<HTMLDivElement> = useCallback((e) => {
const target = e.currentTarget
const {
scrollTop,
scrollHeight,
@ -74,48 +75,45 @@ const SearchInput = ({
}, [handleSearch, hasNext])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom"
offset={4}
triggerPopupSameWidth
>
<PortalToFollowElemTrigger className="block w-full">
<Input
className="w-full"
placeholder={t('form.schoolName.placeholder', { ns: 'education' })}
value={value}
onChange={handleValueChange}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-32">
{
!!schools.length && value && (
<div
className="max-h-[330px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1"
onScroll={handleScroll as any}
>
{
schools.map((school, index) => (
<div
key={index}
className="flex h-8 cursor-pointer items-center truncate rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
title={school}
onClick={() => {
onChange(school)
setOpen(false)
}}
>
{school}
</div>
))
}
</div>
)
}
</PortalToFollowElemContent>
</PortalToFollowElem>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Input
className="w-full"
placeholder={t('form.schoolName.placeholder', { ns: 'education' })}
value={value}
onChange={handleValueChange}
/>
)}
/>
{!!schools.length && !!value && (
<PopoverContent
placement="bottom"
sideOffset={4}
popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"
positionerProps={{ style: { zIndex: 32 } }}
>
<div
className="max-h-[330px] overflow-y-auto"
onScroll={handleScroll}
>
{schools.map(school => (
<div
key={school}
className="flex h-8 cursor-pointer items-center truncate rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
title={school}
onClick={() => {
onChange(school)
setOpen(false)
}}
>
{school}
</div>
))}
</div>
</PopoverContent>
)}
</Popover>
)
}

View File

@ -13,9 +13,9 @@ import storybook from 'eslint-plugin-storybook'
import {
HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS,
NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS,
NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
OVERLAY_MIGRATION_LEGACY_BASE_FILES,
OVERLAY_RESTRICTED_IMPORT_PATTERNS,
WEB_RESTRICTED_IMPORT_PATTERNS,
} from './eslint.constants.mjs'
import dify from './plugins/eslint/index.js'
@ -161,13 +161,13 @@ export default antfu(
},
},
{
name: 'dify/no-direct-next-imports',
name: 'dify/restricted-imports',
files: [GLOB_TS, GLOB_TSX],
ignores: ['next/**'],
rules: {
'no-restricted-imports': ['error', {
paths: NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS,
patterns: NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
patterns: WEB_RESTRICTED_IMPORT_PATTERNS,
}],
},
},
@ -183,7 +183,7 @@ export default antfu(
'no-restricted-imports': ['error', {
paths: NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS,
patterns: [
...NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
...WEB_RESTRICTED_IMPORT_PATTERNS,
...OVERLAY_RESTRICTED_IMPORT_PATTERNS,
],
}],

View File

@ -5,7 +5,7 @@ export const NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS = [
},
]
export const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [
const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [
{
group: ['next/image'],
message: 'Do not import next/image. Use native img tags instead.',
@ -20,6 +20,21 @@ export const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [
},
]
const BASE_UI_RESTRICTED_IMPORT_PATTERNS = [
{
group: [
'@base-ui/react',
'@base-ui/react/*',
],
message: 'Do not import Base UI directly in web. Use @langgenius/dify-ui/* primitives instead.',
},
]
export const WEB_RESTRICTED_IMPORT_PATTERNS = [
...NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
...BASE_UI_RESTRICTED_IMPORT_PATTERNS,
]
export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
{
group: [