mirror of
https://github.com/langgenius/dify.git
synced 2026-05-28 04:43:33 +08:00
Compare commits
3 Commits
4-17-lint-
...
deploy/zha
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b49059231 | |||
| b7666af311 | |||
| 37a2199029 |
10
.gitignore
vendored
10
.gitignore
vendored
@ -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
|
||||
|
||||
431
api/configs/env_reference.py
Normal file
431
api/configs/env_reference.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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 "",
|
||||
|
||||
8307
api/docs/backend-env.reference.json
Normal file
8307
api/docs/backend-env.reference.json
Normal file
File diff suppressed because it is too large
Load Diff
1364
api/docs/backend-env.reference.md
Normal file
1364
api/docs/backend-env.reference.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
)
|
||||
|
||||
|
||||
251
api/tests/unit_tests/configs/test_env_reference.py
Normal file
251
api/tests/unit_tests/configs/test_env_reference.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 />,
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ const jsonResponse = (body: unknown, init: ResponseInit = {}): Response =>
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...init.headers,
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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}</>
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}))
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -309,7 +309,7 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.cursor-default').length).toBeGreaterThan(0)
|
||||
expect(container.querySelector('[data-state]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 => (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}) => {
|
||||
|
||||
@ -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', '')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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: () => ({
|
||||
|
||||
@ -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}
|
||||
|
||||
{
|
||||
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}
|
||||
|
||||
{
|
||||
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">
|
||||
|
||||
@ -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>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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]} />)
|
||||
|
||||
|
||||
@ -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
Reference in New Issue
Block a user