Compare commits

..

3 Commits

Author SHA1 Message Date
1b49059231 feat: add frontend environment reference generation
- Introduced `frontend-env.reference.json` and `frontend-env.reference.md` to document frontend environment variables.
- Implemented `env-reference.mjs` script to extract and generate environment variable metadata from `web/env.ts`.
- Added tests for environment reference generation in `env-reference.spec.ts`.
2026-04-21 21:26:28 +08:00
b7666af311 Merge remote-tracking branch 'origin/main' into deploy/zhaohao 2026-04-20 21:23:21 +08:00
37a2199029 init 2026-04-20 21:21:54 +08:00
156 changed files with 14187 additions and 3842 deletions

10
.gitignore vendored
View File

@ -237,10 +237,6 @@ scripts/stress-test/reports/
.playwright-mcp/
.serena/
# vitest browser mode attachments (failure screenshots, traces, etc.)
.vitest-attachments/
**/__screenshots__/
# settings
*.local.json
*.local.md
@ -249,3 +245,9 @@ scripts/stress-test/reports/
.qoder/*
.eslintcache
docker/.codex/
docker/.claude/
docker/defaults
docker/openspec
docker/AGENTS.md

View File

@ -0,0 +1,431 @@
"""Generate a backend env reference from the authoritative config model.
This module derives backend env input metadata from ``DifyConfig`` instead of
grepping individual files. The exported reference intentionally captures only
code-defined semantics and fallback defaults; it does not attempt to represent
deployment defaults or runtime-effective values.
"""
from __future__ import annotations
import inspect
import json
import logging
import re
from collections import defaultdict
from enum import Enum
from pathlib import Path
from types import UnionType
from typing import Any, TypedDict, get_args, get_origin
from pydantic import AliasChoices, BaseModel
from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings
from .app_config import DifyConfig
_REPO_ROOT = Path(__file__).resolve().parents[2]
_API_ROOT = Path(__file__).resolve().parents[1]
_DOCS_ROOT = _API_ROOT / "docs"
_JSON_OUTPUT = _DOCS_ROOT / "backend-env.reference.json"
_MARKDOWN_OUTPUT = _DOCS_ROOT / "backend-env.reference.md"
_SENSITIVE_SUFFIXES = (
"_PASSWORD",
"_SECRET",
"_TOKEN",
"_API_KEY",
"_ACCESS_KEY",
"_SECRET_KEY",
"_PRIVATE_KEY",
)
logger = logging.getLogger(__name__)
_DESCRIPTION_REWRITES = {
"Duration in minutes for which a account deletion token remains valid": (
"Duration in minutes for which an account deletion token remains valid."
),
"whether to enable education identity": "Whether to enable education identity.",
(
"Granularity for async workflow scheduler, sometime, few users could block the queue "
"due to some time-consuming tasks, to avoid this, workflow can be suspended if needed, "
"to achievethis, a time-based checker is required, every granularity seconds, "
"the checker will check the workflow queue and suspend the workflow"
): (
"Granularity for the async workflow scheduler. Some users could block the queue with "
"time-consuming tasks, so workflows can be suspended when needed. A time-based checker "
"runs every granularity seconds to inspect the queue and suspend workflows."
),
(
"Base URL for file preview or download, used for frontend display and multi-model "
"inputsUrl is signed and has expiration time."
): (
"Base URL for file preview or download, used for frontend display and multi-model "
"inputs. The URL is signed and has an expiration time."
),
}
class BackendEnvVariableReference(TypedDict):
name: str
accepted_names: list[str]
group: str
type: str
description: str
code_default: Any | None
required: bool
applies_when: str | None
class BackendEnvReference(TypedDict):
schema_version: str
artifact_policy: str
authority: dict[str, str]
resolution: dict[str, list[str]]
variables: list[BackendEnvVariableReference]
def _config_classes() -> list[type[BaseSettings]]:
return [
cls
for cls in DifyConfig.__mro__[1:]
if inspect.isclass(cls)
and issubclass(cls, BaseSettings)
and cls is not BaseSettings
and cls.__module__.startswith("configs.")
]
def _owner_class_for_field(field_name: str) -> type[BaseSettings] | None:
for cls in _config_classes():
if field_name in getattr(cls, "__annotations__", {}):
return cls
return None
def _normalize_name(name: str) -> str:
return re.sub(r"(?<!^)(?=[A-Z])", "-", name).replace("_", "-").lower()
def _group_for_owner(owner: type[BaseSettings]) -> str:
module_parts = owner.__module__.removeprefix("configs.").split(".")
if module_parts[-1].endswith("_config"):
module_parts = module_parts[:-1]
return ".".join([*module_parts, _normalize_name(owner.__name__.removesuffix("Config"))])
def _accepted_names(field_name: str, field_info: FieldInfo) -> list[str]:
alias = field_info.validation_alias
if isinstance(alias, AliasChoices):
names = [str(choice) for choice in alias.choices]
elif isinstance(alias, str):
names = [alias]
else:
names = [field_name]
if field_name not in names:
names.append(field_name)
return names
def _type_name(annotation: Any) -> str:
origin = get_origin(annotation)
if origin is None:
if annotation in {str, Any}:
return "string"
if annotation is bool:
return "boolean"
if annotation is int:
return "integer"
if annotation is float:
return "float"
if annotation is type(None):
return "null"
if inspect.isclass(annotation):
if issubclass(annotation, Enum):
return "enum"
if issubclass(annotation, str):
return "string"
if issubclass(annotation, bool):
return "boolean"
if issubclass(annotation, int):
return "integer"
if issubclass(annotation, float):
return "float"
return getattr(annotation, "__name__", str(annotation))
if origin is UnionType or str(origin).endswith("Union"):
args = [arg for arg in get_args(annotation) if arg is not type(None)]
rendered = " | ".join(_type_name(arg) for arg in args) if args else "null"
if len(args) != len(get_args(annotation)):
return f"{rendered} | null"
return rendered
if str(origin).endswith("Literal"):
values = ", ".join(repr(value) for value in get_args(annotation))
return f"literal[{values}]"
if str(origin).endswith("Annotated"):
args = get_args(annotation)
return _type_name(args[0]) if args else "annotated"
if origin in {list, tuple, set}:
args = get_args(annotation)
item_type = _type_name(args[0]) if args else "any"
return f"{origin.__name__}[{item_type}]"
return str(annotation)
def _serialize_default(value: Any) -> Any | None:
if value is None:
return None
if isinstance(value, BaseModel):
return value.model_dump(mode="json")
if isinstance(value, Enum):
return value.value
if isinstance(value, Path):
return str(value)
if isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (list, tuple)):
return [_serialize_default(item) for item in value]
if isinstance(value, dict):
return {str(key): _serialize_default(item) for key, item in value.items()}
return str(value)
def _markdown_cell(value: Any | None) -> str:
if value is None:
return ""
text = str(value)
normalized = " ".join(text.split())
return normalized.replace("|", "\\|")
def _markdown_code_cell(value: Any | None, *, empty: str = "") -> str:
text = _markdown_cell(value)
if not text:
return empty
return f"`{text.replace('`', '\\`')}`"
def _render_code_default(value: Any | None) -> str:
if value is None:
return _markdown_code_cell(json.dumps("", ensure_ascii=False))
if isinstance(value, str):
return _markdown_code_cell(json.dumps(" ".join(value.split()), ensure_ascii=False))
return _markdown_code_cell(json.dumps(value, ensure_ascii=False))
def _normalize_description(description: str) -> str:
normalized = " ".join(description.split())
if not normalized:
return ""
rewritten = _DESCRIPTION_REWRITES.get(normalized, normalized)
rewritten = re.sub(r"(?<=[.!?])(?=[A-Z])", " ", rewritten)
rewritten = re.sub(r"(?<=\w),(?=[A-Za-z])", ", ", rewritten)
rewritten = re.sub(r"(?<=:)(?=https?://)", " ", rewritten)
rewritten = re.sub(r"(?<=\w)\((?=e\.g\.,)", " (", rewritten)
return rewritten
def _render_group_applicability_notes(variables: list[BackendEnvVariableReference]) -> list[str]:
applies_when_groups: dict[str, list[str]] = defaultdict(list)
for variable in variables:
applies_when = variable["applies_when"]
if applies_when:
applies_when_groups[applies_when].append(variable["name"])
if not applies_when_groups:
return []
if len(applies_when_groups) == 1 and len(next(iter(applies_when_groups.values()))) == len(variables):
applies_when = next(iter(applies_when_groups))
return [f"> Applies when: {_markdown_code_cell(applies_when)}", ""]
lines = ["Applies when:"]
for applies_when, names in sorted(applies_when_groups.items()):
joined_names = ", ".join(f"`{name}`" for name in sorted(names))
lines.append(f"- {joined_names}: {_markdown_code_cell(applies_when)}")
lines.append("")
return lines
def _provider_applies_when(owner: type[BaseSettings], field_name: str) -> str | None:
source_file = Path(inspect.getsourcefile(owner) or "")
source_name = source_file.name
storage_map = {
"amazon_s3_storage_config.py": "STORAGE_TYPE=s3",
"aliyun_oss_storage_config.py": "STORAGE_TYPE=aliyun-oss",
"azure_blob_storage_config.py": "STORAGE_TYPE=azure-blob",
"baidu_obs_storage_config.py": "STORAGE_TYPE=baidu-obs",
"clickzetta_volume_storage_config.py": "STORAGE_TYPE=clickzetta-volume",
"google_cloud_storage_config.py": "STORAGE_TYPE=google-storage",
"huawei_obs_storage_config.py": "STORAGE_TYPE=huawei-obs",
"oci_storage_config.py": "STORAGE_TYPE=oci-storage",
"opendal_storage_config.py": "STORAGE_TYPE=opendal",
"supabase_storage_config.py": "STORAGE_TYPE=supabase",
"tencent_cos_storage_config.py": "STORAGE_TYPE=tencent-cos",
"volcengine_tos_storage_config.py": "STORAGE_TYPE=volcengine-tos",
}
if field_name == "STORAGE_LOCAL_PATH":
return "STORAGE_TYPE=local"
if source_name in storage_map:
return storage_map[source_name]
vector_map = {
"analyticdb_config.py": "VECTOR_STORE=analyticdb",
"baidu_vector_config.py": "VECTOR_STORE=baidu_vector",
"chroma_config.py": "VECTOR_STORE=chroma",
"clickzetta_config.py": "VECTOR_STORE=clickzetta",
"couchbase_config.py": "VECTOR_STORE=couchbase",
"elasticsearch_config.py": "VECTOR_STORE=elasticsearch",
"hologres_config.py": "VECTOR_STORE=hologres",
"huawei_cloud_config.py": "VECTOR_STORE=huawei-cloud",
"iris_config.py": "VECTOR_STORE=iris",
"lindorm_config.py": "VECTOR_STORE=lindorm",
"matrixone_config.py": "VECTOR_STORE=matrixone",
"milvus_config.py": "VECTOR_STORE=milvus",
"myscale_config.py": "VECTOR_STORE=myscale",
"oceanbase_config.py": "VECTOR_STORE=oceanbase",
"opengauss_config.py": "VECTOR_STORE=opengauss",
"opensearch_config.py": "VECTOR_STORE=opensearch",
"oracle_config.py": "VECTOR_STORE=oracle",
"pgvector_config.py": "VECTOR_STORE=pgvector",
"pgvectors_config.py": "VECTOR_STORE=pgvectors",
"qdrant_config.py": "VECTOR_STORE=qdrant",
"relyt_config.py": "VECTOR_STORE=relyt",
"tablestore_config.py": "VECTOR_STORE=tablestore",
"tencent_vector_config.py": "VECTOR_STORE=tencent",
"tidb_on_qdrant_config.py": "VECTOR_STORE=tidb_on_qdrant",
"tidb_vector_config.py": "VECTOR_STORE=tidb_vector",
"upstash_config.py": "VECTOR_STORE=upstash",
"vastbase_vector_config.py": "VECTOR_STORE=vastbase",
"vikingdb_config.py": "VECTOR_STORE=vikingdb",
"weaviate_config.py": "VECTOR_STORE=weaviate",
"alibabacloud_mysql_config.py": "VECTOR_STORE=alibabacloud-mysql",
}
applies_when = vector_map.get(source_name)
if (
applies_when
and source_name == "elasticsearch_config.py"
and ("CLOUD" in field_name or field_name in {"ELASTICSEARCH_API_KEY", "ELASTICSEARCH_CA_CERTS"})
):
return f"{applies_when}; ELASTICSEARCH_USE_CLOUD=true"
return applies_when
def build_backend_env_reference() -> BackendEnvReference:
variables: list[BackendEnvVariableReference] = []
for field_name, field_info in sorted(DifyConfig.model_fields.items()):
if not field_name.isupper():
continue
owner = _owner_class_for_field(field_name)
if owner is None:
continue
variables.append(
{
"name": field_name,
"accepted_names": _accepted_names(field_name, field_info),
"group": _group_for_owner(owner),
"type": _type_name(field_info.annotation),
"description": field_info.description or "",
"code_default": None if field_info.is_required() else _serialize_default(field_info.default),
"required": field_info.is_required(),
"applies_when": _provider_applies_when(owner, field_name),
}
)
return {
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {
"kind": "backend-code-defaults",
"source_root": "api/configs",
"model": "configs.app_config.DifyConfig",
},
"resolution": {
"precedence": [
"init_settings",
"process_env",
"remote_settings",
"dotenv",
"file_secrets",
"toml",
"code_default",
]
},
"variables": variables,
}
def render_backend_env_reference_markdown(reference: BackendEnvReference) -> str:
grouped: dict[str, list[BackendEnvVariableReference]] = defaultdict(list)
for variable in reference["variables"]:
grouped[variable["group"]].append(variable)
lines = [
"# Backend Env Reference",
"",
"> Generated from `api/configs/**/*.py`. Do not edit manually.",
"",
"This reference documents backend env input semantics and code defaults only.",
"Deployment defaults, `.env.example`, and runtime-effective values are intentionally excluded.",
"",
"## Value Resolution Order",
"",
"```text",
" > ".join(reference["resolution"]["precedence"]),
"```",
"",
"Code defaults are fallback values only. Runtime process environment, remote settings, and dotenv values can override them.",
"",
]
for group in sorted(grouped):
lines.extend([f"## `{group}`", ""])
lines.extend(_render_group_applicability_notes(grouped[group]))
lines.append("| Name | Type | Default | Accepted Env Names | Description |")
lines.append("| --- | --- | --- | --- | --- |")
for variable in grouped[group]:
code_default = _render_code_default(variable["code_default"])
aliases = _markdown_code_cell(", ".join(variable["accepted_names"]))
description = _markdown_cell(_normalize_description(variable["description"]))
variable_type = _markdown_code_cell(variable["type"])
lines.append(
f"| `{variable['name']}` | {variable_type} | {code_default} | {aliases} | {description} |"
)
lines.append("")
return "\n".join(lines)
def write_backend_env_reference(
json_output: Path = _JSON_OUTPUT,
markdown_output: Path = _MARKDOWN_OUTPUT,
) -> tuple[Path, Path]:
reference = build_backend_env_reference()
json_output.parent.mkdir(parents=True, exist_ok=True)
markdown_output.parent.mkdir(parents=True, exist_ok=True)
json_output.write_text(json.dumps(reference, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
markdown_output.write_text(render_backend_env_reference_markdown(reference) + "\n", encoding="utf-8")
return json_output, markdown_output
def main() -> None:
json_output, markdown_output = write_backend_env_reference()
logger.info("Wrote %s", json_output.relative_to(_REPO_ROOT))
logger.info("Wrote %s", markdown_output.relative_to(_REPO_ROOT))
if __name__ == "__main__":
main()

View File

@ -70,32 +70,12 @@ 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:
"""
@ -134,10 +114,6 @@ 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)
@ -297,8 +273,6 @@ 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,10 +139,8 @@ 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 if dataset_keyword_table else "file"
keyword_data_source_type = dataset_keyword_table.data_source_type
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,5 +1,4 @@
import re
from collections.abc import Callable
from operator import itemgetter
from typing import cast
@ -81,14 +80,12 @@ 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 = cast(int | None, kwargs.pop("topK", top_k))
if top_k is None:
top_k = 20
top_k = kwargs.pop("topK", top_k)
cut = getattr(jieba, "cut", None)
if self._lcut:
tokens = self._lcut(sentence)
elif callable(cut):
tokens = list(cast(Callable[[str], list[str]], cut)(sentence))
tokens = list(cut(sentence))
else:
tokens = re.findall(r"\w+", sentence)
@ -111,7 +108,7 @@ class JiebaKeywordTableHandler:
sentence=text,
topK=max_keywords_per_chunk,
)
# jieba.analyse.extract_tags returns an untyped list when withFlag is False by default.
# jieba.analyse.extract_tags returns list[Any] 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 _ in concurrent.futures.as_completed(futures, timeout=3600):
for future in concurrent.futures.as_completed(futures, timeout=3600):
if exceptions:
for f in futures:
f.cancel()

View File

@ -94,7 +94,6 @@ 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:
@ -105,7 +104,6 @@ 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( # pyright: ignore[reportCallIssue, reportArgumentType]
result: LLMResult = model_instance.invoke_llm(
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 Set as AbstractSet
from collections.abc import Collection
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"] | AbstractSet[str] = frozenset(),
disallowed_special: Literal["all"] | AbstractSet[str] = "all",
allowed_special: Literal["all"] | set[str] = set(),
disallowed_special: Literal["all"] | Collection[str] = "all",
**kwargs: Any,
) -> T:
def _token_encoder(texts: list[str]) -> list[int]:
@ -40,7 +40,6 @@ 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,8 +4,7 @@ import copy
import logging
import re
from abc import ABC, abstractmethod
from collections.abc import Callable, Iterable, Sequence
from collections.abc import Set as AbstractSet
from collections.abc import Callable, Collection, Iterable, Sequence, Set
from dataclasses import dataclass
from typing import Any, Literal
@ -188,8 +187,8 @@ class TokenTextSplitter(TextSplitter):
self,
encoding_name: str = "gpt2",
model_name: str | None = None,
allowed_special: Literal["all"] | AbstractSet[str] = frozenset(),
disallowed_special: Literal["all"] | AbstractSet[str] = "all",
allowed_special: Literal["all"] | Set[str] = set(),
disallowed_special: Literal["all"] | Collection[str] = "all",
**kwargs: Any,
):
"""Create a new TextSplitter."""
@ -208,8 +207,8 @@ class TokenTextSplitter(TextSplitter):
else:
enc = tiktoken.get_encoding(encoding_name)
self._tokenizer = enc
self._allowed_special: Literal["all"] | AbstractSet[str] = allowed_special
self._disallowed_special: Literal["all"] | AbstractSet[str] = disallowed_special
self._allowed_special = allowed_special
self._disallowed_special = 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=False)
json_article: dict[str, Any] = simple_json_from_html_string(html, use_readability=True)
article = Article(
title=json_article.get("title") or "",
author=json_article.get("byline") or "",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,251 @@
import json
from configs.env_reference import (
build_backend_env_reference,
render_backend_env_reference_markdown,
)
def test_backend_env_reference_uses_backend_authority() -> None:
reference = build_backend_env_reference()
assert reference["authority"]["source_root"] == "api/configs"
assert reference["authority"]["model"] == "configs.app_config.DifyConfig"
assert reference["resolution"]["precedence"][-1] == "code_default"
def test_backend_env_reference_includes_aliases_and_defaults() -> None:
reference = build_backend_env_reference()
variables = {variable["name"]: variable for variable in reference["variables"]}
files_url = variables["FILES_URL"]
redis_host = variables["REDIS_HOST"]
assert "CONSOLE_API_URL" in files_url["accepted_names"]
assert redis_host["code_default"] == "localhost"
assert redis_host["group"] == "middleware.cache.redis"
assert "source_location" not in redis_host
assert "sensitive" not in redis_host
def test_backend_env_reference_excludes_computed_and_nested_fields() -> None:
reference = build_backend_env_reference()
names = {variable["name"] for variable in reference["variables"]}
assert "SQLALCHEMY_DATABASE_URI" not in names
assert "normalized_pubsub_redis_url" not in names
assert "project" not in names
def test_backend_env_reference_marks_provider_applicability() -> None:
reference = build_backend_env_reference()
variables = {variable["name"]: variable for variable in reference["variables"]}
assert variables["S3_ACCESS_KEY"]["applies_when"] == "STORAGE_TYPE=s3"
assert variables["STORAGE_LOCAL_PATH"]["applies_when"] == "STORAGE_TYPE=local"
def test_backend_env_reference_markdown_explains_code_default_scope() -> None:
reference = build_backend_env_reference()
markdown = render_backend_env_reference_markdown(reference)
assert "Deployment defaults, `.env.example`, and runtime-effective values are intentionally excluded." in markdown
assert "Code defaults are fallback values only." in markdown
assert "`REDIS_HOST`" in markdown
assert "| Name | Type | Default | Accepted Env Names | Description |" in markdown
assert "Code Default" not in markdown
assert "Required |" not in markdown
assert "Applies When |" not in markdown
assert "Source |" not in markdown
def test_backend_env_reference_markdown_normalizes_multiline_cells() -> None:
markdown = render_backend_env_reference_markdown(
{
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
"resolution": {"precedence": ["process_env", "code_default"]},
"variables": [
{
"name": "EXAMPLE_ENV",
"accepted_names": ["EXAMPLE_ENV", "EXAMPLE_ALIAS"],
"group": "test.group",
"type": "string | null",
"description": "line one\nline two | extra",
"code_default": "value\nwith newline",
"applies_when": "MODE=demo\nENABLED=true",
"required": False,
}
],
}
)
assert "line one line two \\| extra" in markdown
assert "> Applies when: `MODE=demo ENABLED=true`" in markdown
assert "`string \\| null`" in markdown
assert '`"value with newline"`' in markdown
assert "\nline two" not in markdown
def test_backend_env_reference_markdown_groups_partial_applicability_notes() -> None:
markdown = render_backend_env_reference_markdown(
{
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
"resolution": {"precedence": ["process_env", "code_default"]},
"variables": [
{
"name": "S3_ACCESS_KEY",
"accepted_names": ["S3_ACCESS_KEY"],
"group": "storage.s3",
"type": "string",
"description": "Access key",
"code_default": None,
"required": False,
"applies_when": "STORAGE_TYPE=s3",
},
{
"name": "S3_SECRET_KEY",
"accepted_names": ["S3_SECRET_KEY"],
"group": "storage.s3",
"type": "string",
"description": "Secret key",
"code_default": None,
"required": False,
"applies_when": "STORAGE_TYPE=s3",
},
{
"name": "STORAGE_ENDPOINT",
"accepted_names": ["STORAGE_ENDPOINT"],
"group": "storage.s3",
"type": "string | null",
"description": "Endpoint override",
"code_default": None,
"required": False,
"applies_when": None,
},
],
}
)
assert "Applies when:" in markdown
assert "- `S3_ACCESS_KEY`, `S3_SECRET_KEY`: `STORAGE_TYPE=s3`" in markdown
assert "Applies When |" not in markdown
def test_backend_env_reference_markdown_normalizes_awkward_descriptions() -> None:
markdown = render_backend_env_reference_markdown(
{
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
"resolution": {"precedence": ["process_env", "code_default"]},
"variables": [
{
"name": "ENTERPRISE_ENABLED",
"accepted_names": ["ENTERPRISE_ENABLED"],
"group": "enterprise.feature",
"type": "boolean",
"description": (
"Enable or disable enterprise-level features.Before using, please contact "
"business@dify.ai by email to inquire about licensing matters."
),
"code_default": False,
"required": False,
"applies_when": None,
},
{
"name": "FILES_URL",
"accepted_names": ["FILES_URL", "CONSOLE_API_URL"],
"group": "feature.file-access",
"type": "string",
"description": (
"Base URL for file preview or download, used for frontend display and "
"multi-model inputsUrl is signed and has expiration time."
),
"code_default": "",
"required": False,
"applies_when": None,
},
],
}
)
assert "features. Before using, please contact business@dify.ai" in markdown
assert "multi-model inputs. The URL is signed and has an expiration time." in markdown
def test_backend_env_reference_markdown_renders_missing_defaults_explicitly() -> None:
markdown = render_backend_env_reference_markdown(
{
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
"resolution": {"precedence": ["process_env", "code_default"]},
"variables": [
{
"name": "SENTRY_DSN",
"accepted_names": ["SENTRY_DSN"],
"group": "extra.sentry",
"type": "string | null",
"description": "Sentry DSN",
"code_default": None,
"required": False,
"applies_when": None,
}
],
}
)
row = '| `SENTRY_DSN` | `string \\| null` | `""` | `SENTRY_DSN` | Sentry DSN |'
assert row in markdown
assert row.count(" | ") == 4
def test_backend_env_reference_markdown_keeps_code_default_column_styling_consistent() -> None:
markdown = render_backend_env_reference_markdown(
{
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
"resolution": {"precedence": ["process_env", "code_default"]},
"variables": [
{
"name": "EMPTY_DEFAULT",
"accepted_names": ["EMPTY_DEFAULT"],
"group": "test.group",
"type": "string | null",
"description": "Empty default placeholder",
"code_default": None,
"required": False,
"applies_when": None,
},
{
"name": "STRING_DEFAULT",
"accepted_names": ["STRING_DEFAULT"],
"group": "test.group",
"type": "string",
"description": "Concrete string default",
"code_default": "value",
"required": False,
"applies_when": None,
},
],
}
)
assert '| `EMPTY_DEFAULT` | `string \\| null` | `""` | `EMPTY_DEFAULT` | Empty default placeholder |' in markdown
assert '| `STRING_DEFAULT` | `string` | `"value"` | `STRING_DEFAULT` | Concrete string default |' in markdown
def test_backend_env_reference_is_json_serializable() -> None:
reference = build_backend_env_reference()
rendered = json.dumps(reference)
assert '"schema_version": "1"' in rendered
assert '"resolution"' in rendered
assert '"source_location"' not in rendered
assert '"sensitive"' not in rendered

View File

@ -372,78 +372,6 @@ 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()

View File

@ -11,6 +11,7 @@
# If empty, it is the same domain.
# Example: https://api.console.dify.ai
CONSOLE_API_URL=
CONSOLE_API_URL_NEW=
# The front-end URL of the console web,
# used to concatenate some front-end addresses and for CORS configuration use.
@ -249,7 +250,7 @@ DB_TYPE=postgresql
DB_USERNAME=postgres
DB_PASSWORD=difyai123456
DB_HOST=db_postgres
DB_PORT=5432
DB_PORT=5433
DB_DATABASE=dify
# The size of the database connection pool.
@ -273,7 +274,7 @@ SQLALCHEMY_POOL_TIMEOUT=30
# Default is 100
#
# Reference: https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS
POSTGRES_MAX_CONNECTIONS=200
POSTGRES_MAX_CONNECTIONS=100
# Sets the amount of shared memory used for postgres's shared buffers.
# Default is 128MB
@ -342,7 +343,7 @@ MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT=2
# ------------------------------
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PORT=6390
REDIS_USERNAME=
REDIS_PASSWORD=difyai123456
REDIS_USE_SSL=false

View File

@ -6,6 +6,7 @@
x-shared-env: &shared-api-worker-env
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
CONSOLE_API_URL_NEW: ${CONSOLE_API_URL_NEW:-}
CONSOLE_WEB_URL: ${CONSOLE_WEB_URL:-}
SERVICE_API_URL: ${SERVICE_API_URL:-}
TRIGGER_URL: ${TRIGGER_URL:-http://localhost}
@ -61,7 +62,7 @@ x-shared-env: &shared-api-worker-env
DB_USERNAME: ${DB_USERNAME:-postgres}
DB_PASSWORD: ${DB_PASSWORD:-difyai123456}
DB_HOST: ${DB_HOST:-db_postgres}
DB_PORT: ${DB_PORT:-5432}
DB_PORT: ${DB_PORT:-5433}
DB_DATABASE: ${DB_DATABASE:-dify}
SQLALCHEMY_POOL_SIZE: ${SQLALCHEMY_POOL_SIZE:-30}
SQLALCHEMY_MAX_OVERFLOW: ${SQLALCHEMY_MAX_OVERFLOW:-10}
@ -70,7 +71,7 @@ x-shared-env: &shared-api-worker-env
SQLALCHEMY_POOL_PRE_PING: ${SQLALCHEMY_POOL_PRE_PING:-false}
SQLALCHEMY_POOL_USE_LIFO: ${SQLALCHEMY_POOL_USE_LIFO:-false}
SQLALCHEMY_POOL_TIMEOUT: ${SQLALCHEMY_POOL_TIMEOUT:-30}
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-200}
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-100}
POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-128MB}
POSTGRES_WORK_MEM: ${POSTGRES_WORK_MEM:-4MB}
POSTGRES_MAINTENANCE_WORK_MEM: ${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}
@ -82,7 +83,7 @@ x-shared-env: &shared-api-worker-env
MYSQL_INNODB_LOG_FILE_SIZE: ${MYSQL_INNODB_LOG_FILE_SIZE:-128M}
MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT: ${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_PORT: ${REDIS_PORT:-6390}
REDIS_USERNAME: ${REDIS_USERNAME:-}
REDIS_PASSWORD: ${REDIS_PASSWORD:-difyai123456}
REDIS_USE_SSL: ${REDIS_USE_SSL:-false}
@ -748,7 +749,7 @@ services:
# Use the shared environment variables.
<<: *shared-api-worker-env
# Startup mode, 'api' starts the API server.
MODE: api
MODE: apina
SENTRY_DSN: ${API_SENTRY_DSN:-}
SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0}
SENTRY_PROFILES_SAMPLE_RATE: ${API_SENTRY_PROFILES_SAMPLE_RATE:-1.0}

View File

@ -1,9 +1,4 @@
{
"e2e/cucumber.config.ts": {
"node/prefer-global/process": {
"count": 1
}
},
"e2e/features/support/hooks.ts": {
"no-console": {
"count": 3
@ -12,47 +7,16 @@
"count": 1
}
},
"e2e/fixtures/auth.ts": {
"node/prefer-global/process": {
"count": 5
}
},
"e2e/scripts/common.ts": {
"node/prefer-global/buffer": {
"count": 2
},
"node/prefer-global/process": {
"count": 8
}
},
"e2e/scripts/run-cucumber.ts": {
"node/prefer-global/process": {
"count": 9
}
},
"e2e/scripts/setup.ts": {
"node/prefer-global/process": {
"count": 4
}
},
"e2e/support/process.ts": {
"node/prefer-global/process": {
"count": 4
},
"ts/no-use-before-define": {
"count": 2
}
},
"e2e/test-env.ts": {
"node/prefer-global/process": {
"count": 7
}
},
"packages/dify-ui/vite.config.ts": {
"node/prefer-global/process": {
"count": 1
}
},
"packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/migrate.ts": {
"no-console": {
"count": 11
@ -165,6 +129,11 @@
"count": 3
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx": {
"no-restricted-imports": {
"count": 1
@ -1117,11 +1086,21 @@
"count": 1
}
},
"web/app/components/base/date-and-time-picker/date-picker/index.tsx": {
"react/set-state-in-effect": {
"count": 4
}
},
"web/app/components/base/date-and-time-picker/hooks.ts": {
"react/no-unnecessary-use-prefix": {
"count": 2
}
},
"web/app/components/base/date-and-time-picker/time-picker/index.tsx": {
"react/set-state-in-effect": {
"count": 2
}
},
"web/app/components/base/date-and-time-picker/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -1216,6 +1195,11 @@
"count": 5
}
},
"web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx": {
"no-restricted-imports": {
"count": 1
@ -1239,6 +1223,11 @@
"count": 2
}
},
"web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/features/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -1889,6 +1878,11 @@
"count": 1
}
},
"web/app/components/base/portal-to-follow-elem/index.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/prompt-editor/index.stories.tsx": {
"no-console": {
"count": 1
@ -1912,6 +1906,11 @@
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/context-block/component.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/context-block/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 3
@ -1941,6 +1940,11 @@
"count": 2
}
},
"web/app/components/base/prompt-editor/plugins/history-block/component.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/history-block/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 3
@ -1967,6 +1971,11 @@
"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
@ -2254,6 +2263,21 @@
"count": 3
}
},
"web/app/components/billing/usage-info/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/common/document-picker/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/common/document-picker/preview-document-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/common/image-previewer/index.tsx": {
"no-irregular-whitespace": {
"count": 1
@ -2870,6 +2894,14 @@
"count": 1
}
},
"web/app/components/datasets/settings/permission-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/no-missing-key": {
"count": 1
}
},
"web/app/components/datasets/settings/summary-index-setting.tsx": {
"no-restricted-imports": {
"count": 1
@ -3037,11 +3069,21 @@
"count": 1
}
},
"web/app/components/header/account-setting/api-based-extension-page/selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/header/account-setting/data-source-page-new/card.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/header/account-setting/data-source-page-new/configure.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/header/account-setting/data-source-page-new/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
@ -3125,6 +3167,19 @@
"count": 4
}
},
"web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": {
"no-restricted-imports": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx": {
"no-restricted-imports": {
"count": 1
@ -3356,6 +3411,11 @@
"count": 1
}
},
"web/app/components/plugins/marketplace/search-box/tags-filter.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": {
"ts/no-explicit-any": {
"count": 2
@ -3387,6 +3447,14 @@
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorized/index.tsx": {
"no-restricted-imports": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/plugins/plugin-auth/authorized/item.tsx": {
"no-restricted-imports": {
"count": 1
@ -3442,6 +3510,16 @@
"count": 8
}
},
"web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3635,6 +3713,11 @@
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
"ts/no-explicit-any": {
"count": 5
@ -3673,6 +3756,16 @@
"count": 2
}
},
"web/app/components/plugins/plugin-page/filter-management/category-filter.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-page/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -3825,6 +3918,11 @@
"count": 1
}
},
"web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -4113,7 +4211,15 @@
"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
},
@ -4122,6 +4228,9 @@
}
},
"web/app/components/workflow/block-selector/featured-triggers.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 2
},
@ -4139,6 +4248,11 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/main.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/market-place-plugin/action.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -4154,6 +4268,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
@ -4164,6 +4283,11 @@
"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
@ -4175,6 +4299,9 @@
}
},
"web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -4251,6 +4378,19 @@
"count": 1
}
},
"web/app/components/workflow/header/view-history.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/workflow/header/view-workflow-history.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/hooks-store/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
@ -4913,6 +5053,11 @@
"count": 5
}
},
"web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -5161,6 +5306,16 @@
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/default.ts": {
"ts/no-explicit-any": {
"count": 1
@ -5259,6 +5414,17 @@
"count": 2
}
},
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 2
}
},
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx": {
"no-restricted-imports": {
"count": 1
@ -5705,11 +5871,24 @@
"count": 1
}
},
"web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
}
},
"web/app/components/workflow/note-node/note-editor/toolbar/command.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/note-node/note-editor/utils.ts": {
"regexp/no-useless-quantifier": {
"count": 1
@ -5833,6 +6012,11 @@
"count": 1
}
},
"web/app/components/workflow/panel/version-history-panel/filter/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx": {
"no-restricted-imports": {
"count": 1

View File

@ -1,3 +1,5 @@
// @ts-check
import antfu, { GLOB_MARKDOWN } from '@antfu/eslint-config'
import md from 'eslint-markdown'
import markdownPreferences from 'eslint-plugin-markdown-preferences'
@ -15,13 +17,6 @@ export default antfu(
'!vite.config.ts',
...original,
],
react: {
files: ['packages/dify-ui/**/*.{ts,tsx}'],
overrides: {
'react/set-state-in-effect': 'error',
'react/no-unnecessary-use-prefix': 'error',
},
},
typescript: {
overrides: {
'ts/consistent-type-definitions': ['error', 'type'],
@ -40,12 +35,7 @@ export default antfu(
'antfu/top-level-function': 'off',
},
},
},
{
files: ['packages/dify-ui/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
},
e18e: false,
},
markdownPreferences.configs.standard,
{
@ -67,4 +57,9 @@ export default antfu(
'markdown-preferences/sort-definitions': 'error',
},
},
{
rules: {
'node/prefer-global/process': 'off',
},
},
)

View File

@ -11,27 +11,6 @@ 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.
@ -55,5 +34,3 @@ 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,10 +49,6 @@
"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.\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.',
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.',
},
},
},
@ -101,48 +101,6 @@ 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

@ -1,127 +0,0 @@
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

@ -1,213 +0,0 @@
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

@ -1,81 +0,0 @@
'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

@ -137,7 +137,7 @@ describe('@langgenius/dify-ui/toast', () => {
await vi.advanceTimersByTimeAsync(4999)
expect(document.body).toHaveTextContent('Default timeout')
await vi.advanceTimersByTimeAsync(10)
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Default timeout')
})
@ -152,7 +152,7 @@ describe('@langgenius/dify-ui/toast', () => {
await vi.advanceTimersByTimeAsync(2999)
expect(document.body).toHaveTextContent('Configured timeout')
await vi.advanceTimersByTimeAsync(10)
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Configured timeout')
})
@ -166,7 +166,7 @@ describe('@langgenius/dify-ui/toast', () => {
})
await expect.element(screen.getByText('Custom timeout')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(1010)
await vi.advanceTimersByTimeAsync(1000)
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Custom timeout')
})

View File

@ -136,7 +136,7 @@ const StackExamples = () => {
const PromiseExamples = () => {
const createPromiseToast = () => {
const request = new Promise<string>((resolve) => {
window.setTimeout(resolve, 1400, 'The deployment is now available in production.')
window.setTimeout(() => resolve('The deployment is now available in production.'), 1400)
})
void toast.promise(request, {

View File

@ -81,7 +81,7 @@ type ToastApi = {
const toastManager = BaseToast.createToastManager<ToastData>()
function isToastType(type: string): type is ToastType {
return Object.hasOwn(TOAST_TONE_STYLES, type)
return Object.prototype.hasOwnProperty.call(TOAST_TONE_STYLES, type)
}
function getToastType(type?: string): ToastType | undefined {

View File

@ -46,7 +46,20 @@ describe('TooltipContent', () => {
})
})
describe('Popup props', () => {
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')
})
it('should forward popup props and handlers when popup props are provided', async () => {
const onMouseEnter = vi.fn()
@ -70,11 +83,7 @@ describe('TooltipContent', () => {
await expect.element(popup).toHaveAttribute('id', 'tooltip-popup-id')
await expect.element(popup).toHaveAttribute('data-track-id', 'tooltip-track')
// 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()
expect(onMouseEnter).toHaveBeenCalledTimes(1)
})
it('should apply className to the popup and positionerClassName to the positioner', async () => {

View File

@ -8,8 +8,8 @@ import {
TooltipTrigger,
} from '.'
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 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 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 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.',
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.',
},
},
},
@ -35,58 +35,47 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
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 IconButton: Story = {
name: 'Icon button (canonical)',
parameters: {
docs: {
description: {
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.',
},
},
},
export const Default: Story = {
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>
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Hover me
</TooltipTrigger>
<TooltipContent>
Tooltips describe interactive elements without a click.
</TooltipContent>
</Tooltip>
),
}
export const KeyboardShortcut: Story = {
export const Plain: 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.',
story: 'Use `variant="plain"` to render the popup without default chrome (background, padding, typography). Apply your own styling via `className` on `TooltipContent`.',
},
},
},
render: () => (
<Tooltip>
<TooltipTrigger
render={(
<button type="button" className={triggerButtonClassName}>
Save
</button>
)}
/>
<TooltipContent>S</TooltipContent>
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>
</Tooltip>
),
}
@ -127,10 +116,14 @@ const PlacementsDemo = () => {
</div>
<Tooltip open>
<TooltipTrigger
render={<button type="button" aria-label="Placement anchor" className={iconButtonClassName}><span aria-hidden className="i-ri-pushpin-line h-4 w-4" /></button>}
/>
render={<button type="button" className={triggerButtonClassName} />}
>
Anchor
</TooltipTrigger>
<TooltipContent placement={placement}>
{`placement="${placement}"`}
placement="
{placement}
"
</TooltipContent>
</Tooltip>
</div>
@ -140,45 +133,113 @@ 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', delay: 0 },
{ label: 'Fast', delay: 150 },
{ label: 'Default', delay: 600 },
{ label: 'Instant (0ms)', delay: 0 },
{ label: 'Fast (150ms)', delay: 150 },
{ label: 'Default (600ms)', delay: 600 },
]
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>
)
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>
)
}
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. The Dify app root sets `delay={300} closeDelay={200}` — override locally only when the surrounding UX demands it.',
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.',
},
},
},

View File

@ -8,28 +8,7 @@ import { parsePlacement } from '../placement'
export type { Placement }
/**
* 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 TooltipContentVariant = 'default' | 'plain'
type TooltipContentProps = {
children: ReactNode
@ -38,6 +17,7 @@ type TooltipContentProps = {
alignOffset?: number
positionerClassName?: string
className?: string
variant?: TooltipContentVariant
} & Omit<BaseTooltip.Popup.Props, 'children' | 'className'>
export function TooltipContent({
@ -47,6 +27,7 @@ export function TooltipContent({
alignOffset = 0,
positionerClassName,
className,
variant = 'default',
...props
}: TooltipContentProps) {
const { side, align } = parsePlacement(placement)
@ -62,7 +43,7 @@ export function TooltipContent({
>
<BaseTooltip.Popup
className={cn(
'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',
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',
'origin-(--transform-origin) transition-opacity data-ending-style:opacity-0 data-instant:transition-none data-starting-style:opacity-0 motion-reduce:transition-none',
className,
)}
@ -74,3 +55,7 @@ export function TooltipContent({
</BaseTooltip.Portal>
)
}
export const TooltipProvider = BaseTooltip.Provider
export const Tooltip = BaseTooltip.Root
export const TooltipTrigger = BaseTooltip.Trigger

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

@ -1,127 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '../base-ui-popover'
type PopoverHarnessProps = {
useRenderElement?: boolean
preventDefaultOnTrigger?: boolean
}
const PopoverHarness = ({
useRenderElement = false,
preventDefaultOnTrigger = false,
}: PopoverHarnessProps) => {
const [open, setOpen] = React.useState(false)
return (
<div>
<div data-testid="outside-area">outside</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={useRenderElement
? (
<button
type="button"
data-testid="custom-trigger"
onClick={(event) => {
if (preventDefaultOnTrigger)
event.preventDefault()
}}
>
toggle
</button>
)
: undefined}
>
fallback trigger
</PopoverTrigger>
<PopoverContent
className="custom-content"
placement="bottom-start"
sideOffset={4}
alignOffset={8}
positionerProps={{ 'data-positioner': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>}
popupProps={{ 'data-popup': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>}
>
<div>popover body</div>
</PopoverContent>
</Popover>
<div data-testid="open-state">{open ? 'open' : 'closed'}</div>
</div>
)
}
describe('base-ui-popover mock', () => {
it('should toggle popover content from the fallback trigger and expose content props', () => {
render(<PopoverHarness />)
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('popover-trigger'))
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-placement', 'bottom-start')
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-side-offset', '4')
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-align-offset', '8')
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-positioner', 'true')
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-popup', 'true')
expect(screen.getByTestId('popover-content')).toHaveClass('custom-content')
})
it('should keep the popover open on inside clicks and close it on outside clicks or escape', () => {
render(<PopoverHarness useRenderElement />)
fireEvent.click(screen.getByTestId('custom-trigger'))
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
fireEvent.mouseDown(screen.getByTestId('popover-content'))
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
fireEvent.click(screen.getByTestId('custom-trigger'))
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
fireEvent.mouseDown(screen.getByTestId('outside-area'))
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
})
it('should preserve rendered trigger props and respect preventDefault', () => {
render(<PopoverHarness useRenderElement preventDefaultOnTrigger />)
fireEvent.click(screen.getByTestId('custom-trigger'))
expect(screen.getByTestId('custom-trigger')).toHaveAttribute('data-popover-trigger', 'true')
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
it('should keep the popover closed when the fallback trigger click is prevented', () => {
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault()
}
render(
<div>
<Popover open={false} onOpenChange={vi.fn()}>
<PopoverTrigger onClick={handleClick}>
fallback trigger
</PopoverTrigger>
<PopoverContent>
<div>popover body</div>
</PopoverContent>
</Popover>
</div>,
)
fireEvent.click(screen.getByTestId('popover-trigger'))
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
})

View File

@ -1,154 +0,0 @@
import type { ReactNode } from 'react'
import * as React from 'react'
const PopoverContext = React.createContext({
open: false,
onOpenChange: (_open: boolean) => {},
})
type PopoverProps = {
children?: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
type PopoverTriggerProps = React.HTMLAttributes<HTMLElement> & {
children?: ReactNode
nativeButton?: boolean
render?: React.ReactElement
}
type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & {
children?: ReactNode
placement?: string
sideOffset?: number
alignOffset?: number
positionerProps?: React.HTMLAttributes<HTMLDivElement>
popupProps?: React.HTMLAttributes<HTMLDivElement>
}
export const Popover = ({
children,
open = false,
onOpenChange,
}: PopoverProps) => {
React.useEffect(() => {
if (!open)
return
const handleMouseDown = (event: MouseEvent) => {
const target = event.target as Element | null
if (target?.closest?.('[data-popover-trigger="true"], [data-popover-content="true"]'))
return
onOpenChange?.(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape')
onOpenChange?.(false)
}
document.addEventListener('mousedown', handleMouseDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleMouseDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open, onOpenChange])
return (
<PopoverContext.Provider value={{
open,
onOpenChange: onOpenChange ?? (() => {}),
}}
>
<div data-testid="popover" data-open={String(open)}>
{children}
</div>
</PopoverContext.Provider>
)
}
export const PopoverTrigger = ({
children,
render,
nativeButton: _nativeButton,
onClick,
...props
}: PopoverTriggerProps) => {
const { open, onOpenChange } = React.useContext(PopoverContext)
const node = render ?? children
if (React.isValidElement(node)) {
const triggerElement = node as React.ReactElement<Record<string, unknown>>
const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string }
return React.cloneElement(triggerElement, {
...props,
...childProps,
'data-testid': childProps['data-testid'] ?? 'popover-trigger',
'data-popover-trigger': 'true',
'onClick': (event: React.MouseEvent<HTMLElement>) => {
childProps.onClick?.(event)
onClick?.(event)
if (event.defaultPrevented)
return
onOpenChange(!open)
},
})
}
return (
<div
data-testid="popover-trigger"
data-popover-trigger="true"
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented)
return
onOpenChange(!open)
}}
{...props}
>
{node}
</div>
)
}
export const PopoverContent = ({
children,
className,
placement,
sideOffset,
alignOffset,
positionerProps,
popupProps,
...props
}: PopoverContentProps) => {
const { open } = React.useContext(PopoverContext)
if (!open)
return null
return (
<div
data-testid="popover-content"
data-popover-content="true"
data-placement={placement}
data-side-offset={sideOffset}
data-align-offset={alignOffset}
className={className}
{...positionerProps}
{...popupProps}
{...props}
>
{children}
</div>
)
}
export const PopoverClose = ({ children }: { children?: ReactNode }) => <>{children}</>
export const PopoverTitle = ({ children }: { children?: ReactNode }) => <>{children}</>
export const PopoverDescription = ({ children }: { children?: ReactNode }) => <>{children}</>

View File

@ -3,13 +3,13 @@ import type { FC } from 'react'
import type { PopupProps } from './config-popup'
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 { useCallback, useRef, useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import ConfigPopup from './config-popup'
type Props = {
@ -25,31 +25,36 @@ const ConfigBtn: FC<Props> = ({
children,
...popupProps
}) => {
const [open, setOpen] = useState(false)
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
if (popupProps.readOnly && !hasConfigured)
return null
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={12}
>
<PopoverTrigger
render={(
<div className={cn('select-none', className)}>
{children}
</div>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={12}
popupClassName="border-none bg-transparent shadow-none"
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div className={cn('select-none', className)}>
{children}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<ConfigPopup {...popupProps} />
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(ConfigBtn)

View File

@ -43,7 +43,7 @@ vi.mock('../form-fields', () => ({
>
invalid-name-change
</button>
<button data-testid="valid-json-change" onClick={() => props.onJSONSchemaChange('{\n "foo": "bar"\n}')}>valid-json-change</button>
<button data-testid="valid-json-change" onClick={() => props.onJSONSchemaChange('{\n \"foo\": \"bar\"\n}')}>valid-json-change</button>
<button data-testid="empty-json-change" onClick={() => props.onJSONSchemaChange(' ')}>empty-json-change</button>
<button data-testid="invalid-json-change" onClick={() => props.onJSONSchemaChange('{invalid-json}')}>invalid-json-change</button>
<button data-testid="type-change" onClick={() => props.onTypeChange({ value: InputVarType.singleFile })}>type-change</button>

View File

@ -1,4 +1,3 @@
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiChatSettingsLine,
} from '@remixicon/react'
@ -7,29 +6,30 @@ import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
const ViewFormDropdown = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 4,
}}
>
<PopoverTrigger
render={(
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
<RiChatSettingsLine className="h-[18px] w-[18px]" />
</ActionButton>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
alignOffset={4}
popupClassName="border-none bg-transparent shadow-none"
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
>
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
<RiChatSettingsLine className="h-[18px] w-[18px]" />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs">
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
<Message3Fill className="h-6 w-6 shrink-0" />
@ -39,8 +39,8 @@ const ViewFormDropdown = () => {
<InputsFormContent />
</div>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -6,8 +6,6 @@ import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
import Popup from '../popup'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentDownload: vi.fn(),
}))

View File

@ -1,9 +1,13 @@
import type { FC, MouseEvent } from 'react'
import type { Resources } from './index'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Fragment, useState } from 'react'
import { useTranslation } from 'react-i18next'
import FileIcon from '@/app/components/base/file-icon'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Link from '@/next/link'
import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
@ -43,25 +47,22 @@ const Popup: FC<PopupProps> = ({
}
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="top-start"
offset={{
mainAxis: 8,
crossAxis: -2,
}}
>
<PopoverTrigger
nativeButton={false}
render={(
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
</div>
)}
/>
<PopoverContent
placement="top-start"
sideOffset={8}
alignOffset={-2}
popupClassName="border-none bg-transparent shadow-none"
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
<div data-testid="popup-content" className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
<div className="px-4 pt-3 pb-2">
<div className="flex h-[18px] items-center">
@ -155,8 +156,8 @@ const Popup: FC<PopupProps> = ({
</div>
</div>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -1,10 +1,10 @@
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 ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
type Props = {
iconColor?: string
@ -17,27 +17,25 @@ const ViewFormDropdown = ({
const [open, setOpen] = useState(false)
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 4,
}}
>
<PopoverTrigger
render={(
<ActionButton
size="l"
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
data-testid="view-form-dropdown-trigger"
>
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
</ActionButton>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
alignOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<ActionButton
size="l"
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
data-testid="view-form-dropdown-trigger"
>
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-99">
<div
data-testid="view-form-dropdown-content"
className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs"
@ -50,8 +48,8 @@ const ViewFormDropdown = ({
<InputsFormContent />
</div>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -3,20 +3,6 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'
import dayjs from '../../utils/dayjs'
import DatePicker from '../index'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick, disabled, className }: {
children?: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
}) => (
<button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}>
{children}
</button>
),
}))
// Mock scrollIntoView
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
@ -127,13 +113,14 @@ describe('DatePicker', () => {
render(<DatePicker {...props} />)
openPicker()
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true')
// Simulate a mousedown event outside the container
act(() => {
document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
})
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
// The picker should now be closed - input shows its value
// The picker should now be closed - input shows its value
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
})

View File

@ -1,10 +1,14 @@
import type { Dayjs } from 'dayjs'
import type { DatePickerProps, Period } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Calendar from '../calendar'
import TimePickerHeader from '../time-picker/header'
import TimePickerOptions from '../time-picker/options'
@ -31,14 +35,15 @@ const DatePicker = ({
needTimePicker = true,
renderTrigger,
triggerWrapClassName,
popupZIndexClassname,
popupZIndexClassname = 'z-11',
noConfirm,
getIsDateDisabled,
}: DatePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [view, setView] = useState(ViewType.date)
const isInitialRef = useRef(true)
const containerRef = useRef<HTMLDivElement>(null)
const isInitial = useRef(true)
// Normalize the value to ensure that all subsequent uses are Day.js objects.
const normalizedValue = useMemo(() => {
@ -57,41 +62,46 @@ const DatePicker = ({
const [selectedYear, setSelectedYear] = useState(() => (inputValue || defaultValue).year())
useEffect(() => {
if (isInitialRef.current) {
isInitialRef.current = false
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
setView(ViewType.date)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
useEffect(() => {
if (isInitial.current) {
isInitial.current = false
return
}
clearMonthMapCache()
if (normalizedValue) {
const newValue = getDateWithTimezone({ date: normalizedValue, timezone })
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state.
setCurrentDate(newValue)
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value.
setSelectedDate(newValue)
onChange(newValue)
}
else {
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state.
setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone }))
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value.
setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
}
// eslint-disable-next-line react/exhaustive-deps -- this effect intentionally runs only when timezone changes.
}, [timezone])
const handleOpenChange = useCallback((nextOpen: boolean) => {
setIsOpen(nextOpen)
const handleClickTrigger = (e: React.MouseEvent) => {
e.stopPropagation()
if (isOpen) {
setIsOpen(false)
return
}
setView(ViewType.date)
if (nextOpen && normalizedValue) {
setIsOpen(true)
if (normalizedValue) {
setCurrentDate(normalizedValue)
setSelectedDate(normalizedValue)
}
}, [normalizedValue])
const handleClickTrigger = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
handleOpenChange(!isOpen)
}
const handleClear = (e: React.MouseEvent) => {
@ -200,21 +210,21 @@ const DatePicker = ({
const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('defaultPlaceholder', { ns: 'time' }))
return (
<Popover
<PortalToFollowElem
open={isOpen}
onOpenChange={handleOpenChange}
onOpenChange={setIsOpen}
placement="bottom-end"
>
<PopoverTrigger
nativeButton={false}
className={triggerWrapClassName}
render={renderTrigger
? renderTrigger({
value: normalizedValue,
selectedDate,
isOpen,
handleClear,
handleClickTrigger,
})
<PortalToFollowElemTrigger className={triggerWrapClassName}>
{renderTrigger
? (
renderTrigger({
value: normalizedValue,
selectedDate,
isOpen,
handleClear,
handleClickTrigger,
}))
: (
<div
className="group flex w-[252px] cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt"
@ -232,13 +242,8 @@ const DatePicker = ({
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block hover:text-text-secondary')} onClick={handleClear} data-testid="date-picker-clear-button" />
</div>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={0}
className={popupZIndexClassname}
popupClassName="border-none bg-transparent shadow-none"
>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={popupZIndexClassname}>
<div className="mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5">
{/* Header */}
{view === ViewType.date
@ -314,8 +319,8 @@ const DatePicker = ({
)
}
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -3,20 +3,6 @@ import { fireEvent, render, screen, within } from '@testing-library/react'
import dayjs, { isDayjsObject } from '../../utils/dayjs'
import TimePicker from '../index'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick, disabled, className }: {
children?: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
}) => (
<button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}>
{children}
</button>
),
}))
// Mock scrollIntoView since the test DOM runtime doesn't implement it
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
@ -120,7 +106,7 @@ describe('TimePicker', () => {
expect(input)!.toHaveValue('')
fireEvent.mouseDown(document.body)
expect(input)!.toHaveValue('10:00 AM')
expect(input)!.toHaveValue('')
})
it('should call onClear when clear is clicked while picker is closed', () => {

View File

@ -1,10 +1,14 @@
import type { Dayjs } from 'dayjs'
import type { TimePickerProps } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import TimezoneLabel from '@/app/components/base/timezone-label'
import { Period } from '../types'
import dayjs, {
@ -39,20 +43,31 @@ const TimePicker = ({
}: TimePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const isInitialRef = useRef(true)
const containerRef = useRef<HTMLDivElement>(null)
const isInitial = useRef(true)
// Initialize selectedTime
const [selectedTime, setSelectedTime] = useState(() => {
return toDayjs(value, { timezone })
})
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
/* v8 ignore next 2 -- outside-click closing is handled by PortalToFollowElem; this local ref guard is a defensive fallback. */
if (containerRef.current && !containerRef.current.contains(event.target as Node))
setIsOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Track previous values to avoid unnecessary updates
const prevValueRef = useRef(value)
const prevTimezoneRef = useRef(timezone)
useEffect(() => {
if (isInitialRef.current) {
isInitialRef.current = false
if (isInitial.current) {
isInitial.current = false
// Save initial values on first render
prevValueRef.current = value
prevTimezoneRef.current = timezone
@ -76,7 +91,6 @@ const TimePicker = ({
if (!dayjsValue)
return
// eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time.
setSelectedTime(dayjsValue)
if (timezoneChanged && !valueChanged)
@ -84,7 +98,6 @@ const TimePicker = ({
return
}
// eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time.
setSelectedTime((prev) => {
if (!isDayjsObject(prev))
return undefined
@ -92,30 +105,24 @@ const TimePicker = ({
})
}, [timezone, value, onChange])
const syncSelectedTimeFromValue = useCallback(() => {
if (!value)
return
const dayjsValue = toDayjs(value, { timezone })
const needsUpdate = dayjsValue && (
!selectedTime
|| !isDayjsObject(selectedTime)
|| !dayjsValue.isSame(selectedTime, 'minute')
)
if (needsUpdate)
setSelectedTime(dayjsValue)
}, [selectedTime, timezone, value])
const handleOpenChange = useCallback((nextOpen: boolean) => {
setIsOpen(nextOpen)
if (nextOpen)
syncSelectedTimeFromValue()
}, [syncSelectedTimeFromValue])
const handleClickTrigger = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
handleOpenChange(!isOpen)
if (isOpen) {
setIsOpen(false)
return
}
setIsOpen(true)
if (value) {
const dayjsValue = toDayjs(value, { timezone })
const needsUpdate = dayjsValue && (
!selectedTime
|| !isDayjsObject(selectedTime)
|| !dayjsValue.isSame(selectedTime, 'minute')
)
if (needsUpdate)
setSelectedTime(dayjsValue)
}
}
const handleClear = (e: React.MouseEvent) => {
@ -125,7 +132,7 @@ const TimePicker = ({
onClear()
}
const handleTimeSelect = useCallback((hour: string, minute: string, period: Period) => {
const handleTimeSelect = (hour: string, minute: string, period: Period) => {
const periodAdjustedHour = to24Hour(hour, period)
const nextMinute = Number.parseInt(minute, 10)
setSelectedTime((prev) => {
@ -138,7 +145,7 @@ const TimePicker = ({
.set('second', 0)
.set('millisecond', 0)
})
}, [timezone])
}
const getSafeTimeObject = useCallback(() => {
if (isDayjsObject(selectedTime))
@ -149,17 +156,17 @@ const TimePicker = ({
const handleSelectHour = useCallback((hour: string) => {
const time = getSafeTimeObject()
handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period)
}, [getSafeTimeObject, handleTimeSelect])
}, [getSafeTimeObject])
const handleSelectMinute = useCallback((minute: string) => {
const time = getSafeTimeObject()
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period)
}, [getSafeTimeObject, handleTimeSelect])
}, [getSafeTimeObject])
const handleSelectPeriod = useCallback((period: Period) => {
const time = getSafeTimeObject()
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period)
}, [getSafeTimeObject, handleTimeSelect])
}, [getSafeTimeObject])
const handleSelectCurrentTime = useCallback(() => {
const newDate = getDateWithTimezone({ timezone })
@ -200,19 +207,18 @@ const TimePicker = ({
/>
)
return (
<Popover
<PortalToFollowElem
open={isOpen}
onOpenChange={handleOpenChange}
onOpenChange={setIsOpen}
placement={placement}
>
<PopoverTrigger
nativeButton={false}
className={triggerFullWidth ? 'block! w-full' : undefined}
render={renderTrigger
? renderTrigger({
<PortalToFollowElemTrigger className={triggerFullWidth ? 'block! w-full' : undefined}>
{renderTrigger
? (renderTrigger({
inputElem,
onClick: handleClickTrigger,
isOpen,
})
}))
: (
<div
className={cn(
@ -230,13 +236,8 @@ const TimePicker = ({
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:inline-block hover:text-text-secondary')} role="button" aria-label={t('operation.clear', { ns: 'common' })} onClick={handleClear} />
</div>
)}
/>
<PopoverContent
placement={placement}
sideOffset={0}
className={popupClassName}
popupClassName="border-none bg-transparent shadow-none"
>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={cn('z-50', popupClassName)}>
<div className="mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5">
{/* Header */}
<Header title={title} />
@ -257,8 +258,8 @@ const TimePicker = ({
/>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -28,7 +28,7 @@ export type DatePickerProps = {
onChange: (date: Dayjs | undefined) => void
onClear: () => void
triggerWrapClassName?: string
renderTrigger?: (props: TriggerProps) => React.ReactElement
renderTrigger?: (props: TriggerProps) => React.ReactNode
minuteFilter?: (minutes: string[]) => string[]
popupZIndexClassname?: string
noConfirm?: boolean
@ -62,7 +62,7 @@ export type TimePickerProps = {
placeholder?: string
onChange: (date: Dayjs | undefined) => void
onClear: () => void
renderTrigger?: (props: TriggerParams) => React.ReactElement
renderTrigger?: (props: TriggerParams) => React.ReactNode
title?: string
minuteFilter?: (minutes: string[]) => string[]
popupClassName?: string

View File

@ -61,7 +61,7 @@ describe('FileUploadSettings (setting-modal)', () => {
})
})
it('should call onOpen with true when trigger is clicked', () => {
it('should call onOpen with toggle function when trigger is clicked', () => {
const onOpen = vi.fn()
renderWithProvider(
<FileUploadSettings open={false} onOpen={onOpen}>
@ -71,7 +71,12 @@ describe('FileUploadSettings (setting-modal)', () => {
fireEvent.click(screen.getByText('Upload Settings'))
expect(onOpen).toHaveBeenCalledWith(true)
expect(onOpen).toHaveBeenCalled()
// The toggle function should flip the open state
const toggleFn = onOpen.mock.calls[0]![0]
expect(typeof toggleFn).toBe('function')
expect(toggleFn(false)).toBe(true)
expect(toggleFn(true)).toBe(false)
})
it('should not call onOpen when disabled', () => {

View File

@ -1,16 +1,16 @@
'use client'
import type { OnFeaturesChange } from '@/app/components/base/features/types'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { memo } from 'react'
import SettingContent from '@/app/components/base/features/new-feature-panel/file-upload/setting-content'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type FileUploadSettingsProps = {
open: boolean
onOpen: (state: boolean) => void
onOpen: (state: any) => void
onChange?: OnFeaturesChange
disabled?: boolean
children?: React.ReactNode
@ -25,27 +25,18 @@ const FileUploadSettings = ({
imageUpload,
}: FileUploadSettingsProps) => {
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={(nextOpen) => {
if (disabled)
return
onOpen(nextOpen)
onOpenChange={onOpen}
placement="left"
offset={{
mainAxis: 32,
}}
>
<PopoverTrigger
nativeButton={false}
render={(
<div className="flex">
{children}
</div>
)}
/>
<PopoverContent
placement="left"
sideOffset={32}
popupClassName="border-none bg-transparent shadow-none"
>
<PortalToFollowElemTrigger className="flex" onClick={() => !disabled && onOpen((open: boolean) => !open)}>
{children}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 50 }}>
<div className="max-h-[calc(100vh-20px)] w-[360px] overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-2xl">
<SettingContent
imageUpload={imageUpload}
@ -56,8 +47,8 @@ const FileUploadSettings = ({
}}
/>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(FileUploadSettings)

View File

@ -1,17 +1,38 @@
import type { ReactNode } from 'react'
import type { Features } from '../../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { FeaturesProvider } from '../../../context'
import VoiceSettings from '../voice-settings'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
placement,
offset,
}: {
children: React.ReactNode
placement?: string
offset?: { mainAxis?: number }
}) => (
<div
data-testid="voice-settings-portal"
data-placement={placement}
data-main-axis={offset?.mainAxis}
>
{children}
</div>
),
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<div data-testid="voice-settings-trigger" onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/next/navigation', () => ({
@ -25,25 +46,6 @@ vi.mock('@/service/use-apps', () => ({
}),
}))
vi.mock('@langgenius/dify-ui/switch', () => ({
Switch: ({
checked,
onCheckedChange,
...props
}: {
checked?: boolean
onCheckedChange?: (checked: boolean) => void
}) => (
<button
type="button"
data-testid="switch"
data-checked={String(checked)}
onClick={() => onCheckedChange?.(!checked)}
{...props}
/>
),
}))
const defaultFeatures: Features = {
moreLikeThis: { enabled: false },
opening: { enabled: false },
@ -56,7 +58,7 @@ const defaultFeatures: Features = {
annotationReply: { enabled: false },
}
const renderWithProvider = (ui: ReactNode) => {
const renderWithProvider = (ui: React.ReactNode) => {
return render(
<FeaturesProvider features={defaultFeatures}>
{ui}
@ -99,7 +101,12 @@ describe('VoiceSettings', () => {
fireEvent.click(screen.getByText('Settings'))
expect(onOpen).toHaveBeenCalledWith(true)
expect(onOpen).toHaveBeenCalled()
// The toggle function should flip the open state
const toggleFn = onOpen.mock.calls[0]![0]
expect(typeof toggleFn).toBe('function')
expect(toggleFn(false)).toBe(true)
expect(toggleFn(true)).toBe(false)
})
it('should not call onOpen when disabled and trigger is clicked', () => {
@ -130,13 +137,16 @@ describe('VoiceSettings', () => {
it('should use top placement and mainAxis 4 when placementLeft is false', () => {
renderWithProvider(
<VoiceSettings open={true} onOpen={vi.fn()} placementLeft={false}>
<VoiceSettings open={false} onOpen={vi.fn()} placementLeft={false}>
<button>Settings</button>
</VoiceSettings>,
)
const content = screen.getByTestId('popover-content')
expect(content).toHaveAttribute('data-placement', 'top')
expect(content).toHaveAttribute('data-side-offset', '4')
const portal = screen.getAllByTestId('voice-settings-portal')
.find(item => item.hasAttribute('data-main-axis'))
expect(portal).toBeDefined()
expect(portal)!.toHaveAttribute('data-placement', 'top')
expect(portal)!.toHaveAttribute('data-main-axis', '4')
})
})

View File

@ -1,16 +1,16 @@
'use client'
import type { OnFeaturesChange } from '@/app/components/base/features/types'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { memo } from 'react'
import ParamConfigContent from '@/app/components/base/features/new-feature-panel/text-to-speech/param-config-content'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type VoiceSettingsProps = {
open: boolean
onOpen: (state: boolean) => void
onOpen: (state: any) => void
onChange?: OnFeaturesChange
disabled?: boolean
children?: React.ReactNode
@ -25,32 +25,23 @@ const VoiceSettings = ({
placementLeft = true,
}: VoiceSettingsProps) => {
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={(nextOpen) => {
if (disabled)
return
onOpen(nextOpen)
onOpenChange={onOpen}
placement={placementLeft ? 'left' : 'top'}
offset={{
mainAxis: placementLeft ? 32 : 4,
}}
>
<PopoverTrigger
nativeButton={false}
render={(
<div className="flex">
{children}
</div>
)}
/>
<PopoverContent
placement={placementLeft ? 'left' : 'top'}
sideOffset={placementLeft ? 32 : 4}
popupClassName="border-none bg-transparent shadow-none"
>
<PortalToFollowElemTrigger className="flex" onClick={() => !disabled && onOpen((open: boolean) => !open)}>
{children}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 50 }}>
<div className="w-[360px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-2xl">
<ParamConfigContent onClose={() => onOpen(false)} onChange={onChange} />
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(VoiceSettings)

View File

@ -1,17 +1,17 @@
import type { FileUpload } from '@/app/components/base/features/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiUploadCloud2Line } from '@remixicon/react'
import {
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { FILE_URL_REGEX } from '../constants'
import FileInput from '../file-input'
import { useFile } from '../hooks'
@ -54,16 +54,16 @@ const FileFromLinkOrLocal = ({
}
return (
<Popover
<PortalToFollowElem
placement="top"
offset={4}
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger render={trigger(open) as React.ReactElement} />
<PopoverContent
placement="top"
sideOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} asChild>
{trigger(open)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1001">
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg">
{
showFromLink && (
@ -126,8 +126,8 @@ const FileFromLinkOrLocal = ({
)
}
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -1,13 +1,13 @@
import type { FC } from 'react'
import type { ImageFile, VisionSettings } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { TransferMethod } from '@/types/app'
import ImageLinkInput from './image-link-input'
import Uploader from './uploader'
@ -63,31 +63,29 @@ const UploaderButton: FC<UploaderButtonProps> = ({
const closePopover = () => setOpen(false)
const handleToggle = () => {
if (disabled)
return
setOpen(v => !v)
}
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={(nextOpen) => {
if (disabled)
return
setOpen(nextOpen)
}}
onOpenChange={setOpen}
placement="top-start"
>
<PopoverTrigger
render={(
<button
type="button"
disabled={disabled}
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
>
<span className="i-custom-vender-line-images-image-plus h-4 w-4 text-gray-500" />
</button>
)}
/>
<PopoverContent
placement="top-start"
sideOffset={0}
popupClassName="border-none bg-transparent shadow-none"
>
<PortalToFollowElemTrigger onClick={handleToggle}>
<button
type="button"
disabled={disabled}
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
>
<span className="i-custom-vender-line-images-image-plus h-4 w-4 text-gray-500" />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div className="w-[260px] rounded-lg border-[0.5px] border-gray-200 bg-white p-2 shadow-lg">
<ImageLinkInput onUpload={handleUpload} disabled={disabled} />
{!!hasUploadFromLocal && (
@ -117,8 +115,8 @@ const UploaderButton: FC<UploaderButtonProps> = ({
</>
)}
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -1,10 +1,5 @@
import type { FC } from 'react'
import type { ImageFile, VisionSettings } from '@/types/app'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
Fragment,
useEffect,
@ -13,6 +8,11 @@ import {
import { useTranslation } from 'react-i18next'
import { Link03 } from '@/app/components/base/icons/src/vender/line/general'
import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { TransferMethod } from '@/types/app'
import { useImageFiles } from './hooks'
import ImageLinkInput from './image-link-input'
@ -35,38 +35,35 @@ const PasteImageLinkButton: FC<PasteImageLinkButtonProps> = ({
onUpload(imageFile)
}
const handleToggle = () => {
if (disabled)
return
setOpen(v => !v)
}
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={(nextOpen) => {
if (disabled)
return
setOpen(nextOpen)
}}
onOpenChange={setOpen}
placement="top-start"
>
<PopoverTrigger
render={(
<div
className={`
relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
`}
>
<Link03 className="mr-2 h-4 w-4" />
{t('imageUploader.pasteImageLink', { ns: 'common' })}
</div>
)}
/>
<PopoverContent
placement="top-start"
sideOffset={0}
popupClassName="border-none bg-transparent shadow-none"
>
<PortalToFollowElemTrigger onClick={handleToggle}>
<div className={`
relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
`}
>
<Link03 className="mr-2 h-4 w-4" />
{t('imageUploader.pasteImageLink', { ns: 'common' })}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<div className="w-[320px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-2 shadow-lg">
<ImageLinkInput onUpload={handleUpload} />
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -1,82 +0,0 @@
'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

@ -91,7 +91,7 @@ function buildDirectiveRehypePlugins(): PluggableList {
])
const attributes: Record<string, AttributeDefinition[]> = {
...defaultSanitizeSchema.attributes,
...(defaultSanitizeSchema.attributes ?? {}),
}
for (const [tagName, allowedAttributes] of Object.entries(DIRECTIVE_ALLOWED_TAGS))

View File

@ -86,7 +86,7 @@ function buildRehypePlugins(extraPlugins?: PluggableList): PluggableList {
])
const mergedAttributes: Record<string, AttributeDefinition[]> = {
...defaultSanitizeSchema.attributes,
...(defaultSanitizeSchema.attributes ?? {}),
}
for (const tag of Object.keys(ALLOWED_TAGS)) {

View File

@ -148,17 +148,14 @@ export const PortalToFollowElemTrigger = (
}: React.HTMLProps<HTMLElement> & { ref?: React.RefObject<HTMLElement | null>, asChild?: boolean },
) => {
const context = usePortalToFollowElemContext()
const childElement = React.isValidElement<{ ref?: React.Ref<HTMLElement | null> }>(children)
? children
: null
const childrenRef = childElement?.props.ref
const childrenRef = (children as any).props?.ref
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
// `asChild` allows the user to pass any element as the anchor
if (asChild && childElement) {
const childProps = (childElement.props ?? {}) as Record<string, unknown>
if (asChild && React.isValidElement(children)) {
const childProps = (children.props ?? {}) as Record<string, unknown>
return React.cloneElement(
childElement,
children,
context.getReferenceProps({
ref,
...props,

View File

@ -2,9 +2,6 @@ import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../../constants'
import ContextBlockComponent from '../component'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
// Mock the hooks used by ContextBlockComponent
const mockUseSelectOrDelete = vi.fn()
const mockUseTrigger = vi.fn()
@ -226,21 +223,6 @@ describe('ContextBlockComponent', () => {
})
describe('User Interactions', () => {
it('should keep the popover closed when the trigger prevents the default click', async () => {
const user = userEvent.setup()
const { triggerSetOpen } = defaultSetup()
render(
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
)
await user.click(screen.getByTestId('popover-trigger'))
expect(triggerSetOpen).not.toHaveBeenCalled()
expect(
screen.queryByText('common.promptEditor.context.modal.add'),
).not.toBeInTheDocument()
})
it('should call onAddContext when add button is clicked', async () => {
defaultSetup({ open: true })
const handleAddContext = vi.fn()
@ -363,29 +345,6 @@ describe('ContextBlockComponent', () => {
// Original datasets still there
expect(screen.getByText('Dataset A')).toBeInTheDocument()
})
it('should ignore string events from the event emitter', () => {
defaultSetup({ open: true })
let subscriptionCallback: (v: Record<string, unknown> | string) => void = () => { }
mockUseSubscription.mockImplementation((cb: (v: Record<string, unknown> | string) => void) => {
subscriptionCallback = cb
})
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={vi.fn()}
/>,
)
act(() => {
subscriptionCallback('ignore-me')
})
expect(screen.getByText('Dataset A')).toBeInTheDocument()
expect(screen.getByText('Dataset B')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {

View File

@ -1,10 +1,13 @@
import type { FC } from 'react'
import type { Dataset } from './index'
import type { EventEmitterValue } from '@/context/event-emitter'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../constants'
import { useSelectOrDelete, useTrigger } from '../../hooks'
@ -29,12 +32,9 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
const { eventEmitter } = useEventEmitterContextContext()
const [localDatasets, setLocalDatasets] = useState<Dataset[]>(datasets)
eventEmitter?.useSubscription((event?: EventEmitterValue) => {
if (typeof event === 'string')
return
if (event?.type === UPDATE_DATASETS_EVENT_EMITTER && Array.isArray(event.payload))
setLocalDatasets(event.payload as Dataset[])
eventEmitter?.useSubscription((v: any) => {
if (v?.type === UPDATE_DATASETS_EVENT_EMITTER)
setLocalDatasets(v.payload)
})
return (
@ -49,31 +49,24 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
<span className="mr-1 i-custom-vender-solid-files-file-05 h-[14px] w-[14px]" data-testid="file-icon" />
<div className="mr-1 text-xs font-medium">{t('promptEditor.context.item.title', { ns: 'common' })}</div>
{!canNotAddContext && (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 3,
alignmentAxis: -147,
}}
>
<PopoverTrigger
nativeButton={false}
render={(
<div
className={`
<PortalToFollowElemTrigger ref={triggerRef}>
<div className={`
flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded text-[11px] font-semibold
${open ? 'bg-[#6938EF] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'}
`}
ref={triggerRef}
onClick={e => e.preventDefault()}
>
{localDatasets.length}
</div>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={3}
alignOffset={-147}
popupClassName="border-none bg-transparent shadow-none"
>
`}>
{localDatasets.length}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 100 }}>
<div className="w-[360px] rounded-xl bg-white shadow-lg">
<div className="p-4">
<div className="mb-2 text-xs font-medium text-gray-500">
@ -102,8 +95,8 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
{t('promptEditor.context.modal.footer', { ns: 'common' })}
</div>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)}
</div>

View File

@ -6,8 +6,6 @@ import { UPDATE_HISTORY_EVENT_EMITTER } from '../../../constants'
import HistoryBlockComponent from '../component'
import { DELETE_HISTORY_BLOCK_COMMAND } from '../index'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
type HistoryEventPayload = {
type?: string
payload?: RoleName
@ -111,24 +109,6 @@ describe('HistoryBlockComponent', () => {
expect(screen.getByText('common.promptEditor.history.modal.assistant')).toBeInTheDocument()
})
it('should keep the popover closed when the trigger prevents the default click', async () => {
const user = userEvent.setup()
const setOpen = vi.fn() as unknown as Dispatch<SetStateAction<boolean>>
mockUseTrigger.mockReturnValue(createTriggerHookReturn(false, setOpen))
render(
<HistoryBlockComponent
nodeKey="history-node-trigger"
onEditRole={vi.fn()}
/>,
)
await user.click(screen.getByTestId('popover-trigger'))
expect(setOpen).not.toHaveBeenCalled()
expect(screen.queryByText('common.promptEditor.history.modal.edit')).not.toBeInTheDocument()
})
it('should call onEditRole when edit action is clicked', async () => {
const user = userEvent.setup()
const onEditRole = vi.fn()
@ -208,29 +188,6 @@ describe('HistoryBlockComponent', () => {
expect(screen.getByText('kept-assistant')).toBeInTheDocument()
})
it('should ignore string events from the event emitter', () => {
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
render(
<HistoryBlockComponent
nodeKey="history-node-6-string"
roleName={createRoleName({
user: 'kept-user',
assistant: 'kept-assistant',
})}
onEditRole={vi.fn()}
/>,
)
expect(subscribedHandler).not.toBeNull()
act(() => {
subscribedHandler?.('ignore-me' as unknown as HistoryEventPayload)
})
expect(screen.getByText('kept-user')).toBeInTheDocument()
expect(screen.getByText('kept-assistant')).toBeInTheDocument()
})
it('should render when event emitter is unavailable', () => {
mockUseEventEmitterContextContext.mockReturnValue({
eventEmitter: undefined,

View File

@ -1,13 +1,16 @@
import type { FC } from 'react'
import type { RoleName } from './index'
import type { EventEmitterValue } from '@/context/event-emitter'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiMoreFill,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { UPDATE_HISTORY_EVENT_EMITTER } from '../../constants'
import { useSelectOrDelete, useTrigger } from '../../hooks'
@ -30,12 +33,9 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
const { eventEmitter } = useEventEmitterContextContext()
const [localRoleName, setLocalRoleName] = useState<RoleName>(roleName)
eventEmitter?.useSubscription((event?: EventEmitterValue) => {
if (typeof event === 'string')
return
if (event?.type === UPDATE_HISTORY_EVENT_EMITTER && event.payload && typeof event.payload === 'object')
setLocalRoleName(event.payload as RoleName)
eventEmitter?.useSubscription((v: any) => {
if (v?.type === UPDATE_HISTORY_EVENT_EMITTER)
setLocalRoleName(v.payload)
})
return (
@ -49,31 +49,25 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
>
<MessageClockCircle className="mr-1 h-[14px] w-[14px]" />
<div className="mr-1 text-xs font-medium">{t('promptEditor.history.item.title', { ns: 'common' })}</div>
<Popover
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="top-end"
offset={{
mainAxis: 4,
alignmentAxis: -148,
}}
>
<PopoverTrigger
nativeButton={false}
render={(
<div
className={`
<PortalToFollowElemTrigger ref={triggerRef}>
<div className={`
flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded
${open ? 'bg-[#DD2590] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'}
`}
ref={triggerRef}
onClick={e => e.preventDefault()}
>
<RiMoreFill className="h-3 w-3" />
</div>
)}
/>
<PopoverContent
placement="top-end"
sideOffset={4}
alignOffset={-148}
popupClassName="border-none bg-transparent shadow-none"
>
>
<RiMoreFill className="h-3 w-3" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 100 }}>
<div className="w-[360px] rounded-xl bg-white shadow-lg">
<div className="p-4">
<div className="mb-2 text-xs font-medium text-gray-500">{t('promptEditor.history.modal.title', { ns: 'common' })}</div>
@ -93,8 +87,8 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
{t('promptEditor.history.modal.edit', { ns: 'common' })}
</div>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}

View File

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

View File

@ -1,7 +1,6 @@
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 {
@ -14,6 +13,7 @@ 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 || !isShowAPart)
if (!node)
return Item
return (
<PreviewCard>
<PreviewCardTrigger delay={300} closeDelay={200} render={<div>{Item}</div>} />
<PreviewCardContent popupClassName="border-0 bg-transparent p-0 shadow-none">
<Tooltip
noDecoration
popupContent={(
<VarFullPathPanel
nodeName={node.title}
path={variables.slice(1)}
@ -137,8 +137,11 @@ const HITLInputVariableBlockComponent = ({
: Type.string}
nodeType={node?.type}
/>
</PreviewCardContent>
</PreviewCard>
)}
disabled={!isShowAPart}
>
<div>{Item}</div>
</Tooltip>
)
}

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

View File

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

View File

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

View File

@ -3,10 +3,9 @@ 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 { Infotip } from '@/app/components/base/infotip'
import Tooltip from '@/app/components/base/tooltip'
import { NUM_INFINITE } from '../config'
type Props = {
@ -160,11 +159,11 @@ const UsageInfo: FC<Props> = ({
const wrapWithStorageTooltip = (children: ReactNode) => {
if (storageMode && storageTooltip) {
return (
<Tooltip>
<TooltipTrigger render={<div className="cursor-default">{children}</div>} />
<TooltipContent className="w-[200px] max-w-[200px]">
{storageTooltip}
</TooltipContent>
<Tooltip
popupContent={<div className="w-[200px]">{storageTooltip}</div>}
asChild={false}
>
<div className="cursor-default">{children}</div>
</Tooltip>
)
}
@ -179,9 +178,13 @@ const UsageInfo: FC<Props> = ({
<div className="flex items-center gap-1">
<div className="system-xs-medium text-text-tertiary">{name}</div>
{tooltip && (
<Infotip aria-label={tooltip} popupClassName="w-[180px] max-w-[180px]">
{tooltip}
</Infotip>
<Tooltip
popupContent={(
<div className="w-[180px]">
{tooltip}
</div>
)}
/>
)}
</div>
<div className="flex items-center gap-1 system-md-semibold text-text-primary">

View File

@ -5,7 +5,34 @@ import * as React from 'react'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import DocumentPicker from '../index'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
// Mock portal-to-follow-elem - always render content for testing
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: {
children: React.ReactNode
open?: boolean
}) => (
<div data-testid="portal-elem" data-open={String(open || false)}>
{children}
</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: {
children: React.ReactNode
onClick?: () => void
}) => (
<div data-testid="portal-trigger" onClick={onClick}>
{children}
</div>
),
// Always render content to allow testing document selection
PortalToFollowElemContent: ({ children, className }: {
children: React.ReactNode
className?: string
}) => (
<div data-testid="portal-content" className={className}>
{children}
</div>
),
}))
// Mock useDocumentList hook with controllable return value
let mockDocumentListData: { data: SimpleDocumentDetail[] } | undefined
@ -125,10 +152,6 @@ const renderComponent = (props: Partial<React.ComponentProps<typeof DocumentPick
}
}
const openPopover = () => {
fireEvent.click(screen.getByTestId('popover-trigger'))
}
describe('DocumentPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -142,7 +165,7 @@ describe('DocumentPicker', () => {
it('should render without crashing', () => {
renderComponent()
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should render document name when provided', () => {
@ -250,7 +273,7 @@ describe('DocumentPicker', () => {
onChange,
})
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle value with all fields', () => {
@ -295,13 +318,13 @@ describe('DocumentPicker', () => {
it('should initialize with popup closed', () => {
renderComponent()
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
})
it('should open popup when trigger is clicked', () => {
renderComponent()
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
// Verify click handler is called
@ -407,7 +430,7 @@ describe('DocumentPicker', () => {
)
// The component should use the new callback
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should memoize handleChange callback with useCallback', () => {
@ -417,7 +440,7 @@ describe('DocumentPicker', () => {
renderComponent({ onChange })
// Verify component renders correctly, callback memoization is internal
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
@ -495,7 +518,7 @@ describe('DocumentPicker', () => {
it('should toggle popup when trigger is clicked', () => {
renderComponent()
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
// Trigger click should be handled
@ -568,7 +591,7 @@ describe('DocumentPicker', () => {
renderComponent()
// When loading, component should still render without crashing
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should fetch documents on mount', () => {
@ -588,7 +611,7 @@ describe('DocumentPicker', () => {
renderComponent()
// Component should render without crashing
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle undefined data response', () => {
@ -597,7 +620,7 @@ describe('DocumentPicker', () => {
renderComponent()
// Should not crash
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
@ -709,13 +732,13 @@ describe('DocumentPicker', () => {
renderComponent()
// Should not crash
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle rapid toggle clicks', () => {
renderComponent()
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByTestId('portal-trigger')
// Rapid clicks
fireEvent.click(trigger)
@ -772,7 +795,7 @@ describe('DocumentPicker', () => {
renderComponent()
// Should not crash
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle document list mapping with various data_source_detail_dict states', () => {
@ -796,7 +819,7 @@ describe('DocumentPicker', () => {
renderComponent()
// Should not crash during mapping
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
@ -806,13 +829,13 @@ describe('DocumentPicker', () => {
it('should handle empty datasetId', () => {
renderComponent({ datasetId: '' })
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle UUID format datasetId', () => {
renderComponent({ datasetId: '123e4567-e89b-12d3-a456-426614174000' })
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
@ -903,7 +926,6 @@ describe('DocumentPicker', () => {
const onChange = vi.fn()
renderComponent({ onChange })
openPopover()
fireEvent.click(screen.getByText('Document 2'))
@ -917,7 +939,6 @@ describe('DocumentPicker', () => {
mockDocumentListData = { data: docs }
renderComponent()
openPopover()
// Documents should be rendered in the list
expect(screen.getByText('Document 1')).toBeInTheDocument()
@ -957,14 +978,14 @@ describe('DocumentPicker', () => {
// The mapping: d.data_source_detail_dict?.upload_file?.extension || ''
// Should extract 'pdf' from the document
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should render trigger with SearchInput integration', () => {
renderComponent()
// The trigger is always rendered
expect(screen.getByTestId('popover-trigger')).toBeInTheDocument()
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
})
it('should integrate FileIcon component', () => {
@ -980,7 +1001,7 @@ describe('DocumentPicker', () => {
})
// FileIcon should render an SVG icon for the file extension
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByTestId('portal-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})
@ -989,10 +1010,9 @@ describe('DocumentPicker', () => {
describe('Visual States', () => {
it('should render portal content for document selection', () => {
renderComponent()
openPopover()
// Popover content is rendered after opening the trigger in our mock
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
// Portal content is rendered in our mock for testing
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
})
})

View File

@ -3,7 +3,34 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import PreviewDocumentPicker from '../preview-document-picker'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
// Mock portal-to-follow-elem - always render content for testing
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: {
children: React.ReactNode
open?: boolean
}) => (
<div data-testid="portal-elem" data-open={String(open || false)}>
{children}
</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: {
children: React.ReactNode
onClick?: () => void
}) => (
<div data-testid="portal-trigger" onClick={onClick}>
{children}
</div>
),
// Always render content to allow testing document selection
PortalToFollowElemContent: ({ children, className }: {
children: React.ReactNode
className?: string
}) => (
<div data-testid="portal-content" className={className}>
{children}
</div>
),
}))
// Factory function to create mock DocumentItem
const createMockDocumentItem = (overrides: Partial<DocumentItem> = {}): DocumentItem => ({
@ -40,10 +67,6 @@ const renderComponent = (props: Partial<React.ComponentProps<typeof PreviewDocum
}
}
const openPopover = () => {
fireEvent.click(screen.getByTestId('popover-trigger'))
}
describe('PreviewDocumentPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -54,7 +77,7 @@ describe('PreviewDocumentPicker', () => {
it('should render without crashing', () => {
renderComponent()
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should render document name from value prop', () => {
@ -87,7 +110,7 @@ describe('PreviewDocumentPicker', () => {
files: [], // Use empty files to avoid duplicate icons
})
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByTestId('portal-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
@ -97,7 +120,7 @@ describe('PreviewDocumentPicker', () => {
files: [], // Use empty files to avoid duplicate icons
})
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByTestId('portal-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})
@ -108,21 +131,22 @@ describe('PreviewDocumentPicker', () => {
const props = createDefaultProps()
render(<PreviewDocumentPicker {...props} />)
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should apply className to trigger element', () => {
renderComponent({ className: 'custom-class' })
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).toHaveClass('custom-class')
const trigger = screen.getByTestId('portal-trigger')
const innerDiv = trigger.querySelector('.custom-class')
expect(innerDiv).toBeInTheDocument()
})
it('should handle empty files array', () => {
// Component should render without crashing with empty files
renderComponent({ files: [] })
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle single file', () => {
@ -131,7 +155,7 @@ describe('PreviewDocumentPicker', () => {
files: [createMockDocumentItem({ id: 'single-doc', name: 'Single File' })],
})
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle multiple files', () => {
@ -140,7 +164,7 @@ describe('PreviewDocumentPicker', () => {
files: createMockDocumentList(5),
})
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should use value.extension for file icon', () => {
@ -148,7 +172,7 @@ describe('PreviewDocumentPicker', () => {
value: createMockDocumentItem({ name: 'test.docx', extension: 'docx' }),
})
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByTestId('portal-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})
@ -158,13 +182,13 @@ describe('PreviewDocumentPicker', () => {
it('should initialize with popup closed', () => {
renderComponent()
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
})
it('should toggle popup when trigger is clicked', () => {
renderComponent()
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
expect(trigger).toBeInTheDocument()
@ -172,10 +196,9 @@ describe('PreviewDocumentPicker', () => {
it('should render portal content for document selection', () => {
renderComponent()
openPopover()
// Popover content is rendered after opening the trigger in our mock
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
// Portal content is always rendered in our mock for testing
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
})
@ -219,7 +242,7 @@ describe('PreviewDocumentPicker', () => {
<PreviewDocumentPicker value={value} files={files} onChange={onChange2} />,
)
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
@ -242,7 +265,7 @@ describe('PreviewDocumentPicker', () => {
<PreviewDocumentPicker value={value} files={files} onChange={onChange} />,
)
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
@ -251,7 +274,7 @@ describe('PreviewDocumentPicker', () => {
it('should toggle popup when trigger is clicked', () => {
renderComponent()
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
expect(trigger).toBeInTheDocument()
@ -260,7 +283,6 @@ describe('PreviewDocumentPicker', () => {
it('should render document list with files', () => {
const files = createMockDocumentList(3)
renderComponent({ files })
openPopover()
// Documents should be visible in the list
expect(screen.getByText('Document 1')).toBeInTheDocument()
@ -273,7 +295,6 @@ describe('PreviewDocumentPicker', () => {
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
openPopover()
fireEvent.click(screen.getByText('Document 2'))
@ -285,7 +306,7 @@ describe('PreviewDocumentPicker', () => {
it('should handle rapid toggle clicks', () => {
renderComponent()
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByTestId('portal-trigger')
// Rapid clicks
fireEvent.click(trigger)
@ -316,14 +337,14 @@ describe('PreviewDocumentPicker', () => {
// Renders placeholder for missing name
expect(screen.getByText('--')).toBeInTheDocument()
// Portal wrapper renders
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle empty files array', () => {
renderComponent({ files: [] })
// Component should render without crashing
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle very long document names', () => {
@ -353,7 +374,7 @@ describe('PreviewDocumentPicker', () => {
render(<PreviewDocumentPicker {...props} />)
// Component should render without crashing
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle large number of files', () => {
@ -361,7 +382,7 @@ describe('PreviewDocumentPicker', () => {
renderComponent({ files: manyFiles })
// Component should accept large files array
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle files with same name but different extensions', () => {
@ -372,7 +393,7 @@ describe('PreviewDocumentPicker', () => {
renderComponent({ files })
// Component should handle duplicate names
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
@ -406,7 +427,7 @@ describe('PreviewDocumentPicker', () => {
files: [createMockDocumentItem({ name: 'Single' })],
})
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle two files', () => {
@ -414,7 +435,7 @@ describe('PreviewDocumentPicker', () => {
files: createMockDocumentList(2),
})
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle many files', () => {
@ -422,7 +443,7 @@ describe('PreviewDocumentPicker', () => {
files: createMockDocumentList(50),
})
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
})
@ -430,22 +451,23 @@ describe('PreviewDocumentPicker', () => {
it('should apply custom className', () => {
renderComponent({ className: 'my-custom-class' })
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).toHaveClass('my-custom-class')
const trigger = screen.getByTestId('portal-trigger')
expect(trigger.querySelector('.my-custom-class')).toBeInTheDocument()
})
it('should work without className', () => {
renderComponent({ className: undefined })
expect(screen.getByTestId('popover-trigger')).toBeInTheDocument()
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
})
it('should handle multiple class names', () => {
renderComponent({ className: 'class-one class-two' })
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).toHaveClass('class-one')
expect(trigger).toHaveClass('class-two')
const trigger = screen.getByTestId('portal-trigger')
const element = trigger.querySelector('.class-one')
expect(element).toBeInTheDocument()
expect(element).toHaveClass('class-two')
})
})
@ -458,7 +480,7 @@ describe('PreviewDocumentPicker', () => {
files: [], // Use empty files to avoid duplicate icons
})
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByTestId('portal-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})
@ -469,7 +491,6 @@ describe('PreviewDocumentPicker', () => {
it('should render all documents in the list', () => {
const files = createMockDocumentList(5)
renderComponent({ files })
openPopover()
// All documents should be visible
files.forEach((file) => {
@ -482,7 +503,6 @@ describe('PreviewDocumentPicker', () => {
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
openPopover()
fireEvent.click(screen.getByText('Document 1'))
@ -508,7 +528,6 @@ describe('PreviewDocumentPicker', () => {
onChange={vi.fn()}
/>,
)
openPopover()
expect(screen.getByText(/dataset\.preprocessDocument/)).toBeInTheDocument()
})
})
@ -518,8 +537,9 @@ describe('PreviewDocumentPicker', () => {
it('should apply hover styles on trigger', () => {
renderComponent()
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).toHaveClass('hover:bg-state-base-hover')
const trigger = screen.getByTestId('portal-trigger')
const innerDiv = trigger.querySelector('.hover\\:bg-state-base-hover')
expect(innerDiv).toBeInTheDocument()
})
it('should have truncate class for long names', () => {
@ -548,7 +568,6 @@ describe('PreviewDocumentPicker', () => {
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
openPopover()
fireEvent.click(screen.getByText('Document 1'))
@ -563,12 +582,10 @@ describe('PreviewDocumentPicker', () => {
]
renderComponent({ files: customFiles, onChange })
openPopover()
fireEvent.click(screen.getByText('Custom File 1'))
expect(onChange).toHaveBeenCalledWith(customFiles[0])
openPopover()
fireEvent.click(screen.getByText('Custom File 2'))
expect(onChange).toHaveBeenCalledWith(customFiles[1])
})
@ -580,11 +597,8 @@ describe('PreviewDocumentPicker', () => {
renderComponent({ files, onChange })
// Select multiple documents sequentially
openPopover()
fireEvent.click(screen.getByText('Document 1'))
openPopover()
fireEvent.click(screen.getByText('Document 3'))
openPopover()
fireEvent.click(screen.getByText('Document 2'))
expect(onChange).toHaveBeenCalledTimes(3)

View File

@ -2,11 +2,6 @@
import type { FC } from 'react'
import type { DocumentItem, ParentMode, SimpleDocumentDetail } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
@ -14,6 +9,11 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
import Loading from '@/app/components/base/loading'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import SearchInput from '@/app/components/base/search-input'
import { ChunkingMode } from '@/models/datasets'
import { useDocumentList } from '@/service/knowledge/use-document'
@ -61,6 +61,7 @@ const DocumentPicker: FC<Props> = ({
const [open, {
set: setOpen,
toggle: togglePopup,
}] = useBoolean(false)
const ArrowIcon = RiArrowDownSLine
@ -76,40 +77,34 @@ const DocumentPicker: FC<Props> = ({
}, [parentMode, t])
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
>
<PopoverTrigger
nativeButton={false}
render={(
<div className={cn('ml-1 flex cursor-pointer items-center rounded-lg px-2 py-0.5 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
<FileIcon name={name} extension={extension} size="xl" />
<div className="mr-0.5 ml-1 flex flex-col items-start">
<div className="flex items-center space-x-0.5">
<span className={cn('system-md-semibold text-text-primary')}>
{' '}
{name || '--'}
</span>
<ArrowIcon className="h-4 w-4 text-text-primary" />
</div>
<div className="flex h-3 items-center space-x-0.5 text-text-tertiary">
<TypeIcon className="h-3 w-3" />
<span className={cn('system-2xs-medium-uppercase', isParentChild && 'mt-0.5' /* to icon problem cause not ver align */)}>
{isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })}
{isQAMode && t('chunkingMode.qa', { ns: 'dataset' })}
{isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`}
</span>
</div>
<PortalToFollowElemTrigger onClick={togglePopup}>
<div className={cn('ml-1 flex cursor-pointer items-center rounded-lg px-2 py-0.5 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
<FileIcon name={name} extension={extension} size="xl" />
<div className="mr-0.5 ml-1 flex flex-col items-start">
<div className="flex items-center space-x-0.5">
<span className={cn('system-md-semibold text-text-primary')}>
{' '}
{name || '--'}
</span>
<ArrowIcon className="h-4 w-4 text-text-primary" />
</div>
<div className="flex h-3 items-center space-x-0.5 text-text-tertiary">
<TypeIcon className="h-3 w-3" />
<span className={cn('system-2xs-medium-uppercase', isParentChild && 'mt-0.5' /* to icon problem cause not ver align */)}>
{isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })}
{isQAMode && t('chunkingMode.qa', { ns: 'dataset' })}
{isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`}
</span>
</div>
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={0}
popupClassName="border-none bg-transparent shadow-none"
>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 pt-2 shadow-lg backdrop-blur-[5px]">
<SearchInput value={query} onChange={setQuery} className="mx-1" />
{documentsList
@ -130,8 +125,9 @@ const DocumentPicker: FC<Props> = ({
</div>
)}
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(DocumentPicker)

View File

@ -2,17 +2,17 @@
import type { FC } from 'react'
import type { DocumentItem } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback } 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 FileIcon from '../document-file-icon'
import DocumentList from './document-list'
@ -35,6 +35,7 @@ const PreviewDocumentPicker: FC<Props> = ({
const [open, {
set: setOpen,
toggle: togglePopup,
}] = useBoolean(false)
const ArrowIcon = RiArrowDownSLine
@ -44,32 +45,27 @@ const PreviewDocumentPicker: FC<Props> = ({
}, [onChange, setOpen])
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<PopoverTrigger
nativeButton={false}
render={(
<div className={cn('flex h-6 items-center rounded-md px-1 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover', className)}>
<FileIcon name={name} extension={extension} size="lg" />
<div className="ml-1 flex flex-col items-start">
<div className="flex items-center space-x-0.5">
<span className={cn('max-w-[200px] truncate system-md-semibold text-text-primary')}>
{' '}
{name || '--'}
</span>
<ArrowIcon className="h-[18px] w-[18px] text-text-primary" />
</div>
<PortalToFollowElemTrigger onClick={togglePopup}>
<div className={cn('flex h-6 items-center rounded-md px-1 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover', className)}>
<FileIcon name={name} extension={extension} size="lg" />
<div className="ml-1 flex flex-col items-start">
<div className="flex items-center space-x-0.5">
<span className={cn('max-w-[200px] truncate system-md-semibold text-text-primary')}>
{' '}
{name || '--'}
</span>
<ArrowIcon className="h-[18px] w-[18px] text-text-primary" />
</div>
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<div className="w-[392px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
{files?.length > 1 && <div className="flex h-8 items-center pl-2 system-xs-medium-uppercase text-text-tertiary">{t('preprocessDocument', { ns: 'dataset', num: files.length })}</div>}
{files?.length > 0
@ -85,8 +81,9 @@ const PreviewDocumentPicker: FC<Props> = ({
</div>
)}
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(PreviewDocumentPicker)

View File

@ -231,9 +231,8 @@ describe('StepTwoPreview', () => {
describe('Props Passing', () => {
it('should render preview button when isIdle is true', () => {
render(<StepTwoPreview {...defaultProps} isIdle={true} />)
const previewButton = screen.getByRole('button', {
name: 'datasetPipeline.addDocuments.stepTwo.previewChunks',
})
// ChunkPreview shows a preview button when idle
const previewButton = screen.queryByRole('button')
expect(previewButton).toBeInTheDocument()
})
@ -241,13 +240,13 @@ describe('StepTwoPreview', () => {
const onPreview = vi.fn()
render(<StepTwoPreview {...defaultProps} isIdle={true} onPreview={onPreview} />)
const previewButton = screen.getByRole('button', {
name: 'datasetPipeline.addDocuments.stepTwo.previewChunks',
})
previewButton.click()
expect(onPreview).toHaveBeenCalled()
// Find and click the preview button
const buttons = screen.getAllByRole('button')
const previewButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('preview'))
if (previewButton) {
previewButton.click()
expect(onPreview).toHaveBeenCalled()
}
})
})

View File

@ -75,7 +75,7 @@ export function useMetadataState({ docDetail, onUpdate }: UseMetadataStateOption
setEditStatus(true)
}
const cancelEdit = () => {
setMetadataParams({ documentType: docType || '', metadata: { ...docDetail?.doc_metadata } })
setMetadataParams({ documentType: docType || '', metadata: { ...(docDetail?.doc_metadata || {}) } })
setEditStatus(!docType)
if (!docType)
setShowDocTypes(true)

View File

@ -1,17 +1,17 @@
import type { Member } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine, RiGroup2Line, RiLock2Line } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useCallback, 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 { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { DatasetPermission } from '@/models/datasets'
import MemberItem from './member-item'
@ -90,98 +90,93 @@ const PermissionSelector = ({
const selectedMemberNames = selectedMembers.map(member => member.name).join(', ')
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={(nextOpen) => {
if (disabled)
return
setOpen(nextOpen)
}}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PopoverTrigger
render={(
<div className={cn('flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', disabled && 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!')}>
{
isOnlyMe && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
</div>
</>
)
}
{
isAllTeamMembers && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<RiGroup2Line className="size-4 text-text-secondary" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t('form.permissionsAllMember', { ns: 'datasetSettings' })}
</div>
</>
)
}
{
isPartialMembers && (
<>
<div className="relative flex size-6 shrink-0 items-center justify-center">
{
selectedMembers.length === 1 && (
<PortalToFollowElemTrigger
onClick={() => !disabled && setOpen(v => !v)}
className="block"
>
<div className={cn('flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', disabled && 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!')}>
{
isOnlyMe && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
</div>
</>
)
}
{
isAllTeamMembers && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<RiGroup2Line className="size-4 text-text-secondary" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t('form.permissionsAllMember', { ns: 'datasetSettings' })}
</div>
</>
)
}
{
isPartialMembers && (
<>
<div className="relative flex size-6 shrink-0 items-center justify-center">
{
selectedMembers.length === 1 && (
<Avatar
avatar={selectedMembers[0]!.avatar_url}
name={selectedMembers[0]!.name}
size="xs"
/>
)
}
{
selectedMembers.length >= 2 && (
<>
<Avatar
avatar={selectedMembers[0]!.avatar_url}
name={selectedMembers[0]!.name}
size="xs"
className="absolute top-0 left-0 z-0"
size="xxs"
/>
)
}
{
selectedMembers.length >= 2 && (
<>
<Avatar
avatar={selectedMembers[0]!.avatar_url}
name={selectedMembers[0]!.name}
className="absolute top-0 left-0 z-0"
size="xxs"
/>
<Avatar
avatar={selectedMembers[1]!.avatar_url}
name={selectedMembers[1]!.name}
className="absolute right-0 bottom-0 z-10"
size="xxs"
/>
</>
)
}
</div>
<div
title={selectedMemberNames}
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
>
{selectedMemberNames}
</div>
</>
)
}
<RiArrowDownSLine
className={cn(
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
disabled && 'text-components-input-text-placeholder!',
)}
/>
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
<Avatar
avatar={selectedMembers[1]!.avatar_url}
name={selectedMembers[1]!.name}
className="absolute right-0 bottom-0 z-10"
size="xxs"
/>
</>
)
}
</div>
<div
title={selectedMemberNames}
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
>
{selectedMemberNames}
</div>
</>
)
}
<RiArrowDownSLine
className={cn(
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
disabled && 'text-components-input-text-placeholder!',
)}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
<div className="relative w-[480px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5">
<div className="p-1">
{/* Only me */}
@ -241,7 +236,6 @@ const PermissionSelector = ({
)}
{filteredMemberList.map(member => (
<MemberItem
key={member.id}
leftIcon={
<Avatar avatar={member.avatar_url} name={member.name} className="shrink-0" size="sm" />
}
@ -262,9 +256,9 @@ const PermissionSelector = ({
</div>
)}
</div>
</PopoverContent>
</PortalToFollowElemContent>
</div>
</Popover>
</PortalToFollowElem>
)
}

View File

@ -15,8 +15,6 @@ vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
describe('ApiBasedExtensionSelector', () => {
const mockOnChange = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()

View File

@ -1,5 +1,4 @@
import type { FC } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiAddLine,
RiArrowDownSLine,
@ -9,6 +8,11 @@ import { useTranslation } from 'react-i18next'
import {
ArrowUpRight,
} from '@/app/components/base/icons/src/vender/line/arrows'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common'
@ -37,42 +41,35 @@ const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
const currentItem = data?.find(item => item.id === value)
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<PopoverTrigger
render={(
<button type="button" className="block w-full border-0 bg-transparent p-0 text-left">
{
currentItem
? (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
<div className="text-sm text-text-primary">{currentItem.name}</div>
<div className="flex items-center">
<div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary">
{currentItem.api_endpoint}
</div>
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
</div>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} className="w-full">
{
currentItem
? (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
<div className="text-sm text-text-primary">{currentItem.name}</div>
<div className="flex items-center">
<div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary">
{currentItem.api_endpoint}
</div>
)
: (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary">
{t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
</div>
)
}
</button>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
className="w-[calc(100%-32px)] max-w-[576px]"
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
</div>
</div>
)
: (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary">
{t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
</div>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002 w-[calc(100%-32px)] max-w-[576px]">
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="p-1">
<div className="flex items-center justify-between px-3 pt-2 pb-1">
@ -119,8 +116,8 @@ const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
</div>
</div>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -1,4 +1,3 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import type { DataSourceAuth } from '../types'
import type { FormSchema } from '@/app/components/base/form/types'
import type { AddApiKeyButtonProps, AddOAuthButtonProps, PluginPayload } from '@/app/components/plugins/plugin-auth/types'
@ -7,15 +6,6 @@ import { FormTypeEnum } from '@/app/components/base/form/types'
import { AuthCategory } from '@/app/components/plugins/plugin-auth/types'
import Configure from '../configure'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { children?: ReactNode }) => (
<button {...props}>
{children}
</button>
),
}))
/**
* Configure Component Tests
* Using Unit approach to ensure 100% coverage and stable tests.

View File

@ -5,11 +5,6 @@ import type {
PluginPayload,
} from '@/app/components/plugins/plugin-auth/types'
import { Button } from '@langgenius/dify-ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
RiAddLine,
} from '@remixicon/react'
@ -20,6 +15,11 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import {
AddApiKeyButton,
AddOAuthButton,
@ -56,6 +56,10 @@ const Configure = ({
}
}, [pluginPayload, t])
const handleToggle = useCallback(() => {
setOpen(v => !v)
}, [])
const handleUpdate = useCallback(() => {
setOpen(false)
onUpdate?.()
@ -63,26 +67,24 @@ const Configure = ({
return (
<>
<Popover
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PopoverTrigger
render={(
<Button
variant="secondary-accent"
>
<RiAddLine className="h-4 w-4" />
{t('dataSource.configure', { ns: 'common' })}
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
alignOffset={-4}
popupClassName="border-none bg-transparent shadow-none"
>
<PortalToFollowElemTrigger onClick={handleToggle}>
<Button
variant="secondary-accent"
>
<RiAddLine className="h-4 w-4" />
{t('dataSource.configure', { ns: 'common' })}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
<div className="w-[240px] space-y-1.5 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg">
{
!!canOAuth && (
@ -120,8 +122,8 @@ const Configure = ({
)
}
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
</>
)
}

View File

@ -34,18 +34,34 @@ vi.mock('@remixicon/react', () => ({
RiAddLine: () => <div data-testid="add-line-icon" />,
}))
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => (
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip-mock">
{children}
<div>{popupContent}</div>
</div>
),
}))
// Mock portal components to avoid async test DOM issues (consistent with sibling tests)
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean, onOpenChange: (open: boolean) => void }) => (
<div data-testid="portal" data-open={open}>
{children}
</div>
),
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => {
// In many tests, we need to find elements inside the content even if "closed" in state
// but not yet "removed" from DOM. However, to avoid multiple elements issues,
// we should be careful.
// For AddCustomModel, we need the content to be present when we click a model.
return <div data-testid="portal-content" style={{ display: 'block' }}>{children}</div>
},
}))
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
describe('AddCustomModel', () => {
const mockProvider = {
provider: 'openai',
@ -78,7 +94,7 @@ describe('AddCustomModel', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: /modelProvider.addModel/i }))
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
})
@ -91,10 +107,10 @@ describe('AddCustomModel', () => {
/>,
)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('portal-trigger'))
// The portal should be "open"
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
expect(screen.getByText('gpt-4')).toBeInTheDocument()
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
})
@ -109,7 +125,7 @@ describe('AddCustomModel', () => {
/>,
)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('gpt-4'))
expect(mockHandleOpenModalForAddCustomModelToModelList).toHaveBeenCalledWith(undefined, model)
@ -124,7 +140,7 @@ describe('AddCustomModel', () => {
/>,
)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModel/))
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
@ -143,7 +159,7 @@ describe('AddCustomModel', () => {
expect(screen.getByTestId('tooltip-mock')).toBeInTheDocument()
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /modelProvider.addModel/i }))
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(mockHandleOpenModalForAddNewCustomModel).not.toHaveBeenCalled()
})
})

View File

@ -7,16 +7,6 @@ import {
Button,
} from '@langgenius/dify-ui/button'
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 {
RiAddCircleFill,
RiAddLine,
@ -27,6 +17,12 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Tooltip from '@/app/components/base/tooltip'
import { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import ModelIcon from '../model-icon'
import { useAuth } from './hooks/use-auth'
@ -71,12 +67,12 @@ const AddCustomModel = ({
},
)
const notAllowCustomCredential = provider.allow_custom_token === false
const renderTrigger = useCallback((open?: boolean, onClick?: () => void) => {
const item = (
const renderTrigger = useCallback((open?: boolean) => {
const Item = (
<Button
variant="ghost"
size="small"
onClick={onClick}
className={cn(
'text-text-tertiary',
open && 'bg-components-button-ghost-bg-hover',
@ -89,32 +85,38 @@ const AddCustomModel = ({
)
if (notAllowCustomCredential && !!noModels) {
return (
<Tooltip>
<TooltipTrigger render={item} />
<TooltipContent>{t('auth.credentialUnavailable', { ns: 'plugin' })}</TooltipContent>
<Tooltip asChild popupContent={t('auth.credentialUnavailable', { ns: 'plugin' })}>
{Item}
</Tooltip>
)
}
return item
return Item
}, [t, notAllowCustomCredential, noModels])
if (noModels) {
return renderTrigger(false, notAllowCustomCredential ? undefined : handleOpenModalForAddNewCustomModel)
}
return (
<Popover
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PopoverTrigger
render={<div className="inline-block">{renderTrigger(open)}</div>}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
<PortalToFollowElemTrigger onClick={() => {
if (noModels) {
if (notAllowCustomCredential)
return
handleOpenModalForAddNewCustomModel()
return
}
setOpen(prev => !prev)
}}
>
{renderTrigger(open)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
<div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<div className="max-h-[304px] overflow-y-auto p-1">
{
@ -123,8 +125,8 @@ const AddCustomModel = ({
key={model.model}
className="flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover"
onClick={() => {
setOpen(false)
handleOpenModalForAddCustomModelToModelList(undefined, model)
setOpen(false)
}}
>
<ModelIcon
@ -148,8 +150,8 @@ const AddCustomModel = ({
<div
className="flex cursor-pointer items-center border-t border-t-divider-subtle p-3 system-xs-medium text-text-accent-light-mode-only"
onClick={() => {
setOpen(false)
handleOpenModalForAddNewCustomModel()
setOpen(false)
}}
>
<RiAddLine className="mr-1 h-4 w-4" />
@ -158,8 +160,8 @@ const AddCustomModel = ({
)
}
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -45,8 +45,6 @@ vi.mock('../authorized-item', () => ({
),
}))
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
describe('Authorized', () => {
const mockProvider: ModelProvider = {
provider: 'openai',

View File

@ -1,8 +1,3 @@
import type {
OffsetOptions,
} from '@floating-ui/react'
import type { Placement } from '@langgenius/dify-ui/popover'
import type { MouseEvent } from 'react'
import type {
ConfigurationMethodEnum,
Credential,
@ -11,6 +6,9 @@ import type {
ModelModalModeEnum,
ModelProvider,
} from '../../declarations'
import type {
PortalToFollowElemOptions,
} from '@/app/components/base/portal-to-follow-elem'
import {
AlertDialog,
AlertDialogActions,
@ -21,11 +19,6 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
RiAddLine,
} from '@remixicon/react'
@ -36,6 +29,11 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useAuth } from '../hooks'
import AuthorizedItem from './authorized-item'
@ -45,7 +43,7 @@ type AuthorizedProps = {
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
authParams?: {
isModelCredential?: boolean
onUpdate?: (newPayload?: Record<string, unknown>, formValues?: Record<string, unknown>) => void
onUpdate?: (newPayload?: any, formValues?: Record<string, any>) => void
onRemove?: (credentialId: string) => void
mode?: ModelModalModeEnum
}
@ -59,8 +57,8 @@ type AuthorizedProps = {
renderTrigger: (open?: boolean) => React.ReactNode
isOpen?: boolean
onOpenChange?: (open: boolean) => void
offset?: number | OffsetOptions
placement?: Placement
offset?: PortalToFollowElemOptions['offset']
placement?: PortalToFollowElemOptions['placement']
triggerPopupSameWidth?: boolean
popupClassName?: string
showItemSelectedIcon?: boolean
@ -134,13 +132,9 @@ const Authorized = ({
)
const handleEdit = useCallback((credential?: Credential, model?: CustomModel) => {
setMergedIsOpen(false)
handleOpenModal(credential, model)
}, [handleOpenModal, setMergedIsOpen])
const handleDelete = useCallback((credential?: Credential, model?: CustomModel) => {
setMergedIsOpen(false)
openConfirmDelete(credential, model)
}, [openConfirmDelete, setMergedIsOpen])
}, [handleOpenModal, setMergedIsOpen])
const handleItemClick = useCallback((credential: Credential, model?: CustomModel) => {
if (disableItemClick)
@ -154,37 +148,30 @@ const Authorized = ({
setMergedIsOpen(false)
}, [handleActiveCredential, onItemClick, setMergedIsOpen, disableItemClick])
const notAllowCustomCredential = provider.allow_custom_token === false
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
const popupProps = triggerPopupSameWidth
? { style: { width: 'var(--anchor-width, auto)' } }
: undefined
const handleTriggerClick = useCallback((event: MouseEvent<HTMLElement>) => {
if (!triggerOnlyOpenModal)
return
event.preventDefault()
handleOpenModal()
}, [handleOpenModal, triggerOnlyOpenModal])
return (
<>
<Popover
<PortalToFollowElem
open={mergedIsOpen}
onOpenChange={setMergedIsOpen}
placement={placement}
offset={offset}
triggerPopupSameWidth={triggerPopupSameWidth}
>
<PopoverTrigger
render={<div className={triggerPopupSameWidth ? 'w-full' : 'inline-block'}>{renderTrigger(mergedIsOpen)}</div>}
onClick={handleTriggerClick}
/>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupProps={popupProps}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
<PortalToFollowElemTrigger
onClick={() => {
if (triggerOnlyOpenModal) {
handleOpenModal()
return
}
setMergedIsOpen(!mergedIsOpen)
}}
asChild
>
{renderTrigger(mergedIsOpen)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
<div className={cn(
'w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]',
popupClassName,
@ -199,15 +186,15 @@ const Authorized = ({
}
<div className="max-h-[304px] overflow-y-auto">
{
items.map(item => (
<Fragment key={item.model?.model ?? item.title ?? item.credentials.map(credential => credential.credential_id).join('-')}>
items.map((item, index) => (
<Fragment key={index}>
<AuthorizedItem
provider={provider}
title={item.title}
model={item.model}
credentials={item.credentials}
disabled={disabled}
onDelete={handleDelete}
onDelete={openConfirmDelete}
disableDeleteButShowAction={disableDeleteButShowAction}
disableDeleteTip={disableDeleteTip}
onEdit={handleEdit}
@ -217,7 +204,7 @@ const Authorized = ({
showModelTitle={showModelTitle}
/>
{
item !== items[items.length - 1] && (
index !== items.length - 1 && (
<div className="h-px bg-divider-subtle"></div>
)
}
@ -258,8 +245,8 @@ const Authorized = ({
)
}
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
<AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirmDelete()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">

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,13 +349,18 @@ function ParameterItem({
</div>
{
parameterRule.help && (
<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>
<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>
)
}
</div>

View File

@ -10,11 +10,7 @@ import {
PopoverContent,
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 { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
@ -164,13 +160,8 @@ const PopupItem: FC<PopupItemProps> = ({
</Popover>
</div>
{!collapsed && model.models.map(modelItem => (
// 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}
<Tooltip key={modelItem.model}>
<TooltipTrigger
render={(
<button
type="button"
@ -206,9 +197,10 @@ const PopupItem: FC<PopupItemProps> = ({
</button>
)}
/>
<PreviewCardContent
<TooltipContent
placement="right"
popupClassName="w-[206px] bg-components-panel-bg-blur p-3 shadow-none backdrop-blur-xs"
variant="plain"
className="w-[206px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 backdrop-blur-xs"
>
<div className="flex flex-col gap-1">
<div className="flex flex-col items-start gap-2">
@ -253,8 +245,8 @@ const PopupItem: FC<PopupItemProps> = ({
</div>
)}
</div>
</PreviewCardContent>
</PreviewCard>
</TooltipContent>
</Tooltip>
))}
</div>
)

View File

@ -225,7 +225,7 @@ const Popup: FC<PopupProps> = ({
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<div className="pr-1 pb-1 pl-3">
<div className="px-1 pb-1">
{
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,7 +20,6 @@ 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">
@ -32,9 +31,19 @@ export default function UsagePrioritySection({ value, disabled, onSelect }: Usag
<span className="truncate system-sm-medium text-text-secondary">
{t('modelProvider.card.usagePriority', { ns: 'common' })}
</span>
<Infotip aria-label={usagePriorityTip}>
{usagePriorityTip}
</Infotip>
<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>
</div>
<div className="flex shrink-0 items-center gap-1">
{options.map(option => (

View File

@ -9,7 +9,6 @@ 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'
@ -101,9 +100,19 @@ 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' })}
<Infotip aria-label={tipText} className="ml-0.5">
{tipText}
</Infotip>
<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>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">

View File

@ -11,9 +11,13 @@ 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'
@ -134,13 +138,21 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
return (
<div className="flex min-h-6 items-center text-[13px] font-medium text-text-secondary">
{t(labelKey, { ns: 'common' })}
<Infotip
aria-label={tipText}
className="ml-0.5"
popupClassName="w-[261px]"
>
{tipText}
</Infotip>
<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>
</div>
)
}

View File

@ -62,38 +62,31 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
// Mock popover with shared open state
// Mock portal-to-follow-elem with shared open state
let mockPortalOpenState = false
let mockPopoverOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({ children, open, onOpenChange }: {
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: {
children: React.ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}) => {
mockPortalOpenState = open
mockPopoverOnOpenChange = onOpenChange
return (
<div data-testid="portal-elem" data-open={open}>
{children}
</div>
)
},
PopoverTrigger: ({ children, render, className }: {
children?: React.ReactNode
render?: React.ReactNode
PortalToFollowElemTrigger: ({ children, onClick, className }: {
children: React.ReactNode
onClick: () => void
className?: string
}) => (
<div
data-testid="portal-trigger"
onClick={() => mockPopoverOnOpenChange?.(!mockPortalOpenState)}
className={className}
>
{render ?? children}
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{children}
</div>
),
PopoverContent: ({ children, className }: {
PortalToFollowElemContent: ({ children, className }: {
children: React.ReactNode
className?: string
}) => {

View File

@ -1,19 +1,10 @@
import {
fireEvent,
render,
screen,
within,
} from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagsFilter from '../tags-filter'
const { mockTranslate } = vi.hoisted(() => ({
mockTranslate: vi.fn((key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key),
}))
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: mockTranslate,
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
@ -55,7 +46,20 @@ vi.mock('@/app/components/base/input', () => ({
),
}))
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const React = await import('react')
return {
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) => <button data-testid="portal-trigger" onClick={onClick}>{children}</button>,
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div data-testid="portal-content">{children}</div>,
}
})
vi.mock('../trigger/marketplace', () => ({
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
@ -76,16 +80,8 @@ vi.mock('../trigger/tool-selector', () => ({
}))
describe('TagsFilter', () => {
const ensurePopoverOpen = () => {
if (!screen.queryByTestId('popover-content'))
fireEvent.click(screen.getByTestId('popover-trigger'))
return screen.getByTestId('popover-content')
}
beforeEach(() => {
vi.clearAllMocks()
mockTranslate.mockImplementation((key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key)
})
it('renders marketplace trigger when used in marketplace', () => {
@ -104,7 +100,6 @@ describe('TagsFilter', () => {
it('filters tag options by search text', () => {
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
expect(screen.getByText('Agent')).toBeInTheDocument()
expect(screen.getByText('RAG')).toBeInTheDocument()
@ -121,20 +116,11 @@ describe('TagsFilter', () => {
const onTagsChange = vi.fn()
const { rerender } = render(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
fireEvent.click(within(ensurePopoverOpen()).getByText('Agent'))
fireEvent.click(screen.getByText('Agent'))
expect(onTagsChange).toHaveBeenCalledWith([])
rerender(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
fireEvent.click(within(ensurePopoverOpen()).getByText('RAG'))
fireEvent.click(screen.getByText('RAG'))
expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag'])
})
it('falls back to an empty placeholder when translation is missing', () => {
mockTranslate.mockImplementation(() => undefined as unknown as string)
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
expect(screen.getByLabelText('tags-search')).toHaveAttribute('placeholder', '')
})
})

View File

@ -1,14 +1,14 @@
'use client'
import { useTranslation } from '#i18n'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import Checkbox from '@/app/components/base/checkbox'
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'
import MarketplaceTrigger from './trigger/marketplace'
import ToolSelectorTrigger from './trigger/tool-selector'
@ -37,45 +37,43 @@ const TagsFilter = ({
const selectedTagsLength = tags.length
return (
<Popover
<PortalToFollowElem
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: -6,
}}
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger
nativeButton={false}
render={(
<div className="shrink-0">
{
usedInMarketplace && (
<MarketplaceTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)
}
{
!usedInMarketplace && (
<ToolSelectorTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)
}
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
alignOffset={-6}
popupClassName="border-none bg-transparent shadow-none"
<PortalToFollowElemTrigger
className="shrink-0"
onClick={() => setOpen(v => !v)}
>
{
usedInMarketplace && (
<MarketplaceTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)
}
{
!usedInMarketplace && (
<ToolSelectorTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<div className="w-[240px] 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 +103,8 @@ const TagsFilter = ({
}
</div>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -73,8 +73,6 @@ vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
// Mock service/use-triggers
vi.mock('@/service/use-triggers', () => ({
useTriggerPluginDynamicOptions: () => ({

View File

@ -1,8 +1,7 @@
import type {
OffsetOptions,
} from '@floating-ui/react'
import type { Placement } from '@langgenius/dify-ui/popover'
import type { Credential, PluginPayload } from '../types'
import type {
PortalToFollowElemOptions,
} from '@/app/components/base/portal-to-follow-elem'
import {
AlertDialog,
AlertDialogActions,
@ -13,11 +12,6 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiArrowDownSLine,
@ -29,6 +23,11 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Indicator from '@/app/components/header/indicator'
import Authorize from '../authorize'
import ApiKeyModal from '../authorize/api-key-modal'
@ -49,8 +48,8 @@ type AuthorizedProps = {
renderTrigger?: (open?: boolean) => React.ReactNode
isOpen?: boolean
onOpenChange?: (open: boolean) => void
offset?: number | OffsetOptions
placement?: Placement
offset?: PortalToFollowElemOptions['offset']
placement?: PortalToFollowElemOptions['placement']
triggerPopupSameWidth?: boolean
popupClassName?: string
disableSetDefault?: boolean
@ -97,12 +96,11 @@ const Authorized = ({
const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
const openConfirm = useCallback((credentialId?: string) => {
setMergedIsOpen(false)
if (credentialId)
pendingOperationCredentialId.current = credentialId
setDeleteCredentialId(pendingOperationCredentialId.current)
}, [setMergedIsOpen])
}, [])
const closeConfirm = useCallback(() => {
setDeleteCredentialId(null)
pendingOperationCredentialId.current = null
@ -132,12 +130,11 @@ const Authorized = ({
handleSetDoingAction(false)
}
}, [deletePluginCredential, onUpdate, t, handleSetDoingAction])
const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null)
const handleEdit = useCallback((id: string, values: Record<string, unknown>) => {
setMergedIsOpen(false)
const [editValues, setEditValues] = useState<Record<string, any> | null>(null)
const handleEdit = useCallback((id: string, values: Record<string, any>) => {
pendingOperationCredentialId.current = id
setEditValues(values)
}, [setMergedIsOpen])
}, [])
const handleRemove = useCallback(() => {
setDeleteCredentialId(pendingOperationCredentialId.current)
}, [])
@ -174,59 +171,49 @@ const Authorized = ({
}, [updatePluginCredential, t, handleSetDoingAction, onUpdate])
const unavailableCredentials = credentials.filter(credential => credential.not_allowed_to_use)
const unavailableCredential = credentials.find(credential => credential.not_allowed_to_use && credential.is_default)
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
const popupProps = triggerPopupSameWidth
? { style: { width: 'var(--anchor-width, auto)' } }
: undefined
return (
<>
<Popover
<PortalToFollowElem
open={mergedIsOpen}
onOpenChange={setMergedIsOpen}
placement={placement}
offset={offset}
triggerPopupSameWidth={triggerPopupSameWidth}
>
<PopoverTrigger
render={(
<div className={triggerPopupSameWidth ? 'w-full' : 'inline-block'}>
{
renderTrigger
? renderTrigger(mergedIsOpen)
: (
<Button
className={cn(
'w-full',
mergedIsOpen && 'bg-components-button-secondary-bg-hover',
)}
>
<Indicator className="mr-2" color={unavailableCredential ? 'gray' : 'green'} />
{credentials.length}
&nbsp;
{
credentials.length > 1
? t('auth.authorizations', { ns: 'plugin' })
: t('auth.authorization', { ns: 'plugin' })
}
{
!!unavailableCredentials.length && (
` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})`
)
}
<RiArrowDownSLine className="ml-0.5 h-4 w-4" />
</Button>
)
}
</div>
)}
/>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupProps={popupProps}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
<PortalToFollowElemTrigger
onClick={() => setMergedIsOpen(!mergedIsOpen)}
asChild
>
{
renderTrigger
? renderTrigger(mergedIsOpen)
: (
<Button
className={cn(
'w-full',
isOpen && 'bg-components-button-secondary-bg-hover',
)}
>
<Indicator className="mr-2" color={unavailableCredential ? 'gray' : 'green'} />
{credentials.length}
&nbsp;
{
credentials.length > 1
? t('auth.authorizations', { ns: 'plugin' })
: t('auth.authorization', { ns: 'plugin' })
}
{
!!unavailableCredentials.length && (
` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})`
)
}
<RiArrowDownSLine className="ml-0.5 h-4 w-4" />
</Button>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-100">
<div className={cn(
'max-h-[360px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg',
popupClassName,
@ -336,8 +323,8 @@ const Authorized = ({
)
}
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
<AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">

View File

@ -46,8 +46,8 @@ vi.mock('@/app/components/base/input', () => ({
),
}))
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
open,
}: {
@ -58,20 +58,18 @@ vi.mock('@langgenius/dify-ui/popover', () => ({
{children}
</div>
),
PopoverTrigger: ({
PortalToFollowElemTrigger: ({
children,
render,
onClick,
}: {
children: ReactNode
render?: ReactNode
onClick?: () => void
}) => (
<button data-testid="picker-trigger" onClick={onClick}>
{render ?? children}
{children}
</button>
),
PopoverContent: ({ children }: { children: ReactNode }) => (
PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))

View File

@ -76,7 +76,7 @@ afterAll(() => {
// Mock portal components for controlled positioning in tests
// Use React context to properly scope open state per portal instance (for nested portals)
vi.mock('@langgenius/dify-ui/popover', () => {
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
// Context reference shared across mock components
let sharedContext: React.Context<boolean> | null = null
@ -90,7 +90,7 @@ vi.mock('@langgenius/dify-ui/popover', () => {
}
return {
Popover: ({
PortalToFollowElem: ({
children,
open,
}: {
@ -104,22 +104,20 @@ vi.mock('@langgenius/dify-ui/popover', () => {
React.createElement('div', { 'data-testid': 'portal-to-follow-elem', 'data-open': open }, children),
)
},
PopoverTrigger: ({
PortalToFollowElemTrigger: ({
children,
render,
onClick,
className,
}: {
children: ReactNode
render?: ReactNode
onClick?: () => void
className?: string
}) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{render ?? children}
{children}
</div>
),
PopoverContent: ({ children, className }: { children: ReactNode, className?: string }) => {
PortalToFollowElemContent: ({ children, className }: { children: ReactNode, className?: string }) => {
const Context = getContext()
const isOpen = React.useContext(Context)
if (!isOpen)

View File

@ -5,16 +5,16 @@ import type {
} from '@floating-ui/react'
import type { FC } from 'react'
import type { App } from '@/types/app'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { AppModeEnum } from '@/types/app'
type Props = {
@ -154,33 +154,26 @@ const AppPicker: FC<Props> = ({
}
}
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
const handleTriggerClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
event.preventDefault()
if (disabled || isShow)
const handleTriggerClick = () => {
if (disabled)
return
onShowChange(true)
}, [disabled, isShow, onShowChange])
}
return (
<Popover
<PortalToFollowElem
placement={placement}
offset={offset}
open={isShow}
onOpenChange={onShowChange}
>
<PopoverTrigger
render={<div>{trigger}</div>}
<PortalToFollowElemTrigger
onClick={handleTriggerClick}
/>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<div className="relative flex max-h-[400px] min-h-20 w-[356px] flex-col 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
@ -226,8 +219,8 @@ const AppPicker: FC<Props> = ({
</div>
</div>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -5,14 +5,14 @@ import type {
} from '@floating-ui/react'
import type { FC } from 'react'
import type { App } from '@/types/app'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import * as React 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 AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel'
import AppPicker from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker'
import AppTrigger from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger'
@ -94,9 +94,6 @@ const AppSelector: FC<Props> = ({
}, [currentAppInfo, displayedApps])
const hasMore = hasNextPage ?? true
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
const handleLoadMore = useCallback(async () => {
if (isFetchingNextPage || !hasMore)
@ -105,13 +102,11 @@ const AppSelector: FC<Props> = ({
await fetchNextPage()
}, [fetchNextPage, hasMore, isFetchingNextPage])
const handleTriggerClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
event.preventDefault()
if (disabled || isShow)
const handleTriggerClick = () => {
if (disabled)
return
setIsShow(true)
}, [disabled, isShow])
}
const [isShowChooseApp, setIsShowChooseApp] = useState(false)
const handleSelectApp = (app: App) => {
@ -148,27 +143,22 @@ const AppSelector: FC<Props> = ({
return (
<>
<Popover
<PortalToFollowElem
placement={placement}
offset={offset}
open={isShow}
onOpenChange={setIsShow}
>
<PopoverTrigger
render={(
<div className="w-full">
<AppTrigger
open={isShow}
appDetail={currentAppInfo}
/>
</div>
)}
<PortalToFollowElemTrigger
className="w-full"
onClick={handleTriggerClick}
/>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<AppTrigger
open={isShow}
appDetail={currentAppInfo}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<div className="relative min-h-20 w-[389px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="flex flex-col gap-1 px-4 py-3">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">{t('appSelector.label', { ns: 'app' })}</div>
@ -203,8 +193,8 @@ const AppSelector: FC<Props> = ({
/>
)}
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
</>
)
}

View File

@ -145,7 +145,7 @@ describe('LogViewer', () => {
})
it('should parse request data when it is raw JSON', () => {
const log = createLog({ request: { ...createLog().request, data: '{"hello":1}' } })
const log = createLog({ request: { ...createLog().request, data: '{\"hello\":1}' } })
render(<LogViewer logs={[log]} />)

View File

@ -153,8 +153,8 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({
}))
// Portal components need mocking for controlled positioning in tests
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
open,
}: {
@ -165,20 +165,18 @@ vi.mock('@langgenius/dify-ui/popover', () => ({
{children}
</div>
),
PopoverTrigger: ({
PortalToFollowElemTrigger: ({
children,
render,
onClick,
}: {
children: ReactNode
render?: ReactNode
onClick?: () => void
}) => (
<div data-testid="portal-trigger" onClick={onClick}>
{render ?? children}
{children}
</div>
),
PopoverContent: ({ children }: { children: ReactNode }) => (
PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))

Some files were not shown because too many files have changed in this diff Show More