mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
Merge branch 'main' into 4-27-app-deploy
This commit is contained in:
commit
c631cb086a
@ -3,6 +3,7 @@ import logging
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from core.tools.utils.configuration import ToolParameterConfigurationManager
|
||||
from core.workflow.human_input_adapter import adapt_node_config_for_graph
|
||||
from events.app_event import app_draft_workflow_was_synced
|
||||
from graphon.nodes import BuiltinNodeTypes
|
||||
from graphon.nodes.tool.entities import ToolEntity
|
||||
@ -19,7 +20,8 @@ def handle(sender, **kwargs):
|
||||
for node_data in synced_draft_workflow.graph_dict.get("nodes", []):
|
||||
if node_data.get("data", {}).get("type") == BuiltinNodeTypes.TOOL:
|
||||
try:
|
||||
tool_entity = ToolEntity.model_validate(node_data["data"])
|
||||
adapted_node_data = adapt_node_config_for_graph(node_data)
|
||||
tool_entity = ToolEntity.model_validate(adapted_node_data["data"])
|
||||
provider_type = ToolProviderType(tool_entity.provider_type.value)
|
||||
tool_runtime = ToolManager.get_tool_runtime(
|
||||
provider_type=provider_type,
|
||||
|
||||
@ -1568,12 +1568,14 @@ class WorkflowDraftVariable(Base):
|
||||
),
|
||||
)
|
||||
|
||||
# Relationship to WorkflowDraftVariableFile
|
||||
# WorkflowDraftVariableFile uses TypeBase while WorkflowDraftVariable uses Base, so the relationship
|
||||
# must resolve the class object lazily instead of relying on string lookup across registries.
|
||||
variable_file: Mapped[Optional["WorkflowDraftVariableFile"]] = orm.relationship(
|
||||
lambda: WorkflowDraftVariableFile,
|
||||
foreign_keys=[file_id],
|
||||
lazy="raise",
|
||||
uselist=False,
|
||||
primaryjoin="WorkflowDraftVariableFile.id == WorkflowDraftVariable.file_id",
|
||||
primaryjoin=lambda: orm.foreign(WorkflowDraftVariable.file_id) == WorkflowDraftVariableFile.id,
|
||||
)
|
||||
|
||||
# Cache for deserialized value
|
||||
@ -1892,7 +1894,7 @@ class WorkflowDraftVariable(Base):
|
||||
return self.last_edited_at is not None
|
||||
|
||||
|
||||
class WorkflowDraftVariableFile(Base):
|
||||
class WorkflowDraftVariableFile(TypeBase):
|
||||
"""Stores metadata about files associated with large workflow draft variables.
|
||||
|
||||
This model acts as an intermediary between WorkflowDraftVariable and UploadFile,
|
||||
@ -1906,18 +1908,7 @@ class WorkflowDraftVariableFile(Base):
|
||||
__tablename__ = "workflow_draft_variable_files"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID,
|
||||
primary_key=True,
|
||||
default=lambda: str(uuidv7()),
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
default=naive_utc_now,
|
||||
server_default=func.current_timestamp(),
|
||||
)
|
||||
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, default_factory=lambda: str(uuidv7()), init=False)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
StringUUID,
|
||||
@ -1969,15 +1960,23 @@ class WorkflowDraftVariableFile(Base):
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationship to UploadFile
|
||||
# Rows are created with `upload_file_id`; callers should load this relationship explicitly when needed.
|
||||
upload_file: Mapped["UploadFile"] = orm.relationship(
|
||||
UploadFile,
|
||||
foreign_keys=[upload_file_id],
|
||||
lazy="raise",
|
||||
init=False,
|
||||
uselist=False,
|
||||
primaryjoin=lambda: orm.foreign(WorkflowDraftVariableFile.upload_file_id) == UploadFile.id,
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
default_factory=naive_utc_now,
|
||||
server_default=func.current_timestamp(),
|
||||
)
|
||||
|
||||
|
||||
def is_system_variable_editable(name: str) -> bool:
|
||||
return name in _EDITABLE_SYSTEM_VARIABLE
|
||||
|
||||
@ -1083,10 +1083,9 @@ class DraftVariableSaver:
|
||||
mimetype=content_type,
|
||||
user=self._user,
|
||||
)
|
||||
|
||||
assert self._user.current_tenant_id
|
||||
# Create WorkflowDraftVariableFile record
|
||||
variable_file = WorkflowDraftVariableFile(
|
||||
id=uuidv7(),
|
||||
upload_file_id=upload_file.id,
|
||||
size=original_size,
|
||||
length=original_length,
|
||||
@ -1095,6 +1094,7 @@ class DraftVariableSaver:
|
||||
tenant_id=self._user.current_tenant_id,
|
||||
user_id=self._user.id,
|
||||
)
|
||||
variable_file.id = str(uuidv7())
|
||||
engine = bind = self._session.get_bind()
|
||||
assert isinstance(engine, Engine)
|
||||
with sessionmaker(bind=engine, expire_on_commit=False).begin() as session:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from typing import Any, NamedTuple
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask_restx import marshal
|
||||
@ -29,15 +29,18 @@ class TestWorkflowDraftVariableFields:
|
||||
def test_serialize_full_content(self):
|
||||
"""Test that _serialize_full_content uses pre-loaded relationships."""
|
||||
# Create mock objects with relationships pre-loaded
|
||||
mock_variable_file = MagicMock(spec=WorkflowDraftVariableFile)
|
||||
mock_variable_file.size = 100000
|
||||
mock_variable_file.length = 50
|
||||
mock_variable_file.value_type = SegmentType.OBJECT
|
||||
mock_variable_file.upload_file_id = "test-upload-file-id"
|
||||
|
||||
mock_variable = MagicMock(spec=WorkflowDraftVariable)
|
||||
mock_variable.file_id = "test-file-id"
|
||||
mock_variable.variable_file = mock_variable_file
|
||||
mock_variable = WorkflowDraftVariable(
|
||||
file_id="test-file-id",
|
||||
variable_file=WorkflowDraftVariableFile(
|
||||
size=100000,
|
||||
length=50,
|
||||
value_type=SegmentType.OBJECT,
|
||||
upload_file_id="test-upload-file-id",
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
app_id=str(uuid.uuid4()),
|
||||
user_id=str(uuid.uuid4()),
|
||||
),
|
||||
)
|
||||
|
||||
# Mock the file helpers
|
||||
with patch("controllers.console.app.workflow_draft_variable.file_helpers", autospec=True) as mock_file_helpers:
|
||||
@ -84,7 +87,7 @@ class TestWorkflowDraftVariableFields:
|
||||
|
||||
expected_without_value: OrderedDict[str, Any] = OrderedDict(
|
||||
{
|
||||
"id": str(conv_var.id),
|
||||
"id": conv_var.id,
|
||||
"type": conv_var.get_variable_type().value,
|
||||
"name": "conv_var",
|
||||
"description": "",
|
||||
@ -117,7 +120,7 @@ class TestWorkflowDraftVariableFields:
|
||||
|
||||
expected_without_value = OrderedDict(
|
||||
{
|
||||
"id": str(sys_var.id),
|
||||
"id": sys_var.id,
|
||||
"type": sys_var.get_variable_type().value,
|
||||
"name": "sys_var",
|
||||
"description": "",
|
||||
@ -149,7 +152,7 @@ class TestWorkflowDraftVariableFields:
|
||||
|
||||
expected_without_value: OrderedDict[str, Any] = OrderedDict(
|
||||
{
|
||||
"id": str(node_var.id),
|
||||
"id": node_var.id,
|
||||
"type": node_var.get_variable_type().value,
|
||||
"name": "node_var",
|
||||
"description": "",
|
||||
@ -180,19 +183,22 @@ class TestWorkflowDraftVariableFields:
|
||||
node_var.id = str(uuid.uuid4())
|
||||
node_var.last_edited_at = naive_utc_now()
|
||||
variable_file = WorkflowDraftVariableFile(
|
||||
id=str(uuidv7()),
|
||||
upload_file_id=str(uuid.uuid4()),
|
||||
size=1024,
|
||||
length=10,
|
||||
value_type=SegmentType.ARRAY_STRING,
|
||||
tenant_id=str(uuidv7()),
|
||||
app_id=str(uuidv7()),
|
||||
user_id=str(uuidv7()),
|
||||
)
|
||||
variable_file.id = str(uuidv7())
|
||||
node_var.variable_file = variable_file
|
||||
node_var.file_id = variable_file.id
|
||||
|
||||
expected_without_value: OrderedDict[str, Any] = OrderedDict(
|
||||
{
|
||||
"id": str(node_var.id),
|
||||
"type": node_var.get_variable_type().value,
|
||||
"id": node_var.id,
|
||||
"type": node_var.get_variable_type(),
|
||||
"name": "node_var",
|
||||
"description": "",
|
||||
"selector": ["test_node", "node_var"],
|
||||
@ -235,7 +241,7 @@ class TestWorkflowDraftVariableList:
|
||||
node_var.id = str(uuid.uuid4())
|
||||
node_var_dict = OrderedDict(
|
||||
{
|
||||
"id": str(node_var.id),
|
||||
"id": node_var.id,
|
||||
"type": node_var.get_variable_type().value,
|
||||
"name": "test_var",
|
||||
"description": "",
|
||||
|
||||
@ -33,42 +33,6 @@ class TestDraftVarLoaderSimple:
|
||||
fallback_variables=[],
|
||||
)
|
||||
|
||||
def test_load_offloaded_variable_string_type_unit(self, draft_var_loader):
|
||||
"""Test _load_offloaded_variable with string type - isolated unit test."""
|
||||
# Create mock objects
|
||||
upload_file = Mock(spec=UploadFile)
|
||||
upload_file.key = "storage/key/test.txt"
|
||||
|
||||
variable_file = Mock(spec=WorkflowDraftVariableFile)
|
||||
variable_file.value_type = SegmentType.STRING
|
||||
variable_file.upload_file = upload_file
|
||||
|
||||
draft_var = Mock(spec=WorkflowDraftVariable)
|
||||
draft_var.id = "draft-var-id"
|
||||
draft_var.node_id = "test-node-id"
|
||||
draft_var.name = "test_variable"
|
||||
draft_var.description = "test description"
|
||||
draft_var.get_selector.return_value = ["test-node-id", "test_variable"]
|
||||
draft_var.variable_file = variable_file
|
||||
|
||||
test_content = "This is the full string content"
|
||||
|
||||
with patch("services.workflow_draft_variable_service.storage") as mock_storage:
|
||||
mock_storage.load.return_value = test_content.encode()
|
||||
|
||||
# Execute the method
|
||||
selector_tuple, variable = draft_var_loader._load_offloaded_variable(draft_var)
|
||||
|
||||
# Verify results
|
||||
assert selector_tuple == ("test-node-id", "test_variable")
|
||||
assert variable.id == "draft-var-id"
|
||||
assert variable.name == "test_variable"
|
||||
assert variable.description == "test description"
|
||||
assert variable.value == test_content
|
||||
|
||||
# Verify storage was called correctly
|
||||
mock_storage.load.assert_called_once_with("storage/key/test.txt")
|
||||
|
||||
def test_load_offloaded_variable_object_type_unit(self, draft_var_loader):
|
||||
"""Test _load_offloaded_variable with object type - isolated unit test."""
|
||||
# Create mock objects
|
||||
@ -139,47 +103,6 @@ class TestDraftVarLoaderSimple:
|
||||
result = draft_var_loader._selector_to_tuple(selector)
|
||||
assert result == ("node_id", "var_name")
|
||||
|
||||
def test_load_offloaded_variable_number_type_unit(self, draft_var_loader):
|
||||
"""Test _load_offloaded_variable with number type - isolated unit test."""
|
||||
# Create mock objects
|
||||
upload_file = Mock(spec=UploadFile)
|
||||
upload_file.key = "storage/key/test_number.json"
|
||||
|
||||
variable_file = Mock(spec=WorkflowDraftVariableFile)
|
||||
variable_file.value_type = SegmentType.NUMBER
|
||||
variable_file.upload_file = upload_file
|
||||
|
||||
draft_var = Mock(spec=WorkflowDraftVariable)
|
||||
draft_var.id = "draft-var-id"
|
||||
draft_var.node_id = "test-node-id"
|
||||
draft_var.name = "test_number"
|
||||
draft_var.description = "test number description"
|
||||
draft_var.get_selector.return_value = ["test-node-id", "test_number"]
|
||||
draft_var.variable_file = variable_file
|
||||
|
||||
test_number = 123.45
|
||||
test_json_content = json.dumps(test_number)
|
||||
|
||||
with patch("services.workflow_draft_variable_service.storage") as mock_storage:
|
||||
mock_storage.load.return_value = test_json_content.encode()
|
||||
from graphon.variables.segments import FloatSegment
|
||||
|
||||
mock_segment = FloatSegment(value=test_number)
|
||||
draft_var.build_segment_from_serialized_value.return_value = mock_segment
|
||||
|
||||
# Execute the method
|
||||
selector_tuple, variable = draft_var_loader._load_offloaded_variable(draft_var)
|
||||
|
||||
# Verify results
|
||||
assert selector_tuple == ("test-node-id", "test_number")
|
||||
assert variable.id == "draft-var-id"
|
||||
assert variable.name == "test_number"
|
||||
assert variable.description == "test number description"
|
||||
|
||||
# Verify method calls
|
||||
mock_storage.load.assert_called_once_with("storage/key/test_number.json")
|
||||
draft_var.build_segment_from_serialized_value.assert_called_once_with(SegmentType.NUMBER, test_number)
|
||||
|
||||
def test_load_offloaded_variable_array_type_unit(self, draft_var_loader):
|
||||
"""Test _load_offloaded_variable with array type - isolated unit test."""
|
||||
# Create mock objects
|
||||
@ -229,12 +152,13 @@ class TestDraftVarLoaderSimple:
|
||||
variable_file.value_type = SegmentType.FILE
|
||||
variable_file.upload_file = upload_file
|
||||
|
||||
draft_var = WorkflowDraftVariable()
|
||||
draft_var.id = "draft-var-id"
|
||||
draft_var.app_id = "app-1"
|
||||
draft_var.node_id = "test-node-id"
|
||||
draft_var.name = "test_file"
|
||||
draft_var.description = "test file description"
|
||||
draft_var = WorkflowDraftVariable(
|
||||
id="draft-var-id",
|
||||
app_id="app-1",
|
||||
node_id="test-node-id",
|
||||
name="test_file",
|
||||
description="test file description",
|
||||
)
|
||||
draft_var._set_selector(["test-node-id", "test_file"])
|
||||
draft_var.variable_file = variable_file
|
||||
|
||||
|
||||
@ -200,7 +200,7 @@ class TestDraftVariableSaver:
|
||||
user=mock_user,
|
||||
)
|
||||
|
||||
def test_draft_saver_with_small_variables(self, draft_saver, mock_session):
|
||||
def test_draft_saver_with_small_variables(self, draft_saver: DraftVariableSaver, mock_session):
|
||||
with patch(
|
||||
"services.workflow_draft_variable_service.DraftVariableSaver._try_offload_large_variable", autospec=True
|
||||
) as _mock_try_offload:
|
||||
@ -212,18 +212,21 @@ class TestDraftVariableSaver:
|
||||
assert draft_var.file_id is None
|
||||
_mock_try_offload.return_value = None
|
||||
|
||||
def test_draft_saver_with_large_variables(self, draft_saver, mock_session):
|
||||
def test_draft_saver_with_large_variables(self, draft_saver: DraftVariableSaver, mock_session):
|
||||
with patch(
|
||||
"services.workflow_draft_variable_service.DraftVariableSaver._try_offload_large_variable", autospec=True
|
||||
) as _mock_try_offload:
|
||||
mock_segment = StringSegment(value="small value")
|
||||
mock_draft_var_file = WorkflowDraftVariableFile(
|
||||
id=str(uuidv7()),
|
||||
tenant_id=str(uuidv7()),
|
||||
app_id=str(uuidv7()),
|
||||
user_id=str(uuidv7()),
|
||||
size=1024,
|
||||
length=10,
|
||||
value_type=SegmentType.ARRAY_STRING,
|
||||
upload_file_id=str(uuid.uuid4()),
|
||||
upload_file_id=str(uuidv7()),
|
||||
)
|
||||
mock_draft_var_file.id = str(uuidv7())
|
||||
|
||||
_mock_try_offload.return_value = mock_segment, mock_draft_var_file
|
||||
draft_var = draft_saver._create_draft_variable(name="small_var", value=mock_segment, visible=True)
|
||||
|
||||
@ -119,11 +119,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -207,11 +202,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/dataset-info/dropdown.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -526,11 +516,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 8
|
||||
@ -559,11 +544,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/prompt-value-panel/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/prompt-value-panel/utils.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -620,9 +600,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/app/log/list.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 6
|
||||
},
|
||||
@ -645,7 +622,7 @@
|
||||
},
|
||||
"web/app/components/app/overview/embedded/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -791,11 +768,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/base/audio-btn/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/audio-gallery/AudioPlayer.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@ -871,9 +843,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/base/chat/chat-with-history/header/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
@ -991,11 +960,6 @@
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"web/app/components/base/chat/embedded-chatbot/header/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/chat/embedded-chatbot/hooks.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 3
|
||||
@ -1037,11 +1001,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/copy-feedback/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/date-and-time-picker/hooks.ts": {
|
||||
"react/no-unnecessary-use-prefix": {
|
||||
"count": 2
|
||||
@ -1093,11 +1052,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -1123,15 +1077,7 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/features/new-feature-panel/feature-bar.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/features/new-feature-panel/feature-card.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
@ -1168,9 +1114,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/base/file-uploader/file-list-in-log.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/no-missing-key": {
|
||||
"count": 1
|
||||
}
|
||||
@ -1193,11 +1136,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/file-uploader/pdf-preview.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/file-uploader/store.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 4
|
||||
@ -1215,7 +1153,7 @@
|
||||
},
|
||||
"web/app/components/base/form/components/base/base-field.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
@ -1593,15 +1531,7 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/image-uploader/image-list.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/image-uploader/image-preview.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -1764,9 +1694,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/base/new-audio-button/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -1949,11 +1876,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/qrcode/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/radio-card/index.stories.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -1982,25 +1904,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/select/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 4
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/select/index.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
},
|
||||
"style/multiline-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/sort/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@ -2262,11 +2165,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/create/step-two/components/general-chunking-options.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/create/step-two/components/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 5
|
||||
@ -2374,11 +2272,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/components/document-list/components/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 2
|
||||
@ -2553,11 +2446,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx": {
|
||||
"ts/no-non-null-asserted-optional-chain": {
|
||||
"count": 1
|
||||
@ -2619,11 +2507,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/hit-testing/components/query-input/textarea.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/hit-testing/components/result-item-external.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -2639,21 +2522,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/list/dataset-card/components/dataset-card-footer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@ -2722,11 +2595,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/settings/index-method/keyword-number.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/settings/summary-index-setting.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -2790,11 +2658,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/explore/try-app/app/chat.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/explore/try-app/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -3043,15 +2906,7 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
@ -3067,9 +2922,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
@ -3085,11 +2937,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-use-tip.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/utils.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 2
|
||||
@ -3182,33 +3029,12 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorize/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorized-in-node.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorized/item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -3303,9 +3129,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -3320,11 +3143,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -3338,9 +3156,6 @@
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx": {
|
||||
@ -3376,11 +3191,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -3389,11 +3199,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -3416,7 +3221,7 @@
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/index.ts": {
|
||||
@ -3457,11 +3262,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-page/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
@ -4103,11 +3903,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/config-vision.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -4156,9 +3951,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -4222,9 +4014,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/setting-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -4239,11 +4028,6 @@
|
||||
"count": 8
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker/field.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/variable/output-var-list.tsx": {
|
||||
"ts/no-non-null-asserted-optional-chain": {
|
||||
"count": 1
|
||||
@ -4557,11 +4341,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -4677,11 +4456,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/hooks.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 4
|
||||
@ -4819,11 +4593,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 2
|
||||
@ -5157,11 +4926,6 @@
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/trigger-schedule/default.ts": {
|
||||
"regexp/no-unused-capturing-group": {
|
||||
"count": 2
|
||||
@ -5299,11 +5063,6 @@
|
||||
"count": 12
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/panel/debug-and-preview/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/panel/env-panel/variable-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -5528,9 +5287,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/variable-inspect/group.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
@ -5554,17 +5310,11 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/variable-inspect/right.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/variable-inspect/trigger.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -5658,11 +5408,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/signin/_header.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/signin/components/mail-and-password-auth.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
@ -170,6 +170,12 @@ describe('Select wrappers', () => {
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'city select' }).element().querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should include open state feedback classes', async () => {
|
||||
const screen = await renderOpenSelect()
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-open:bg-state-base-hover-alt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SelectContent', () => {
|
||||
|
||||
@ -21,7 +21,7 @@ export const SelectGroup = BaseSelect.Group
|
||||
const selectTriggerVariants = cva(
|
||||
[
|
||||
'group flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
|
||||
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt',
|
||||
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-open:bg-state-base-hover-alt',
|
||||
'data-placeholder:text-components-input-text-placeholder',
|
||||
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
|
||||
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
|
||||
|
||||
@ -3,12 +3,12 @@ import type { FC, JSX } from 'react'
|
||||
import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import ProviderConfigModal from './provider-config-modal'
|
||||
import ProviderPanel from './provider-panel'
|
||||
@ -338,10 +338,13 @@ const ConfigPopup: FC<PopupProps> = ({
|
||||
<>
|
||||
{providerAllNotConfigured
|
||||
? (
|
||||
<Tooltip
|
||||
popupContent={t(`${I18N_PREFIX}.disabledTip`, { ns: 'app' })}
|
||||
>
|
||||
{switchContent}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={switchContent}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t(`${I18N_PREFIX}.disabledTip`, { ns: 'app' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
: switchContent}
|
||||
|
||||
@ -113,7 +113,9 @@ vi.mock('@/service/datasets', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: (...args: unknown[]) => mockToast(...args),
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToast(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/rename-modal', () => ({
|
||||
@ -220,7 +222,7 @@ describe('Dropdown callback coverage', () => {
|
||||
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast).toHaveBeenCalledWith('app.exportFailed', { type: 'error' })
|
||||
expect(mockToast).toHaveBeenCalledWith('app.exportFailed')
|
||||
})
|
||||
})
|
||||
|
||||
@ -257,7 +259,7 @@ describe('Dropdown callback coverage', () => {
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast).toHaveBeenCalledWith('check failed', { type: 'error' })
|
||||
expect(mockToast).toHaveBeenCalledWith('check failed')
|
||||
})
|
||||
expect(screen.queryByText('dataset.deleteDatasetConfirmTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -34,6 +34,25 @@ type DropDownProps = {
|
||||
expand: boolean
|
||||
}
|
||||
|
||||
type JsonErrorResponse = {
|
||||
json: () => Promise<{ message?: string }>
|
||||
}
|
||||
|
||||
const isJsonErrorResponse = (error: unknown): error is JsonErrorResponse => {
|
||||
return typeof error === 'object'
|
||||
&& error !== null
|
||||
&& 'json' in error
|
||||
&& typeof error.json === 'function'
|
||||
}
|
||||
|
||||
const getErrorMessage = async (error: unknown) => {
|
||||
if (!isJsonErrorResponse(error))
|
||||
return 'Unknown error'
|
||||
|
||||
const res = await error.json()
|
||||
return res?.message || 'Unknown error'
|
||||
}
|
||||
|
||||
const DropDown = ({
|
||||
expand,
|
||||
}: DropDownProps) => {
|
||||
@ -78,7 +97,7 @@ const DropDown = ({
|
||||
downloadBlob({ data: file, fileName: `${name}.pipeline` })
|
||||
}
|
||||
catch {
|
||||
toast(t('exportFailed', { ns: 'app' }), { type: 'error' })
|
||||
toast.error(t('exportFailed', { ns: 'app' }))
|
||||
}
|
||||
}, [dataset, exportPipelineConfig, t])
|
||||
|
||||
@ -89,9 +108,8 @@ const DropDown = ({
|
||||
setConfirmMessage(isUsedByApp ? t('datasetUsedByApp', { ns: 'dataset' })! : t('deleteDatasetConfirmContent', { ns: 'dataset' })!)
|
||||
setShowConfirmDelete(true)
|
||||
}
|
||||
catch (e: any) {
|
||||
const res = await e.json()
|
||||
toast(res?.message || 'Unknown error', { type: 'error' })
|
||||
catch (e: unknown) {
|
||||
toast.error(await getErrorMessage(e))
|
||||
}
|
||||
}, [dataset.id, t])
|
||||
|
||||
@ -112,10 +130,15 @@ const DropDown = ({
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<ActionButton className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md', open && 'bg-state-base-hover')}>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</ActionButton>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md', open && 'bg-state-base-hover')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement={expand ? 'bottom-end' : 'right-start'}
|
||||
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
ModelProvider,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
@ -86,12 +87,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-name'
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: ReactNode, popupContent: string }) => (
|
||||
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
|
||||
id: 'model-1',
|
||||
model: 'gpt-3.5-turbo',
|
||||
@ -385,14 +380,15 @@ describe('ModelParameterTrigger', () => {
|
||||
expect(screen.getByText('common.modelProvider.selectModel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configured model id and incompatible tooltip when model is missing from the provider list', () => {
|
||||
it('should render configured model id and incompatible tooltip when model is missing from the provider list', async () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.incompatibleTip')
|
||||
await userEvent.hover(screen.getByLabelText('common.modelProvider.selector.incompatibleTip'))
|
||||
expect(await screen.findByText('common.modelProvider.selector.incompatibleTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configure required tooltip for no-configure status', () => {
|
||||
it('should render configure required tooltip for no-configure status', async () => {
|
||||
const { unmount } = renderComponent()
|
||||
const triggerContent = capturedModalProps?.renderTrigger({
|
||||
open: false,
|
||||
@ -403,10 +399,11 @@ describe('ModelParameterTrigger', () => {
|
||||
unmount()
|
||||
render(<>{triggerContent}</>)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.configureRequired')
|
||||
await userEvent.hover(screen.getByLabelText('common.modelProvider.selector.configureRequired'))
|
||||
expect(await screen.findByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render disabled tooltip for disabled status', () => {
|
||||
it('should render disabled tooltip for disabled status', async () => {
|
||||
const { unmount } = renderComponent()
|
||||
const triggerContent = capturedModalProps?.renderTrigger({
|
||||
open: false,
|
||||
@ -417,7 +414,8 @@ describe('ModelParameterTrigger', () => {
|
||||
unmount()
|
||||
render(<>{triggerContent}</>)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.disabled')
|
||||
await userEvent.hover(screen.getByLabelText('common.modelProvider.selector.disabled'))
|
||||
expect(await screen.findByText('common.modelProvider.selector.disabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply expanded and warning styles when the trigger is open for a non-active status', () => {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ModelAndParameter } from '../types'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
DERIVED_MODEL_STATUS_BADGE_I18N,
|
||||
DERIVED_MODEL_STATUS_TOOLTIP_I18N,
|
||||
@ -132,8 +132,18 @@ const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({
|
||||
<span className={`i-ri-arrow-down-s-line h-3 w-3 ${isEmpty ? 'text-text-accent' : 'text-text-tertiary'}`} />
|
||||
{
|
||||
!isEmpty && !isActive && statusLabelKey && (
|
||||
<Tooltip popupContent={t((statusTooltipKey || statusLabelKey) as 'modelProvider.selector.incompatible', { ns: 'common' })}>
|
||||
<span className="i-custom-vender-line-alertsAndFeedback-alert-triangle h-4 w-4 text-[#F79009]" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span
|
||||
aria-label={t((statusTooltipKey || statusLabelKey) as 'modelProvider.selector.incompatible', { ns: 'common' })}
|
||||
className="i-custom-vender-line-alertsAndFeedback-alert-triangle h-4 w-4 text-[#F79009]"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t((statusTooltipKey || statusLabelKey) as 'modelProvider.selector.incompatible', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
@ -19,7 +20,6 @@ import FeatureBar from '@/app/components/base/features/new-feature-panel/feature
|
||||
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum, ModelModeType } from '@/types/app'
|
||||
@ -224,16 +224,23 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
|
||||
<div className="flex justify-between border-t border-divider-subtle p-4 pt-3">
|
||||
<Button className="w-[72px]" disabled={readonly} onClick={onClear}>{t('operation.clear', { ns: 'common' })}</Button>
|
||||
{canNotRun && (
|
||||
<Tooltip popupContent={t('otherError.promptNoBeEmpty', { ns: 'appDebug' })}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={canNotRun || readonly}
|
||||
onClick={() => onSend?.()}
|
||||
className="w-[96px]"
|
||||
>
|
||||
<RiPlayLargeFill className="mr-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{t('inputs.run', { ns: 'appDebug' })}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={canNotRun || readonly}
|
||||
onClick={() => onSend?.()}
|
||||
className="w-[96px]"
|
||||
>
|
||||
<RiPlayLargeFill className="mr-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{t('inputs.run', { ns: 'appDebug' })}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('otherError.promptNoBeEmpty', { ns: 'appDebug' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!canNotRun && (
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiCloseLine, RiEditFill } from '@remixicon/react'
|
||||
import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
@ -30,7 +31,6 @@ import CopyIcon from '@/app/components/base/copy-icon'
|
||||
import Drawer from '@/app/components/base/drawer'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import MessageLogModal from '@/app/components/base/message-log-modal'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { WorkflowContextProvider } from '@/app/components/workflow/context'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
@ -409,10 +409,15 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
<div className="mb-0.5 system-xs-semibold-uppercase text-text-primary">{isChatMode ? t('detail.conversationId', { ns: 'appLog' }) : t('detail.time', { ns: 'appLog' })}</div>
|
||||
{isChatMode && (
|
||||
<div className="flex items-center system-2xs-regular-uppercase text-text-secondary">
|
||||
<Tooltip
|
||||
popupContent={detail.id}
|
||||
>
|
||||
<div className="truncate">{detail.id}</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="truncate">{detail.id}</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{detail.id}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<CopyIcon content={detail.id} />
|
||||
</div>
|
||||
@ -769,18 +774,20 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
|
||||
// Annotated data needs to be highlighted
|
||||
const renderTdValue = (value: string | number | null, isEmptyStyle: boolean, isHighlight = false, annotation?: LogAnnotation) => {
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className={cn(isEmptyStyle ? 'text-text-quaternary' : 'text-text-secondary', !isHighlight ? '' : 'bg-orange-100', 'overflow-hidden system-sm-regular text-ellipsis whitespace-nowrap')}>
|
||||
{value || '-'}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent className={(isHighlight && !isChatMode) ? '' : 'hidden!'}>
|
||||
<span className="inline-flex items-center text-xs text-text-tertiary">
|
||||
<RiEditFill className="mr-1 h-3 w-3" />
|
||||
{`${t('detail.annotationTip', { ns: 'appLog', user: annotation?.account?.name })} ${formatTime(annotation?.created_at || dayjs().unix(), 'MM-DD hh:mm A')}`}
|
||||
</span>
|
||||
)}
|
||||
popupClassName={(isHighlight && !isChatMode) ? '' : 'hidden!'}
|
||||
>
|
||||
<div className={cn(isEmptyStyle ? 'text-text-quaternary' : 'text-text-secondary', !isHighlight ? '' : 'bg-orange-100', 'overflow-hidden system-sm-regular text-ellipsis whitespace-nowrap')}>
|
||||
{value || '-'}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiClipboardFill,
|
||||
RiClipboardLine,
|
||||
@ -11,7 +12,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { basePath } from '@/utils/var'
|
||||
@ -174,21 +174,24 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, classNam
|
||||
<div className="shrink-0 grow system-sm-medium text-text-secondary">
|
||||
{t(`${prefixEmbedded}.${option}`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
(isCopied[option]
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton>
|
||||
<div
|
||||
onClick={onClickCopy}
|
||||
>
|
||||
{isCopied[option] && <RiClipboardFill className="h-4 w-4" />}
|
||||
{!isCopied[option] && <RiClipboardLine className="h-4 w-4" />}
|
||||
</div>
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{(isCopied[option]
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
|
||||
}
|
||||
>
|
||||
<ActionButton>
|
||||
<div
|
||||
onClick={onClickCopy}
|
||||
>
|
||||
{isCopied[option] && <RiClipboardFill className="h-4 w-4" />}
|
||||
{!isCopied[option] && <RiClipboardLine className="h-4 w-4" />}
|
||||
</div>
|
||||
</ActionButton>
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-start gap-2 overflow-x-auto p-3">
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { t } from 'i18next'
|
||||
import { useState } from 'react'
|
||||
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import s from './style.module.css'
|
||||
|
||||
@ -82,27 +82,34 @@ const AudioBtn = ({
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center justify-center ${(audioState === 'loading' || audioState === 'playing') ? 'mr-1' : className}`}>
|
||||
<Tooltip
|
||||
popupContent={tooltipContent}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={audioState === 'loading'}
|
||||
className={`box-border flex h-6 w-6 cursor-pointer items-center justify-center ${isAudition ? 'p-0.5' : 'rounded-md bg-white p-0'}`}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{audioState === 'loading'
|
||||
? (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-md">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-md hover:bg-gray-50">
|
||||
<div className={`h-4 w-4 ${(audioState === 'playing') ? s.pauseIcon : s.playIcon}`}></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
disabled={audioState === 'loading'}
|
||||
className={`box-border flex h-6 w-6 cursor-pointer items-center justify-center ${isAudition ? 'p-0.5' : 'rounded-md bg-white p-0'}`}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{audioState === 'loading'
|
||||
? (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-md">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-md hover:bg-gray-50">
|
||||
<div className={`h-4 w-4 ${(audioState === 'playing') ? s.pauseIcon : s.playIcon}`}></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiEditBoxLine,
|
||||
RiLayoutRight2Line,
|
||||
@ -20,7 +21,6 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import ViewFormDropdown from '@/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown'
|
||||
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
useChatWithHistoryContext,
|
||||
} from '../context'
|
||||
@ -117,31 +117,41 @@ const Header = () => {
|
||||
<div className="h-[14px] w-px bg-divider-regular"></div>
|
||||
</div>
|
||||
{isSidebarCollapsed && (
|
||||
<Tooltip
|
||||
disabled={!!currentConversationId}
|
||||
popupContent={t('chat.newChatTip', { ns: 'share' })}
|
||||
>
|
||||
<div>
|
||||
<ActionButton
|
||||
size="l"
|
||||
state={(!currentConversationId || isResponding) ? ActionButtonState.Disabled : ActionButtonState.Default}
|
||||
disabled={!currentConversationId || isResponding}
|
||||
onClick={handleNewConversation}
|
||||
>
|
||||
<RiEditBoxLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
disabled={!!currentConversationId}
|
||||
render={(
|
||||
<div>
|
||||
<ActionButton
|
||||
size="l"
|
||||
state={(!currentConversationId || isResponding) ? ActionButtonState.Disabled : ActionButtonState.Default}
|
||||
disabled={!currentConversationId || isResponding}
|
||||
onClick={handleNewConversation}
|
||||
>
|
||||
<RiEditBoxLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('chat.newChatTip', { ns: 'share' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{currentConversationId && (
|
||||
<Tooltip
|
||||
popupContent={t('chat.resetChat', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={handleNewConversation}>
|
||||
<RiResetLeftLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton size="l" onClick={handleNewConversation}>
|
||||
<RiResetLeftLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('chat.resetChat', { ns: 'share' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{currentConversationId && inputsForms.length > 0 && (
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Theme } from '../theme/theme-context'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
@ -9,7 +10,6 @@ import ActionButton from '@/app/components/base/action-button'
|
||||
import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { isClient } from '@/utils/client'
|
||||
import {
|
||||
@ -111,26 +111,36 @@ const Header: FC<IHeaderProps> = ({
|
||||
)}
|
||||
{
|
||||
showToggleExpandButton && (
|
||||
<Tooltip
|
||||
popupContent={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={handleToggleExpand} data-testid="expand-button">
|
||||
{
|
||||
expanded
|
||||
? <div className="i-ri-collapse-diagonal-2-line h-[18px] w-[18px]" />
|
||||
: <div className="i-ri-expand-diagonal-2-line h-[18px] w-[18px]" />
|
||||
}
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton size="l" onClick={handleToggleExpand} data-testid="expand-button">
|
||||
{
|
||||
expanded
|
||||
? <div className="i-ri-collapse-diagonal-2-line h-[18px] w-[18px]" />
|
||||
: <div className="i-ri-expand-diagonal-2-line h-[18px] w-[18px]" />
|
||||
}
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{currentConversationId && allowResetChat && (
|
||||
<Tooltip
|
||||
popupContent={t('chat.resetChat', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={onCreateNewChat} data-testid="reset-chat-button">
|
||||
<div className="i-ri-reset-left-line h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton size="l" onClick={onCreateNewChat} data-testid="reset-chat-button">
|
||||
<div className="i-ri-reset-left-line h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('chat.resetChat', { ns: 'share' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{currentConversationId && inputsForms.length > 0 && !allInputsHidden && (
|
||||
@ -158,26 +168,36 @@ const Header: FC<IHeaderProps> = ({
|
||||
<div className="flex items-center gap-1">
|
||||
{
|
||||
showToggleExpandButton && (
|
||||
<Tooltip
|
||||
popupContent={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={handleToggleExpand} data-testid="mobile-expand-button">
|
||||
{
|
||||
expanded
|
||||
? <div className={cn('i-ri-collapse-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
: <div className={cn('i-ri-expand-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
}
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton size="l" onClick={handleToggleExpand} data-testid="mobile-expand-button">
|
||||
{
|
||||
expanded
|
||||
? <div className={cn('i-ri-collapse-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
: <div className={cn('i-ri-expand-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
}
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{currentConversationId && allowResetChat && (
|
||||
<Tooltip
|
||||
popupContent={t('chat.resetChat', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={onCreateNewChat} data-testid="mobile-reset-chat-button">
|
||||
<div className={cn('i-ri-reset-left-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton size="l" onClick={onCreateNewChat} data-testid="mobile-reset-chat-button">
|
||||
<div className={cn('i-ri-reset-left-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('chat.resetChat', { ns: 'share' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{currentConversationId && inputsForms.length > 0 && !allInputsHidden && (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiClipboardFill,
|
||||
RiClipboardLine,
|
||||
@ -6,7 +7,6 @@ import {
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useClipboard } from '@/hooks/use-clipboard'
|
||||
import copyStyle from './style.module.css'
|
||||
|
||||
@ -35,15 +35,20 @@ const CopyFeedback = ({ content }: Props) => {
|
||||
}, [copy, content])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={safeText}
|
||||
>
|
||||
<ActionButton>
|
||||
<div onClick={handleCopy}>
|
||||
{copied && <RiClipboardFill className="h-4 w-4" />}
|
||||
{!copied && <RiClipboardLine className="h-4 w-4" />}
|
||||
</div>
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton>
|
||||
<div onClick={handleCopy}>
|
||||
{copied && <RiClipboardFill className="h-4 w-4" />}
|
||||
{!copied && <RiClipboardLine className="h-4 w-4" />}
|
||||
</div>
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{safeText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@ -65,18 +70,23 @@ export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className'
|
||||
}, [copy, content])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={safeText}
|
||||
>
|
||||
<div
|
||||
className={`h-8 w-8 cursor-pointer rounded-lg hover:bg-components-button-ghost-bg-hover ${className ?? ''}`}
|
||||
>
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
className={`h-full w-full ${copyStyle.copyIcon} ${copied ? copyStyle.copied : ''}`}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className={`h-8 w-8 cursor-pointer rounded-lg hover:bg-components-button-ghost-bg-hover ${className ?? ''}`}
|
||||
>
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
className={`h-full w-full ${copyStyle.copyIcon} ${copied ? copyStyle.copied : ''}`}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{safeText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiEditLine, RiFileEditLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { addAnnotation } from '@/service/annotation'
|
||||
@ -40,17 +40,31 @@ const AnnotationCtrlButton: FC<Props> = ({ cached, query, answer, appId, message
|
||||
return (
|
||||
<>
|
||||
{cached && (
|
||||
<Tooltip popupContent={t('feature.annotation.edit', { ns: 'appDebug' })}>
|
||||
<ActionButton onClick={onEdit}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton onClick={onEdit}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('feature.annotation.edit', { ns: 'appDebug' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!cached && answer && (
|
||||
<Tooltip popupContent={t('feature.annotation.add', { ns: 'appDebug' })}>
|
||||
<ActionButton onClick={handleAdd}>
|
||||
<RiFileEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton onClick={handleAdd}>
|
||||
<RiFileEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('feature.annotation.add', { ns: 'appDebug' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiApps2AddLine, RiArrowRightLine, RiSparklingFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
@ -7,7 +8,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useFeatures } from '@/app/components/base/features/hooks'
|
||||
import VoiceSettings from '@/app/components/base/features/new-feature-panel/text-to-speech/voice-settings'
|
||||
import { Citations, ContentModeration, FolderUpload, LoveMessage, MessageFast, Microphone01, TextToAudio, VirtualAssistant } from '@/app/components/base/icons/src/vender/features'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type Props = {
|
||||
isChatMode?: boolean
|
||||
@ -51,86 +51,131 @@ const FeatureBar = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{!!features.moreLikeThis?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('feature.moreLikeThis.title', { ns: 'appDebug' })}
|
||||
>
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs">
|
||||
<RiSparklingFill className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs">
|
||||
<RiSparklingFill className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('feature.moreLikeThis.title', { ns: 'appDebug' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!!features.opening?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('feature.conversationOpener.title', { ns: 'appDebug' })}
|
||||
>
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs">
|
||||
<LoveMessage className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs">
|
||||
<LoveMessage className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('feature.conversationOpener.title', { ns: 'appDebug' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!!features.moderation?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('feature.moderation.title', { ns: 'appDebug' })}
|
||||
>
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-text-success p-1 shadow-xs">
|
||||
<ContentModeration className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-text-success p-1 shadow-xs">
|
||||
<ContentModeration className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('feature.moderation.title', { ns: 'appDebug' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!!features.speech2text?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('feature.speechToText.title', { ns: 'appDebug' })}
|
||||
>
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-violet-violet-600 p-1 shadow-xs">
|
||||
<Microphone01 className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-violet-violet-600 p-1 shadow-xs">
|
||||
<Microphone01 className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('feature.speechToText.title', { ns: 'appDebug' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!!features.text2speech?.enabled && (
|
||||
<VoiceSettings placementLeft={false} open={modalOpen && !disabled} onOpen={setModalOpen}>
|
||||
<Tooltip
|
||||
popupContent={t('feature.textToSpeech.title', { ns: 'appDebug' })}
|
||||
>
|
||||
<div className={cn('shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-violet-violet-600 p-1 shadow-xs', !disabled && 'cursor-pointer')}>
|
||||
<TextToAudio className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className={cn('shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-violet-violet-600 p-1 shadow-xs', !disabled && 'cursor-pointer')}>
|
||||
<TextToAudio className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('feature.textToSpeech.title', { ns: 'appDebug' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</VoiceSettings>
|
||||
)}
|
||||
{showFileUpload && !!features.file?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('feature.fileUpload.title', { ns: 'appDebug' })}
|
||||
>
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-blue-600 p-1 shadow-xs">
|
||||
<FolderUpload className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-blue-600 p-1 shadow-xs">
|
||||
<FolderUpload className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('feature.fileUpload.title', { ns: 'appDebug' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!!features.suggested?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('feature.suggestedQuestionsAfterAnswer.title', { ns: 'appDebug' })}
|
||||
>
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs">
|
||||
<VirtualAssistant className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs">
|
||||
<VirtualAssistant className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('feature.suggestedQuestionsAfterAnswer.title', { ns: 'appDebug' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isChatMode && !!features.citation?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('feature.citation.title', { ns: 'appDebug' })}
|
||||
>
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-warning-warning-500 p-1 shadow-xs">
|
||||
<Citations className="h-4 w-4 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-warning-warning-500 p-1 shadow-xs">
|
||||
<Citations className="h-4 w-4 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('feature.citation.title', { ns: 'appDebug' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isChatMode && !!features.annotationReply?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('feature.annotation.title', { ns: 'appDebug' })}
|
||||
>
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-indigo-indigo-600 p-1 shadow-xs">
|
||||
<MessageFast className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-indigo-indigo-600 p-1 shadow-xs">
|
||||
<MessageFast className="h-3.5 w-3.5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('feature.annotation.title', { ns: 'appDebug' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiQuestionLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type Props = {
|
||||
icon: any
|
||||
@ -41,10 +41,15 @@ const FeatureCard = ({
|
||||
<div className="flex grow items-center system-sm-semibold text-text-secondary">
|
||||
{title}
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
popupContent={tooltip}
|
||||
>
|
||||
<div className="ml-0.5 p-px"><RiQuestionLine className="h-3.5 w-3.5 text-text-quaternary" /></div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="ml-0.5 p-px"><RiQuestionLine className="h-3.5 w-3.5 text-text-quaternary" /></div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { FileEntity } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import FileImageRender from './file-image-render'
|
||||
import FileTypeIcon from './file-type-icon'
|
||||
@ -49,27 +49,37 @@ const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPaddi
|
||||
return (
|
||||
<>
|
||||
{isImageFile && (
|
||||
<Tooltip
|
||||
popupContent={name}
|
||||
>
|
||||
<div key={id}>
|
||||
<FileImageRender
|
||||
className="h-8 w-8"
|
||||
imageUrl={base64Url || url || ''}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div key={id}>
|
||||
<FileImageRender
|
||||
className="h-8 w-8"
|
||||
imageUrl={base64Url || url || ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isImageFile && (
|
||||
<Tooltip
|
||||
popupContent={name}
|
||||
>
|
||||
<div key={id} className="rounded-md border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1.5 shadow-xs">
|
||||
<FileTypeIcon
|
||||
type={getFileAppearanceType(name, type)}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div key={id} className="rounded-md border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1.5 shadow-xs">
|
||||
<FileTypeIcon
|
||||
type={getFileAppearanceType(name, type)}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { t } from 'i18next'
|
||||
@ -7,7 +8,6 @@ import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { PdfHighlighter, PdfLoader } from './pdf-highlighter-adapter'
|
||||
|
||||
@ -76,29 +76,50 @@ const PdfPreview: FC<PdfPreviewProps> = ({
|
||||
}}
|
||||
</PdfLoader>
|
||||
</div>
|
||||
<Tooltip popupContent={t('operation.zoomOut', { ns: 'common' })}>
|
||||
<div
|
||||
className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={zoomOut}
|
||||
>
|
||||
<RiZoomOutLine className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={zoomOut}
|
||||
>
|
||||
<RiZoomOutLine className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.zoomOut', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('operation.zoomIn', { ns: 'common' })}>
|
||||
<div
|
||||
className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={zoomIn}
|
||||
>
|
||||
<RiZoomInLine className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={zoomIn}
|
||||
>
|
||||
<RiZoomInLine className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.zoomIn', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('operation.cancel', { ns: 'common' })}>
|
||||
<div
|
||||
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@ -2,6 +2,7 @@ import type { AnyFieldApi } from '@tanstack/react-form'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import { useForm } from '@tanstack/react-form'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import BaseField from '../base-field'
|
||||
|
||||
@ -117,6 +118,25 @@ describe('BaseField', () => {
|
||||
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render current select value when it is filtered out by show_on conditions', () => {
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
type: FormTypeEnum.select,
|
||||
name: 'mode',
|
||||
label: 'Mode',
|
||||
required: false,
|
||||
options: [
|
||||
{ label: 'Alpha', value: 'alpha' },
|
||||
{ label: 'Beta', value: 'beta', show_on: [{ variable: 'enabled', value: 'yes' }] },
|
||||
],
|
||||
},
|
||||
defaultValues: { mode: 'beta', enabled: 'no' },
|
||||
})
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'Mode' })).not.toHaveTextContent('beta')
|
||||
expect(screen.getByRole('combobox', { name: 'Mode' })).toHaveTextContent('common.placeholder.input')
|
||||
})
|
||||
|
||||
it('should render dynamic select loading state', () => {
|
||||
mockDynamicOptions.mockReturnValue({
|
||||
data: undefined,
|
||||
@ -238,6 +258,7 @@ describe('BaseField', () => {
|
||||
})
|
||||
|
||||
it('should render dynamic options and allow selecting one', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockDynamicOptions.mockReturnValue({
|
||||
data: {
|
||||
options: [
|
||||
@ -258,13 +279,42 @@ describe('BaseField', () => {
|
||||
defaultValues: { plugin_option: '' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('common.placeholder.input'))
|
||||
await user.click(screen.getByRole('combobox', { name: 'Plugin option' }))
|
||||
await user.click(screen.getByRole('option', { name: 'Option A' }))
|
||||
expect(screen.getByRole('combobox', { name: 'Plugin option' })).toHaveTextContent('Option A')
|
||||
})
|
||||
|
||||
it('should preserve multiple dynamic select values', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockDynamicOptions.mockReturnValue({
|
||||
data: {
|
||||
options: [
|
||||
{ label: { en_US: 'Option A', zh_Hans: '选项A' }, value: 'a' },
|
||||
{ label: { en_US: 'Option B', zh_Hans: '选项B' }, value: 'b' },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Option A'))
|
||||
|
||||
renderBaseField({
|
||||
formSchema: {
|
||||
type: FormTypeEnum.dynamicSelect,
|
||||
name: 'plugin_options',
|
||||
label: 'Plugin options',
|
||||
required: false,
|
||||
multiple: true,
|
||||
},
|
||||
defaultValues: { plugin_options: ['a'] },
|
||||
showCurrentValue: true,
|
||||
})
|
||||
expect(screen.getByText('Option A')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'Plugin options' })).toHaveTextContent('common.dynamicSelect.selected')
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: 'Plugin options' }))
|
||||
await user.click(screen.getByRole('option', { name: 'Option B' }))
|
||||
|
||||
expect(screen.getByTestId('field-value')).toHaveTextContent('a,b')
|
||||
})
|
||||
|
||||
it('should update boolean field when users choose false', async () => {
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
import type { AnyFieldApi } from '@tanstack/react-form'
|
||||
import type { FieldState, FormSchema, TypeWithI18N } from '@/app/components/base/form/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
import {
|
||||
isValidElement,
|
||||
@ -14,7 +23,6 @@ import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/
|
||||
import Input from '@/app/components/base/input'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import RadioE from '@/app/components/base/radio/ui'
|
||||
import PureSelect from '@/app/components/base/select/pure'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
|
||||
@ -43,6 +51,19 @@ const getTranslatedContent = ({ content, render }: {
|
||||
return ''
|
||||
}
|
||||
|
||||
type SelectOption = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const getSingleSelectValue = (value: unknown, options: SelectOption[]) => {
|
||||
return options.find(option => option.value === value)?.value ?? null
|
||||
}
|
||||
|
||||
const getSingleSelectLabel = (value: unknown, options: SelectOption[], placeholder: string | undefined) => {
|
||||
return options.find(option => option.value === value)?.label ?? placeholder
|
||||
}
|
||||
|
||||
const VALIDATE_STATUS_STYLE_MAP: Record<FormItemValidateStatusEnum, { componentClassName: string, textClassName: string, infoFieldName: string }> = {
|
||||
[FormItemValidateStatusEnum.Error]: {
|
||||
componentClassName: 'border-components-input-border-destructive focus:border-components-input-border-destructive',
|
||||
@ -121,7 +142,7 @@ const BaseField = ({
|
||||
if (!results[1])
|
||||
results[1] = t('placeholder.input', { ns: 'common' })
|
||||
return results
|
||||
}, [label, placeholder, tooltip, description, help, renderI18nObject])
|
||||
}, [label, placeholder, tooltip, description, help, renderI18nObject, t])
|
||||
|
||||
const watchedVariables = useMemo(() => {
|
||||
const variables = new Set<string>()
|
||||
@ -184,6 +205,13 @@ const BaseField = ({
|
||||
field.handleChange(value)
|
||||
onChange?.(field.name, value)
|
||||
}, [field, onChange])
|
||||
const dynamicPlaceholder = isDynamicOptionsLoading
|
||||
? t('dynamicSelect.loading', { ns: 'common' })
|
||||
: translatedPlaceholder
|
||||
const dynamicNoticeTitle = dynamicOptionsError
|
||||
? t('dynamicSelect.error', { ns: 'common' })
|
||||
: (!dynamicOptions.length ? t('dynamicSelect.noData', { ns: 'common' }) : null)
|
||||
const dynamicNoticeClassName = dynamicOptionsError ? 'text-text-destructive-secondary' : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -223,19 +251,58 @@ const BaseField = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.select && !multiple && (
|
||||
<PureSelect
|
||||
value={value}
|
||||
onChange={v => handleChange(v)}
|
||||
disabled={disabled}
|
||||
placeholder={translatedPlaceholder}
|
||||
options={memorizedOptions}
|
||||
triggerPopupSameWidth
|
||||
popupProps={{
|
||||
className: 'max-h-[320px] overflow-y-auto',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
formItemType === FormTypeEnum.select && (multiple
|
||||
? (
|
||||
<Select
|
||||
multiple
|
||||
items={memorizedOptions}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
disabled={disabled}
|
||||
onValueChange={handleChange}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-label={translatedLabel || field.name} className="px-2">
|
||||
<SelectValue placeholder={translatedPlaceholder}>
|
||||
{(selectedValue: string[]) => selectedValue.length
|
||||
? t('dynamicSelect.selected', { ns: 'common', count: selectedValue.length })
|
||||
: translatedPlaceholder}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="max-h-[320px] w-(--anchor-width) bg-components-panel-bg-blur">
|
||||
{memorizedOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItemText>{option.label}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
: (
|
||||
<Select
|
||||
items={memorizedOptions}
|
||||
value={getSingleSelectValue(value, memorizedOptions)}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => {
|
||||
if (next == null)
|
||||
return
|
||||
handleChange(next)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-label={translatedLabel || field.name} className="px-2">
|
||||
<SelectValue placeholder={translatedPlaceholder}>
|
||||
{nextValue => getSingleSelectLabel(nextValue, memorizedOptions, translatedPlaceholder)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="max-h-[320px] w-(--anchor-width) bg-components-panel-bg-blur">
|
||||
{memorizedOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItemText>{option.label}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
))
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.checkbox /* && multiple */ && (
|
||||
@ -249,24 +316,76 @@ const BaseField = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.dynamicSelect && (
|
||||
<PureSelect
|
||||
options={dynamicOptions}
|
||||
value={value}
|
||||
onChange={field.handleChange}
|
||||
disabled={disabled || isDynamicOptionsLoading}
|
||||
placeholder={
|
||||
isDynamicOptionsLoading
|
||||
? t('dynamicSelect.loading', { ns: 'common' })
|
||||
: translatedPlaceholder
|
||||
}
|
||||
{...(dynamicOptionsError
|
||||
? { popupProps: { title: t('dynamicSelect.error', { ns: 'common' }), titleClassName: 'text-text-destructive-secondary' } }
|
||||
: (!dynamicOptions.length ? { popupProps: { title: t('dynamicSelect.noData', { ns: 'common' }) } } : {}))}
|
||||
triggerPopupSameWidth
|
||||
multiple={multiple}
|
||||
/>
|
||||
)
|
||||
formItemType === FormTypeEnum.dynamicSelect && (multiple
|
||||
? (
|
||||
<Select
|
||||
multiple
|
||||
items={dynamicOptions}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
disabled={disabled || isDynamicOptionsLoading}
|
||||
onValueChange={field.handleChange}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-label={translatedLabel || field.name} className="px-2">
|
||||
<SelectValue placeholder={dynamicPlaceholder}>
|
||||
{(selectedValue: string[]) => selectedValue.length
|
||||
? t('dynamicSelect.selected', { ns: 'common', count: selectedValue.length })
|
||||
: dynamicPlaceholder}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width) bg-components-panel-bg-blur">
|
||||
{dynamicNoticeTitle && (
|
||||
<div className={cn(
|
||||
'flex h-[22px] items-center px-3 system-xs-medium-uppercase text-text-tertiary',
|
||||
dynamicNoticeClassName,
|
||||
)}
|
||||
>
|
||||
{dynamicNoticeTitle}
|
||||
</div>
|
||||
)}
|
||||
{dynamicOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItemText>{option.label}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
: (
|
||||
<Select
|
||||
items={dynamicOptions}
|
||||
value={getSingleSelectValue(value, dynamicOptions)}
|
||||
disabled={disabled || isDynamicOptionsLoading}
|
||||
onValueChange={(next) => {
|
||||
if (next == null)
|
||||
return
|
||||
field.handleChange(next)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-label={translatedLabel || field.name} className="px-2">
|
||||
<SelectValue placeholder={dynamicPlaceholder}>
|
||||
{nextValue => getSingleSelectLabel(nextValue, dynamicOptions, dynamicPlaceholder)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width) bg-components-panel-bg-blur">
|
||||
{dynamicNoticeTitle && (
|
||||
<div className={cn(
|
||||
'flex h-[22px] items-center px-3 system-xs-medium-uppercase text-text-tertiary',
|
||||
dynamicNoticeClassName,
|
||||
)}
|
||||
>
|
||||
{dynamicNoticeTitle}
|
||||
</div>
|
||||
)}
|
||||
{dynamicOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItemText>{option.label}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
))
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.radio && (
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import CustomSelectField from '../custom-select'
|
||||
|
||||
const mockField = {
|
||||
name: 'custom-select-field',
|
||||
state: {
|
||||
value: 'small',
|
||||
},
|
||||
handleChange: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('../../..', () => ({
|
||||
useFieldContext: () => mockField,
|
||||
}))
|
||||
|
||||
describe('CustomSelectField', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockField.state.value = 'small'
|
||||
})
|
||||
|
||||
it('should render select placeholder or selected value', () => {
|
||||
render(
|
||||
<CustomSelectField
|
||||
label="Size"
|
||||
options={[
|
||||
{ label: 'Small', value: 'small' },
|
||||
{ label: 'Large', value: 'large' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Small')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update value when users select another option', () => {
|
||||
render(
|
||||
<CustomSelectField
|
||||
label="Size"
|
||||
options={[
|
||||
{ label: 'Small', value: 'small' },
|
||||
{ label: 'Large', value: 'large' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Small'))
|
||||
fireEvent.click(screen.getByText('Large'))
|
||||
expect(mockField.handleChange).toHaveBeenCalledWith('large')
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SelectField from '../select'
|
||||
|
||||
const mockField = {
|
||||
@ -29,10 +30,27 @@ describe('SelectField', () => {
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox', { name: 'Mode' })).toHaveTextContent('Alpha')
|
||||
})
|
||||
|
||||
it('should update value when users select another option', () => {
|
||||
it('should render the option label when selected value is an empty string', () => {
|
||||
mockField.state.value = ''
|
||||
|
||||
render(
|
||||
<SelectField
|
||||
label="Mode"
|
||||
options={[
|
||||
{ label: 'No default selected', value: '' },
|
||||
{ label: 'Alpha', value: 'alpha' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'Mode' })).toHaveTextContent('No default selected')
|
||||
})
|
||||
|
||||
it('should update value when users select another option', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<SelectField
|
||||
label="Mode"
|
||||
@ -42,8 +60,8 @@ describe('SelectField', () => {
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Alpha'))
|
||||
fireEvent.click(screen.getByText('Beta'))
|
||||
await user.click(screen.getByRole('combobox', { name: 'Mode' }))
|
||||
await user.click(screen.getByRole('option', { name: 'Beta' }))
|
||||
expect(mockField.handleChange).toHaveBeenCalledWith('beta')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
import type { CustomSelectProps, Option } from '../../../select/custom'
|
||||
import type { LabelProps } from '../label'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useFieldContext } from '../..'
|
||||
import CustomSelect from '../../../select/custom'
|
||||
import Label from '../label'
|
||||
|
||||
type CustomSelectFieldProps<T extends Option> = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
options: T[]
|
||||
className?: string
|
||||
} & Omit<CustomSelectProps<T>, 'options' | 'value' | 'onChange'>
|
||||
|
||||
const CustomSelectField = <T extends Option>({
|
||||
label,
|
||||
labelOptions,
|
||||
options,
|
||||
className,
|
||||
...selectProps
|
||||
}: CustomSelectFieldProps<T>) => {
|
||||
const field = useFieldContext<string>()
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor={field.name}
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<CustomSelect<T>
|
||||
value={field.state.value}
|
||||
options={options}
|
||||
onChange={value => field.handleChange(value)}
|
||||
{...selectProps}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomSelectField
|
||||
@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import InputTypeSelectField from '../index'
|
||||
|
||||
const mockField = {
|
||||
@ -20,17 +21,20 @@ describe('InputTypeSelectField', () => {
|
||||
})
|
||||
|
||||
it('should render label and selected option', () => {
|
||||
render(<InputTypeSelectField label="Input type" supportFile={true} />)
|
||||
const { container } = render(<InputTypeSelectField label="Input type" supportFile={true} />)
|
||||
|
||||
expect(screen.getByText('Input type')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.variableConfig.text-input')).toBeInTheDocument()
|
||||
expect(container.querySelector('[role="combobox"] span > div')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('[role="combobox"] > span > span')).toHaveClass('flex', 'min-w-0', 'items-center', 'gap-x-0.5')
|
||||
})
|
||||
|
||||
it('should update value when users choose another input type', () => {
|
||||
it('should update value when users choose another input type', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<InputTypeSelectField label="Input type" supportFile={true} />)
|
||||
|
||||
fireEvent.click(screen.getByText('appDebug.variableConfig.text-input'))
|
||||
fireEvent.click(screen.getByText('appDebug.variableConfig.number'))
|
||||
await user.click(screen.getByRole('combobox', { name: 'Input type' }))
|
||||
await user.click(screen.getByRole('option', { name: /appDebug.variableConfig.number/ }))
|
||||
|
||||
expect(mockField.handleChange).toHaveBeenCalledWith('number')
|
||||
})
|
||||
|
||||
@ -5,7 +5,7 @@ const MockIcon = () => <svg aria-label="mock icon" />
|
||||
|
||||
describe('InputTypeSelect Trigger', () => {
|
||||
it('should show placeholder text when no option is selected', () => {
|
||||
render(<Trigger option={undefined} open={false} />)
|
||||
render(<Trigger option={undefined} />)
|
||||
expect(screen.getByText('common.placeholder.select')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -18,11 +18,25 @@ describe('InputTypeSelect Trigger', () => {
|
||||
Icon: MockIcon,
|
||||
type: 'string',
|
||||
}}
|
||||
open={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Text Input')).toBeInTheDocument()
|
||||
expect(screen.getByText('string')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep selected option parts in one inline flex row', () => {
|
||||
render(
|
||||
<Trigger
|
||||
option={{
|
||||
value: 'text-input',
|
||||
label: 'Text Input',
|
||||
Icon: MockIcon,
|
||||
type: 'string',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Text Input').parentElement).toHaveClass('flex', 'min-w-0', 'items-center', 'gap-x-0.5')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import type { CustomSelectProps } from '../../../../select/custom'
|
||||
import type { LabelProps } from '../../label'
|
||||
import type { FileTypeSelectOption, InputType } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { useFieldContext } from '../../..'
|
||||
import CustomSelect from '../../../../select/custom'
|
||||
import Label from '../../label'
|
||||
import { useInputTypeOptions } from './hooks'
|
||||
import Option from './option'
|
||||
@ -15,24 +18,19 @@ type InputTypeSelectFieldProps = {
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
supportFile: boolean
|
||||
className?: string
|
||||
} & Omit<CustomSelectProps<FileTypeSelectOption>, 'options' | 'value' | 'onChange' | 'CustomTrigger' | 'CustomOption'>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const InputTypeSelectField = ({
|
||||
label,
|
||||
labelOptions,
|
||||
supportFile,
|
||||
className,
|
||||
...customSelectProps
|
||||
disabled,
|
||||
}: InputTypeSelectFieldProps) => {
|
||||
const field = useFieldContext<InputType>()
|
||||
const inputTypeOptions = useInputTypeOptions(supportFile)
|
||||
|
||||
const renderTrigger = useCallback((option: FileTypeSelectOption | undefined, open: boolean) => {
|
||||
return <Trigger option={option} open={open} />
|
||||
}, [])
|
||||
const renderOption = useCallback((option: FileTypeSelectOption) => {
|
||||
return <Option option={option} />
|
||||
}, [])
|
||||
const selected = inputTypeOptions.find(option => option.value === field.state.value)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
@ -41,22 +39,31 @@ const InputTypeSelectField = ({
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<CustomSelect<FileTypeSelectOption>
|
||||
value={field.state.value}
|
||||
options={inputTypeOptions}
|
||||
onChange={value => field.handleChange(value as InputType)}
|
||||
triggerProps={{
|
||||
className: 'gap-x-0.5',
|
||||
<Select
|
||||
items={inputTypeOptions}
|
||||
value={field.state.value ?? null}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => {
|
||||
if (next == null)
|
||||
return
|
||||
field.handleChange(next as InputType)
|
||||
}}
|
||||
popupProps={{
|
||||
className: 'w-[368px]',
|
||||
wrapperClassName: 'z-9999999',
|
||||
itemClassName: 'gap-x-1',
|
||||
}}
|
||||
CustomTrigger={renderTrigger}
|
||||
CustomOption={renderOption}
|
||||
{...customSelectProps}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger id={field.name} className="gap-x-0.5 px-2">
|
||||
<Trigger option={selected} />
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-[368px] bg-components-panel-bg-blur shadow-shadow-shadow-5">
|
||||
{inputTypeOptions.map((option: FileTypeSelectOption) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="gap-x-1"
|
||||
>
|
||||
<Option option={option} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,43 +1,27 @@
|
||||
import type { FileTypeSelectOption } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
|
||||
type TriggerProps = {
|
||||
option: FileTypeSelectOption | undefined
|
||||
open: boolean
|
||||
}
|
||||
|
||||
const Trigger = ({
|
||||
option,
|
||||
open,
|
||||
}: TriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!option)
|
||||
return <span className="grow p-1">{t('placeholder.select', { ns: 'common' })}</span>
|
||||
|
||||
return (
|
||||
<>
|
||||
{option
|
||||
? (
|
||||
<>
|
||||
<option.Icon className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow p-1">{option.label}</span>
|
||||
<div className="pr-0.5">
|
||||
<Badge text={option.type} uppercase={false} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<span className="grow p-1">{t('placeholder.select', { ns: 'common' })}</span>
|
||||
)}
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
open && 'text-text-secondary',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
<span className="flex min-w-0 items-center gap-x-0.5">
|
||||
<option.Icon className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span className="min-w-0 grow truncate p-1">{option.label}</span>
|
||||
<span className="relative inline-flex h-5 shrink-0 items-center rounded-[5px] border border-divider-deep px-[5px] system-xs-medium leading-3 whitespace-nowrap text-text-tertiary">
|
||||
{option.type}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,18 +1,46 @@
|
||||
import type { Option, PureSelectProps } from '../../../select/pure'
|
||||
import type { LabelProps } from '../label'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFieldContext } from '../..'
|
||||
import PureSelect from '../../../select/pure'
|
||||
import Label from '../label'
|
||||
|
||||
export type Option = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const getSelectedValue = (value: string | undefined, options: Option[]) => {
|
||||
return options.some(option => option.value === value) ? value : null
|
||||
}
|
||||
|
||||
const getDisplayLabel = (value: string | null, options: Option[], placeholder: string) => {
|
||||
return options.find(option => option.value === value)?.label ?? placeholder
|
||||
}
|
||||
|
||||
type SelectFieldPopupProps = {
|
||||
className?: string
|
||||
title?: string
|
||||
titleClassName?: string
|
||||
}
|
||||
|
||||
type SelectFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
options: Option[]
|
||||
onChange?: (value: string) => void
|
||||
className?: string
|
||||
} & Omit<PureSelectProps, 'options' | 'value' | 'onChange' | 'multiple'> & {
|
||||
multiple?: false
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
popupProps?: SelectFieldPopupProps
|
||||
}
|
||||
|
||||
const SelectField = ({
|
||||
@ -21,9 +49,13 @@ const SelectField = ({
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
...selectProps
|
||||
placeholder,
|
||||
disabled,
|
||||
popupProps,
|
||||
}: SelectFieldProps) => {
|
||||
const { t } = useTranslation()
|
||||
const field = useFieldContext<string>()
|
||||
const placeholderText = placeholder || t('placeholder.select', { ns: 'common' })
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
@ -32,15 +64,41 @@ const SelectField = ({
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<PureSelect
|
||||
value={field.state.value}
|
||||
options={options}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value)
|
||||
onChange?.(value)
|
||||
<Select
|
||||
items={options}
|
||||
value={getSelectedValue(field.state.value, options)}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => {
|
||||
if (next == null)
|
||||
return
|
||||
field.handleChange(next)
|
||||
onChange?.(next)
|
||||
}}
|
||||
{...selectProps}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger id={field.name} className="px-2">
|
||||
<SelectValue placeholder={placeholderText}>
|
||||
{(nextValue: string | null) => getDisplayLabel(nextValue, options, placeholderText)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName={cn('w-(--anchor-width) bg-components-panel-bg-blur', popupProps?.className)}>
|
||||
{popupProps?.title && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[22px] items-center px-3 system-xs-medium-uppercase text-text-tertiary',
|
||||
popupProps.titleClassName,
|
||||
)}
|
||||
>
|
||||
{popupProps.title}
|
||||
</div>
|
||||
)}
|
||||
{options.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItemText>{option.label}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Option } from '../../../select/pure'
|
||||
import type { Option } from '../../components/field/select'
|
||||
import type { CustomActionsProps } from '../../components/form/actions'
|
||||
import type { TransferMethod } from '@/types/app'
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { createFormHook, createFormHookContexts } from '@tanstack/react-form'
|
||||
import CheckboxField from './components/field/checkbox'
|
||||
import CustomSelectField from './components/field/custom-select'
|
||||
import FileTypesField from './components/field/file-types'
|
||||
import FileUploaderField from './components/field/file-uploader'
|
||||
import InputTypeSelectField from './components/field/input-type-select'
|
||||
@ -26,7 +25,6 @@ export const { useAppForm, withForm } = createFormHook({
|
||||
NumberInputField,
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
CustomSelectField,
|
||||
OptionsField,
|
||||
InputTypeSelectField,
|
||||
FileTypesField,
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type ImageListProps = {
|
||||
@ -82,10 +82,15 @@ const ImageList: FC<ImageListProps> = ({
|
||||
<span className="i-ri-loader-2-line h-5 w-5 animate-spin text-white" data-testid="image-loader" />
|
||||
)}
|
||||
{item.progress === -1 && (
|
||||
<Tooltip
|
||||
popupContent={t('imageUploader.pasteImageLinkInvalid', { ns: 'common' })}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-[#DC6803]" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<AlertTriangle className="h-4 w-4 text-[#DC6803]" />
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('imageUploader.pasteImageLinkInvalid', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { FC } from 'react'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { t } from 'i18next'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
|
||||
type ImagePreviewProps = {
|
||||
@ -198,55 +198,97 @@ const ImagePreview: FC<ImagePreviewProps> = ({
|
||||
}}
|
||||
data-testid="image-preview-image"
|
||||
/>
|
||||
<Tooltip popupContent={t('operation.copyImage', { ns: 'common' })}>
|
||||
<div
|
||||
className="absolute top-6 right-48 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={imageCopy}
|
||||
>
|
||||
{isCopied
|
||||
? <span className="i-ri-file-copy-line h-4 w-4 text-green-500" data-testid="image-preview-copied-icon" />
|
||||
: <span className="i-ri-file-copy-line h-4 w-4 text-gray-500" data-testid="image-preview-copy-button" />}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className="absolute top-6 right-48 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={imageCopy}
|
||||
>
|
||||
{isCopied
|
||||
? <span className="i-ri-file-copy-line h-4 w-4 text-green-500" data-testid="image-preview-copied-icon" />
|
||||
: <span className="i-ri-file-copy-line h-4 w-4 text-gray-500" data-testid="image-preview-copy-button" />}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.copyImage', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('operation.zoomOut', { ns: 'common' })}>
|
||||
<div
|
||||
className="absolute top-6 right-40 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={zoomOut}
|
||||
>
|
||||
<span className="i-ri-zoom-out-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-out-button" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className="absolute top-6 right-40 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={zoomOut}
|
||||
>
|
||||
<span className="i-ri-zoom-out-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-out-button" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.zoomOut', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('operation.zoomIn', { ns: 'common' })}>
|
||||
<div
|
||||
className="absolute top-6 right-32 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={zoomIn}
|
||||
>
|
||||
<span className="i-ri-zoom-in-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-in-button" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className="absolute top-6 right-32 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={zoomIn}
|
||||
>
|
||||
<span className="i-ri-zoom-in-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-in-button" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.zoomIn', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('operation.download', { ns: 'common' })}>
|
||||
<div
|
||||
className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={downloadImage}
|
||||
>
|
||||
<span className="i-ri-download-cloud-2-line h-4 w-4 text-gray-500" data-testid="image-preview-download-button" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={downloadImage}
|
||||
>
|
||||
<span className="i-ri-download-cloud-2-line h-4 w-4 text-gray-500" data-testid="image-preview-download-button" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.download', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('operation.openInNewTab', { ns: 'common' })}>
|
||||
<div
|
||||
className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={openInNewTab}
|
||||
>
|
||||
<span className="i-ri-add-box-line h-4 w-4 text-gray-500" data-testid="image-preview-open-in-tab-button" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={openInNewTab}
|
||||
>
|
||||
<span className="i-ri-add-box-line h-4 w-4 text-gray-500" data-testid="image-preview-open-in-tab-button" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.openInNewTab', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('operation.cancel', { ns: 'common' })}>
|
||||
<div
|
||||
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="image-preview-close-button" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="image-preview-close-button" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@ -65,12 +65,13 @@ export function Infotip({
|
||||
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>
|
||||
className={cn(
|
||||
'inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<span aria-hidden className={cn('i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary', iconClassName)} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
popupClassName={cn('max-w-[300px] rounded-md px-3 py-2 system-xs-regular text-text-tertiary', popupClassName)}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiVolumeUpLine,
|
||||
} from '@remixicon/react'
|
||||
@ -6,7 +7,6 @@ import { t } from 'i18next'
|
||||
import { useState } from 'react'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
|
||||
type AudioBtnProps = {
|
||||
@ -78,20 +78,27 @@ const AudioBtn = ({
|
||||
}[audioState]
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={tooltipContent}
|
||||
>
|
||||
<ActionButton
|
||||
state={
|
||||
audioState === 'loading' || audioState === 'playing'
|
||||
? ActionButtonState.Active
|
||||
: ActionButtonState.Default
|
||||
}
|
||||
onClick={handleToggle}
|
||||
disabled={audioState === 'loading'}
|
||||
>
|
||||
<RiVolumeUpLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="inline-flex">
|
||||
<ActionButton
|
||||
state={
|
||||
audioState === 'loading' || audioState === 'playing'
|
||||
? ActionButtonState.Active
|
||||
: ActionButtonState.Default
|
||||
}
|
||||
onClick={handleToggle}
|
||||
disabled={audioState === 'loading'}
|
||||
>
|
||||
<RiVolumeUpLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export type PortalToFollowElemOptions = {
|
||||
type PortalToFollowElemOptions = {
|
||||
/*
|
||||
* top, bottom, left, right
|
||||
* start, end. Default is middle
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { QRCodeCanvas as QRCode } from 'qrcode.react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
|
||||
type Props = {
|
||||
@ -54,28 +54,33 @@ const ShareQRCode = ({ content }: Props) => {
|
||||
const safeTooltipText = tooltipText || ''
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={safeTooltipText}
|
||||
>
|
||||
<div className="relative h-6 w-6" onClick={toggleQRCode} data-testid="qrcode-container">
|
||||
<ActionButton>
|
||||
<span className="i-ri-qr-code-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
{isShow && (
|
||||
<div
|
||||
ref={qrCodeRef}
|
||||
className="absolute top-8 -right-8 z-10 flex w-[232px] flex-col items-center rounded-lg bg-components-panel-bg p-4 shadow-xs"
|
||||
onClick={handlePanelClick}
|
||||
>
|
||||
<QRCode size={160} value={content} className="mb-2" />
|
||||
<div className="flex items-center system-xs-regular">
|
||||
<div className="text-text-tertiary">{t('overview.appInfo.qrcode.scan', { ns: 'appOverview' })}</div>
|
||||
<div className="text-text-tertiary">·</div>
|
||||
<div className="cursor-pointer text-text-accent-secondary" onClick={downloadQR}>{t('overview.appInfo.qrcode.download', { ns: 'appOverview' })}</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="relative h-6 w-6" onClick={toggleQRCode} data-testid="qrcode-container">
|
||||
<ActionButton>
|
||||
<span className="i-ri-qr-code-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
{isShow && (
|
||||
<div
|
||||
ref={qrCodeRef}
|
||||
className="absolute top-8 -right-8 z-10 flex w-[232px] flex-col items-center rounded-lg bg-components-panel-bg p-4 shadow-xs"
|
||||
onClick={handlePanelClick}
|
||||
>
|
||||
<QRCode size={160} value={content} className="mb-2" />
|
||||
<div className="flex items-center system-xs-regular">
|
||||
<div className="text-text-tertiary">{t('overview.appInfo.qrcode.scan', { ns: 'appOverview' })}</div>
|
||||
<div className="text-text-tertiary">·</div>
|
||||
<div className="cursor-pointer text-text-accent-secondary" onClick={downloadQR}>{t('overview.appInfo.qrcode.download', { ns: 'appOverview' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
<TooltipContent>
|
||||
{safeTooltipText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
import type { Option } from '../custom'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CustomSelect from '../custom'
|
||||
|
||||
const options: Option[] = [
|
||||
{ label: 'First option', value: 'first' },
|
||||
{ label: 'Second option', value: 'second' },
|
||||
]
|
||||
|
||||
describe('CustomSelect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior and value fallback.
|
||||
describe('Rendering', () => {
|
||||
it('should show the placeholder when value is undefined or not found', () => {
|
||||
const { rerender } = render(
|
||||
<CustomSelect options={options} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTitle(/select/i)).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<CustomSelect options={options} value="missing" />,
|
||||
)
|
||||
|
||||
expect(screen.getByTitle(/select/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions for opening and selecting options.
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange and close the popup when an option is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<CustomSelect options={options} onChange={onChange} />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTitle(/select/i))
|
||||
expect(screen.getByTitle('Second option')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTitle('Second option'))
|
||||
expect(onChange).toHaveBeenCalledWith('second')
|
||||
expect(screen.queryByTitle('Second option')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Controlled container props behavior.
|
||||
describe('Container Props', () => {
|
||||
it('should delegate open-state changes through containerProps.onOpenChange', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
render(
|
||||
<CustomSelect
|
||||
options={options}
|
||||
containerProps={{ open: true, onOpenChange }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTitle('First option')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTitle(/select/i))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Custom rendering hooks for trigger and options.
|
||||
describe('Custom Renderers', () => {
|
||||
it('should render CustomTrigger and CustomOption with selected state', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<CustomSelect
|
||||
options={options}
|
||||
value="first"
|
||||
CustomTrigger={(option, open) => <div>{`${option?.label ?? 'none'}-${open ? 'open' : 'closed'}`}</div>}
|
||||
CustomOption={(option, selected) => <div>{`${option.label}-${selected ? 'selected' : 'idle'}`}</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('First option-closed')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('First option-closed'))
|
||||
|
||||
expect(screen.getByText('First option-open')).toBeInTheDocument()
|
||||
expect(screen.getByText('First option-selected')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second option-idle')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Class-based customization props.
|
||||
describe('Style Props', () => {
|
||||
it('should apply trigger and popup class names from props', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<CustomSelect
|
||||
options={options}
|
||||
triggerProps={{ className: 'trigger-class' }}
|
||||
popupProps={{
|
||||
wrapperClassName: 'wrapper-class',
|
||||
className: 'popup-class',
|
||||
itemClassName: 'item-class',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const triggerLabel = screen.getByTitle(/select/i)
|
||||
const trigger = triggerLabel.parentElement
|
||||
expect(trigger).toHaveClass('trigger-class')
|
||||
|
||||
await user.click(triggerLabel)
|
||||
|
||||
expect(document.querySelector('.wrapper-class')).toBeInTheDocument()
|
||||
expect(document.querySelector('.popup-class')).toBeInTheDocument()
|
||||
expect(document.querySelectorAll('.item-class')).toHaveLength(options.length)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,957 +0,0 @@
|
||||
import type { Item } from '../index'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Select, { PortalSelect, SimpleSelect } from '../index'
|
||||
|
||||
const items: Item[] = [
|
||||
{ value: 'apple', name: 'Apple' },
|
||||
{ value: 'banana', name: 'Banana' },
|
||||
{ value: 'citrus', name: 'Citrus' },
|
||||
]
|
||||
|
||||
describe('Select', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should show the default selected item when defaultValue matches an item', () => {
|
||||
render(
|
||||
<Select
|
||||
items={items}
|
||||
defaultValue="banana"
|
||||
allowSearch={false}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTitle('Banana'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render null selectedItem when defaultValue does not match any item', () => {
|
||||
render(
|
||||
<Select
|
||||
items={items}
|
||||
defaultValue="missing"
|
||||
allowSearch={false}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
// No item title should appear for a non-matching default
|
||||
expect(screen.queryByTitle('Apple')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Banana')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with allowSearch=true (input mode)', () => {
|
||||
render(
|
||||
<Select
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
allowSearch={true}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom bgClassName', () => {
|
||||
render(
|
||||
<Select
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
allowSearch={false}
|
||||
onSelect={vi.fn()}
|
||||
bgClassName="bg-custom-color"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTitle('Apple'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onSelect when choosing an option from default select', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Select
|
||||
items={items}
|
||||
defaultValue="banana"
|
||||
allowSearch={false}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTitle('Banana'))
|
||||
await user.click(screen.getByText('Citrus'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
value: 'citrus',
|
||||
name: 'Citrus',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not open or select when default select is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Select
|
||||
items={items}
|
||||
defaultValue="banana"
|
||||
allowSearch={false}
|
||||
disabled={true}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTitle('Banana'))
|
||||
|
||||
expect(screen.queryByText('Citrus')).not.toBeInTheDocument()
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should filter items when searching with allowSearch=true', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<Select
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
allowSearch={true}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// First, click the chevron button to open the dropdown
|
||||
const buttons = screen.getAllByRole('button')
|
||||
await user.click(buttons[0]!)
|
||||
|
||||
// Now type in the search input to filter
|
||||
const input = screen.getByRole('combobox')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'ban')
|
||||
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
// Citrus should be filtered away
|
||||
expect(screen.queryByText('Citrus')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not filter or update query when disabled and allowSearch=true', async () => {
|
||||
render(
|
||||
<Select
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
allowSearch={true}
|
||||
disabled={true}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('combobox') as HTMLInputElement
|
||||
|
||||
// we must use fireEvent because userEvent throws on disabled inputs
|
||||
fireEvent.change(input, { target: { value: 'ban' } })
|
||||
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
// We just want to ensure it doesn't throw and covers the !disabled branch in onChange.
|
||||
// Since it's disabled, no search dropdown should appear.
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call onSelect when a disabled Combobox value changes externally', () => {
|
||||
// In Headless UI, disabled elements do not fire events via React.
|
||||
// To cover the defensive `if (!disabled)` branches inside the callbacks,
|
||||
// we temporarily remove the disabled attribute from the DOM to force the event through.
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Select
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
allowSearch={false}
|
||||
disabled={true}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getAllByRole('button')[0] as HTMLButtonElement
|
||||
button.removeAttribute('disabled')
|
||||
button.removeAttribute('aria-disabled')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not open dropdown when clicking ComboboxButton while disabled and allowSearch=false', () => {
|
||||
// Covers line 128-141 where disabled check prevents open state toggle
|
||||
render(
|
||||
<Select
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
allowSearch={false}
|
||||
disabled={true}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The main trigger button should be disabled
|
||||
const button = screen.getAllByRole('button')[0] as HTMLButtonElement
|
||||
button.removeAttribute('disabled')
|
||||
|
||||
const chevron = screen.getAllByRole('button')[1] as HTMLButtonElement
|
||||
chevron.removeAttribute('disabled')
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(chevron)
|
||||
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
// Dropdown options should not appear because the internal `if (!disabled)` guards it
|
||||
expect(screen.queryByText('Banana')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing item nicely in renderTrigger', () => {
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="non-existent"
|
||||
onSelect={vi.fn()}
|
||||
renderTrigger={(selected) => {
|
||||
return (
|
||||
<span>
|
||||
{/* eslint-disable-next-line style/jsx-one-expression-per-line */}
|
||||
Custom: {selected?.name ?? 'Fallback'}
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Custom: Fallback'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with custom renderOption', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<Select
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
allowSearch={false}
|
||||
onSelect={vi.fn()}
|
||||
renderOption={({ item, selected }) => (
|
||||
<span data-testid={`custom-opt-${item.value}`}>
|
||||
{item.name}
|
||||
{selected ? ' ✓' : ''}
|
||||
</span>
|
||||
)}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTitle('Apple'))
|
||||
|
||||
expect(screen.getByTestId('custom-opt-apple'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('custom-opt-banana'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show ChevronUpIcon when open and ChevronDownIcon when closed', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<Select
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
allowSearch={false}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Initially closed — should have a chevron button
|
||||
await user.click(screen.getByTitle('Apple'))
|
||||
// Dropdown is now open
|
||||
// Dropdown is now open
|
||||
expect(screen.getByText('Banana'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// SimpleSelect (Listbox-based)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
describe('SimpleSelect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render i18n placeholder when no selection exists', () => {
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="missing"
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/select/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom placeholder when provided', () => {
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="missing"
|
||||
placeholder="Pick one"
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Pick one'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render selected item name when defaultValue matches', () => {
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="banana"
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Banana'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with isLoading=true showing spinner', () => {
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
onSelect={vi.fn()}
|
||||
isLoading={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Loader icon should be rendered (RiLoader4Line has aria hidden)
|
||||
// Loader icon should be rendered (RiLoader4Line has aria hidden)
|
||||
expect(screen.getByText('Apple'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render group items as non-selectable headers', async () => {
|
||||
const user = userEvent.setup()
|
||||
const groupItems: Item[] = [
|
||||
{ value: 'fruits-group', name: 'Fruits', isGroup: true },
|
||||
{ value: 'apple', name: 'Apple' },
|
||||
{ value: 'banana', name: 'Banana' },
|
||||
]
|
||||
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={groupItems}
|
||||
defaultValue="apple"
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(screen.getByText('Fruits'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render ListboxOptions when disabled', () => {
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
disabled={true}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Apple'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open SimpleSelect when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
disabled={true}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
// Banana should not be visible as it won't open
|
||||
expect(screen.queryByText('Banana')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not trigger onSelect via onChange when Listbox is disabled', () => {
|
||||
// Covers line 228 (!disabled check) inside Listbox onChange
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
disabled={true}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button') as HTMLButtonElement
|
||||
button.removeAttribute('disabled')
|
||||
button.removeAttribute('aria-disabled')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onSelect and update display when an option is chosen', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="missing"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('Apple'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
value: 'apple',
|
||||
name: 'Apple',
|
||||
}))
|
||||
expect(screen.getByText('Apple'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass open state into renderTrigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="missing"
|
||||
onSelect={vi.fn()}
|
||||
renderTrigger={(selected, open) => (
|
||||
<span>{`${selected?.name ?? 'none'}-${open ? 'open' : 'closed'}`}</span>
|
||||
)}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('none-closed'))!.toBeInTheDocument()
|
||||
await user.click(screen.getByText('none-closed'))
|
||||
expect(screen.getByText('none-open'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clear selection when XMark is clicked (notClearable=false)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
onSelect={onSelect}
|
||||
notClearable={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The clear button (XMarkIcon) should be visible when an item is selected
|
||||
const clearBtn = screen.getByRole('button').querySelector('[aria-hidden="false"]')
|
||||
expect(clearBtn)!.toBeInTheDocument()
|
||||
|
||||
await user.click(clearBtn!)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({ name: '', value: '' })
|
||||
})
|
||||
|
||||
it('should not show clear button when notClearable is true', () => {
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
onSelect={vi.fn()}
|
||||
notClearable={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const clearBtn = screen.getByRole('button').querySelector('[aria-hidden="false"]')
|
||||
expect(clearBtn).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide check marks when hideChecked is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
onSelect={vi.fn()}
|
||||
hideChecked={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
// The selected item should be visible but without a check icon
|
||||
expect(screen.getAllByText('Apple').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render with custom renderOption in SimpleSelect', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
onSelect={vi.fn()}
|
||||
renderOption={({ item, selected }) => (
|
||||
<span data-testid={`simple-opt-${item.value}`}>
|
||||
{item.name}
|
||||
{selected ? ' (selected)' : ''}
|
||||
</span>
|
||||
)}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(screen.getByTestId('simple-opt-apple'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('simple-opt-banana'))!.toBeInTheDocument()
|
||||
// Verify the custom render shows selected state
|
||||
// Verify the custom render shows selected state
|
||||
expect(screen.getByTestId('simple-opt-apple'))!.toHaveTextContent('Apple (selected)')
|
||||
})
|
||||
|
||||
it('should call onOpenChange when the button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={items}
|
||||
defaultValue="apple"
|
||||
onSelect={vi.fn()}
|
||||
onOpenChange={onOpenChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(onOpenChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle disabled items that cannot be selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
const disabledItems: Item[] = [
|
||||
{ value: 'apple', name: 'Apple' },
|
||||
{ value: 'banana', name: 'Banana', disabled: true },
|
||||
{ value: 'citrus', name: 'Citrus' },
|
||||
]
|
||||
|
||||
render(
|
||||
<SimpleSelect
|
||||
items={disabledItems}
|
||||
defaultValue="apple"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
// Banana should be rendered but not selectable
|
||||
// Banana should be rendered but not selectable
|
||||
expect(screen.getByText('Banana'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// PortalSelect
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
describe('PortalSelect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should show placeholder when value is empty', () => {
|
||||
render(
|
||||
<PortalSelect
|
||||
value=""
|
||||
items={items}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/select/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show selected item name when value matches', () => {
|
||||
render(
|
||||
<PortalSelect
|
||||
value="banana"
|
||||
items={items}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTitle('Banana'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with custom placeholder', () => {
|
||||
render(
|
||||
<PortalSelect
|
||||
value=""
|
||||
items={items}
|
||||
onSelect={vi.fn()}
|
||||
placeholder="Choose fruit"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Choose fruit'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with renderTrigger', () => {
|
||||
render(
|
||||
<PortalSelect
|
||||
value="apple"
|
||||
items={items}
|
||||
onSelect={vi.fn()}
|
||||
renderTrigger={item => (
|
||||
<span data-testid="custom-trigger">{item?.name ?? 'None'}</span>
|
||||
)}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-trigger'))!.toHaveTextContent('Apple')
|
||||
})
|
||||
|
||||
it('should show INSTALLED badge when installedValue differs from selected value', () => {
|
||||
render(
|
||||
<PortalSelect
|
||||
value="banana"
|
||||
items={items}
|
||||
onSelect={vi.fn()}
|
||||
installedValue="apple"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTitle('Banana'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply triggerClassNameFn', () => {
|
||||
const triggerClassNameFn = vi.fn((open: boolean) => open ? 'trigger-open' : 'trigger-closed')
|
||||
|
||||
render(
|
||||
<PortalSelect
|
||||
value="apple"
|
||||
items={items}
|
||||
onSelect={vi.fn()}
|
||||
triggerClassNameFn={triggerClassNameFn}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(triggerClassNameFn).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onSelect when choosing an option from portal dropdown', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<PortalSelect
|
||||
value=""
|
||||
items={items}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText(/select/i))
|
||||
await user.click(screen.getByText('Citrus'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
value: 'citrus',
|
||||
name: 'Citrus',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not open the portal dropdown when readonly is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<PortalSelect
|
||||
value=""
|
||||
items={items}
|
||||
readonly={true}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText(/select/i))
|
||||
expect(screen.queryByTitle('Citrus')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show check mark for selected item when hideChecked is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<PortalSelect
|
||||
value="banana"
|
||||
items={items}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTitle('Banana'))
|
||||
// Banana option in the dropdown should be displayed
|
||||
const allBananas = screen.getAllByText('Banana')
|
||||
expect(allBananas.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should hide check marks when hideChecked is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<PortalSelect
|
||||
value="banana"
|
||||
items={items}
|
||||
onSelect={vi.fn()}
|
||||
hideChecked={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTitle('Banana'))
|
||||
expect(screen.getAllByText('Banana').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should display INSTALLED badge in dropdown for installed items', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<PortalSelect
|
||||
value="banana"
|
||||
items={items}
|
||||
onSelect={vi.fn()}
|
||||
installedValue="apple"
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTitle('Banana'))
|
||||
// The installed badge should appear in the dropdown
|
||||
// The installed badge should appear in the dropdown
|
||||
expect(screen.getByText('INSTALLED'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render item.extra content in dropdown', async () => {
|
||||
const user = userEvent.setup()
|
||||
const extraItems: Item[] = [
|
||||
{ value: 'apple', name: 'Apple', extra: <span data-testid="extra-apple">Extra</span> },
|
||||
{ value: 'banana', name: 'Banana' },
|
||||
]
|
||||
|
||||
render(
|
||||
<PortalSelect
|
||||
value=""
|
||||
items={extraItems}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText(/select/i))
|
||||
expect(screen.getByTestId('extra-apple'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,116 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import LocaleSigninSelect from '../locale-signin'
|
||||
|
||||
const localeItems = [
|
||||
{ value: 'en-US', name: 'English (US)' },
|
||||
{ value: 'zh-Hans', name: '简体中文' },
|
||||
{ value: 'ja-JP', name: '日本語' },
|
||||
]
|
||||
|
||||
describe('LocaleSigninSelect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior for selected value and fallback state.
|
||||
describe('Rendering', () => {
|
||||
it('should render selected locale name when value matches an item', () => {
|
||||
render(
|
||||
<LocaleSigninSelect
|
||||
items={localeItems}
|
||||
value="en-US"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /english \(us\)/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger without selected label when value is not found', () => {
|
||||
render(
|
||||
<LocaleSigninSelect
|
||||
items={localeItems}
|
||||
value="missing"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('button')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
expect(trigger).not.toHaveTextContent('English (US)')
|
||||
})
|
||||
})
|
||||
|
||||
// Menu interactions and callback behavior.
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with selected locale value when clicking an option', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<LocaleSigninSelect
|
||||
items={localeItems}
|
||||
value="en-US"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /english \(us\)/i }))
|
||||
await user.click(screen.getByRole('menuitem', { name: '日本語' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('ja-JP')
|
||||
})
|
||||
|
||||
it('should render all locale options when menu is opened', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<LocaleSigninSelect
|
||||
items={localeItems}
|
||||
value="en-US"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /english \(us\)/i }))
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'English (US)' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: '简体中文' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: '日本語' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge behavior for missing callback and empty data.
|
||||
describe('Edge Cases', () => {
|
||||
it('should not throw when onChange is undefined and option is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<LocaleSigninSelect
|
||||
items={localeItems}
|
||||
value="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /english \(us\)/i }))
|
||||
await user.click(screen.getByRole('menuitem', { name: '简体中文' }))
|
||||
// No assertion needed — test verifies no exception is thrown during selection without onChange.
|
||||
})
|
||||
|
||||
it('should render no options when items are empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<LocaleSigninSelect
|
||||
items={[]}
|
||||
value="en-US"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(screen.queryAllByRole('menuitem')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,197 +0,0 @@
|
||||
import type { Option } from '../pure'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PureSelect from '../pure'
|
||||
|
||||
const options: Option[] = [
|
||||
{ label: 'Apple', value: 'apple' },
|
||||
{ label: 'Banana', value: 'banana' },
|
||||
{ label: 'Citrus', value: 'citrus' },
|
||||
]
|
||||
|
||||
describe('PureSelect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering and placeholder behavior in single/multiple modes.
|
||||
describe('Rendering', () => {
|
||||
it('should render i18n placeholder when single value is empty', () => {
|
||||
render(<PureSelect options={options} />)
|
||||
expect(screen.getByTitle(/select/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom placeholder when provided', () => {
|
||||
render(<PureSelect options={options} placeholder="Choose value" />)
|
||||
expect(screen.getByTitle('Choose value'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render selected option label in single mode', () => {
|
||||
render(<PureSelect options={options} value="banana" />)
|
||||
expect(screen.getByTitle('Banana'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render selected count text in multiple mode', () => {
|
||||
render(<PureSelect options={options} multiple={true} value={['apple', 'banana']} />)
|
||||
expect(screen.getByText(/selected/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder in multiple mode when selected values are empty', () => {
|
||||
render(<PureSelect options={options} multiple={true} value={[]} placeholder="Pick fruits" />)
|
||||
expect(screen.getByTitle('Pick fruits'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction behavior in single and multiple selection modes.
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange and close popup when selecting an option in single mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<PureSelect options={options} onChange={onChange} />)
|
||||
|
||||
await user.click(screen.getByTitle(/select/i))
|
||||
expect(screen.getByTitle('Banana'))!.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTitle('Banana'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('banana')
|
||||
expect(screen.queryByTitle('Citrus')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should append a new value in multiple mode when clicking an unselected option', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<PureSelect
|
||||
options={options}
|
||||
multiple={true}
|
||||
value={['apple']}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText(/common\.dynamicSelect\.selected/i))
|
||||
await user.click(screen.getAllByTitle('Banana')[0]!)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['apple', 'banana'])
|
||||
})
|
||||
|
||||
it('should remove an existing value in multiple mode when clicking a selected option', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<PureSelect
|
||||
options={options}
|
||||
multiple={true}
|
||||
value={['apple', 'banana']}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText(/common\.dynamicSelect\.selected/i))
|
||||
await user.click(screen.getAllByTitle('Apple')[0]!)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['banana'])
|
||||
})
|
||||
|
||||
it('should start with empty array when multiple value is undefined', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<PureSelect
|
||||
options={options}
|
||||
multiple={true}
|
||||
onChange={onChange}
|
||||
containerProps={{ open: true }}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getAllByTitle('Apple')[0]!)
|
||||
expect(onChange).toHaveBeenCalledWith(['apple'])
|
||||
})
|
||||
})
|
||||
|
||||
// Controlled open state and disabled behavior.
|
||||
describe('Container And Disabled Props', () => {
|
||||
it('should call containerProps.onOpenChange when trigger is clicked in controlled mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
render(
|
||||
<PureSelect
|
||||
options={options}
|
||||
containerProps={{ open: true, onOpenChange }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTitle('Apple'))!.toBeInTheDocument()
|
||||
await user.click(screen.getByTitle(/select/i))
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should not open popup when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<PureSelect
|
||||
options={options}
|
||||
disabled={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTitle(/select/i))
|
||||
expect(screen.queryByTitle('Apple')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore option clicks when disabled even if popup is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<PureSelect
|
||||
options={options}
|
||||
disabled={true}
|
||||
onChange={onChange}
|
||||
containerProps={{ open: true }}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getAllByTitle('Apple')[0]!)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Style and popup customization props.
|
||||
describe('Style Props', () => {
|
||||
it('should apply trigger and popup class names and render popup title', () => {
|
||||
render(
|
||||
<PureSelect
|
||||
options={options}
|
||||
triggerProps={{ className: 'trigger-class' }}
|
||||
popupProps={{
|
||||
wrapperClassName: 'wrapper-class',
|
||||
className: 'popup-class',
|
||||
itemClassName: 'item-class',
|
||||
title: 'Available options',
|
||||
titleClassName: 'title-class',
|
||||
}}
|
||||
containerProps={{ open: true }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const triggerLabel = screen.getByTitle(/select/i)
|
||||
const trigger = triggerLabel.parentElement
|
||||
|
||||
expect(trigger)!.toHaveClass('trigger-class')
|
||||
expect(document.querySelector('.wrapper-class'))!.toBeInTheDocument()
|
||||
expect(document.querySelector('.popup-class'))!.toBeInTheDocument()
|
||||
expect(document.querySelectorAll('.item-class')).toHaveLength(options.length)
|
||||
expect(screen.getByText('Available options'))!.toHaveClass('title-class')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,171 +0,0 @@
|
||||
import type {
|
||||
PortalToFollowElemOptions,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
export type Option = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type CustomSelectProps<T extends Option> = {
|
||||
options: T[]
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
containerProps?: PortalToFollowElemOptions & {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
triggerProps?: {
|
||||
className?: string
|
||||
}
|
||||
popupProps?: {
|
||||
wrapperClassName?: string
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
title?: string
|
||||
}
|
||||
CustomTrigger?: (option: T | undefined, open: boolean) => React.JSX.Element
|
||||
CustomOption?: (option: T, selected: boolean) => React.JSX.Element
|
||||
}
|
||||
const CustomSelect = <T extends Option>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
containerProps,
|
||||
triggerProps,
|
||||
popupProps,
|
||||
CustomTrigger,
|
||||
CustomOption,
|
||||
}: CustomSelectProps<T>) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
open,
|
||||
onOpenChange,
|
||||
placement,
|
||||
offset,
|
||||
triggerPopupSameWidth = true,
|
||||
} = containerProps || {}
|
||||
const {
|
||||
className: triggerClassName,
|
||||
} = triggerProps || {}
|
||||
const {
|
||||
wrapperClassName: popupWrapperClassName,
|
||||
className: popupClassName,
|
||||
itemClassName: popupItemClassName,
|
||||
} = popupProps || {}
|
||||
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
const mergedOpen = open ?? localOpen
|
||||
|
||||
const handleOpenChange = useCallback((openValue: boolean) => {
|
||||
onOpenChange?.(openValue)
|
||||
setLocalOpen(openValue)
|
||||
}, [onOpenChange])
|
||||
|
||||
const selectedOption = options.find(option => option.value === value)
|
||||
const triggerText = selectedOption?.label || t('placeholder.select', { ns: 'common' })
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={placement || 'bottom-start'}
|
||||
offset={offset || 4}
|
||||
open={mergedOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
triggerPopupSameWidth={triggerPopupSameWidth}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => handleOpenChange(!mergedOpen)}
|
||||
asChild
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 system-sm-regular text-components-input-text-filled hover:bg-state-base-hover-alt',
|
||||
mergedOpen && 'bg-state-base-hover-alt',
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
{CustomTrigger
|
||||
? CustomTrigger(selectedOption, mergedOpen)
|
||||
: (
|
||||
<>
|
||||
<div
|
||||
className="grow"
|
||||
title={triggerText}
|
||||
>
|
||||
{triggerText}
|
||||
</div>
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
mergedOpen && 'text-text-secondary',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={cn(
|
||||
'z-10',
|
||||
popupWrapperClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg shadow-shadow-shadow-5',
|
||||
popupClassName,
|
||||
)}
|
||||
>
|
||||
{
|
||||
options.map((option) => {
|
||||
const selected = value === option.value
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg px-2 system-sm-medium text-text-secondary hover:bg-state-base-hover',
|
||||
popupItemClassName,
|
||||
)}
|
||||
title={option.label}
|
||||
onClick={() => {
|
||||
onChange?.(option.value)
|
||||
handleOpenChange(false)
|
||||
}}
|
||||
>
|
||||
{CustomOption
|
||||
? CustomOption(option, selected)
|
||||
: (
|
||||
<>
|
||||
<div className="mr-1 grow truncate px-1">
|
||||
{option.label}
|
||||
</div>
|
||||
{
|
||||
selected && <RiCheckLine className="h-4 w-4 shrink-0 text-text-accent" />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomSelect
|
||||
@ -1,572 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import type { Item } from '.'
|
||||
import { useState } from 'react'
|
||||
import Select, { PortalSelect, SimpleSelect } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/Select',
|
||||
component: SimpleSelect,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Select component with three variants: Select (with search), SimpleSelect (basic dropdown), and PortalSelect (portal-based positioning). Built on Headless UI.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
notClearable: {
|
||||
control: 'boolean',
|
||||
description: 'Hide clear button',
|
||||
},
|
||||
hideChecked: {
|
||||
control: 'boolean',
|
||||
description: 'Hide check icon on selected item',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onSelect: (item) => {
|
||||
console.log('Selected:', item)
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof SimpleSelect>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const fruits: Item[] = [
|
||||
{ value: 'apple', name: 'Apple' },
|
||||
{ value: 'banana', name: 'Banana' },
|
||||
{ value: 'cherry', name: 'Cherry' },
|
||||
{ value: 'date', name: 'Date' },
|
||||
{ value: 'elderberry', name: 'Elderberry' },
|
||||
]
|
||||
|
||||
const countries: Item[] = [
|
||||
{ value: 'us', name: 'United States' },
|
||||
{ value: 'uk', name: 'United Kingdom' },
|
||||
{ value: 'ca', name: 'Canada' },
|
||||
{ value: 'au', name: 'Australia' },
|
||||
{ value: 'de', name: 'Germany' },
|
||||
{ value: 'fr', name: 'France' },
|
||||
{ value: 'jp', name: 'Japan' },
|
||||
{ value: 'cn', name: 'China' },
|
||||
]
|
||||
|
||||
// SimpleSelect Demo
|
||||
const SimpleSelectDemo = (args: any) => {
|
||||
const [selected, setSelected] = useState(args.defaultValue || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
{...args}
|
||||
items={fruits}
|
||||
defaultValue={selected}
|
||||
onSelect={(item) => {
|
||||
setSelected(item.value)
|
||||
console.log('Selected:', item)
|
||||
}}
|
||||
/>
|
||||
{selected && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Selected:
|
||||
{' '}
|
||||
<span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default SimpleSelect
|
||||
export const Default: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'apple',
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
|
||||
// With placeholder (no selection)
|
||||
export const WithPlaceholder: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Choose an option...',
|
||||
defaultValue: '',
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'banana',
|
||||
disabled: true,
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
|
||||
// Not clearable
|
||||
export const NotClearable: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'cherry',
|
||||
notClearable: true,
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
|
||||
// Hide checked icon
|
||||
export const HideChecked: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'apple',
|
||||
hideChecked: true,
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
|
||||
// Select with search
|
||||
const WithSearchDemo = () => {
|
||||
const [selected, setSelected] = useState('us')
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<Select
|
||||
items={countries}
|
||||
defaultValue={selected}
|
||||
onSelect={(item) => {
|
||||
setSelected(item.value as string)
|
||||
console.log('Selected:', item)
|
||||
}}
|
||||
allowSearch={true}
|
||||
/>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Selected:
|
||||
{' '}
|
||||
<span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithSearch: Story = {
|
||||
render: () => <WithSearchDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// PortalSelect
|
||||
const PortalSelectVariantDemo = () => {
|
||||
const [selected, setSelected] = useState('apple')
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<PortalSelect
|
||||
value={selected}
|
||||
items={fruits}
|
||||
onSelect={(item) => {
|
||||
setSelected(item.value as string)
|
||||
console.log('Selected:', item)
|
||||
}}
|
||||
placeholder="Select a fruit..."
|
||||
/>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Selected:
|
||||
{' '}
|
||||
<span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PortalSelectVariant: Story = {
|
||||
render: () => <PortalSelectVariantDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Custom render option
|
||||
const CustomRenderOptionDemo = () => {
|
||||
const [selected, setSelected] = useState('us')
|
||||
|
||||
const countriesWithFlags = [
|
||||
{ value: 'us', name: 'United States', flag: '🇺🇸' },
|
||||
{ value: 'uk', name: 'United Kingdom', flag: '🇬🇧' },
|
||||
{ value: 'ca', name: 'Canada', flag: '🇨🇦' },
|
||||
{ value: 'au', name: 'Australia', flag: '🇦🇺' },
|
||||
{ value: 'de', name: 'Germany', flag: '🇩🇪' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
items={countriesWithFlags}
|
||||
defaultValue={selected}
|
||||
onSelect={item => setSelected(item.value as string)}
|
||||
renderOption={({ item, selected }) => (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{item.flag}</span>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{selected && <span className="text-blue-600">✓</span>}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CustomRenderOption: Story = {
|
||||
render: () => <CustomRenderOptionDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Loading state
|
||||
export const LoadingState: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
items={[]}
|
||||
defaultValue=""
|
||||
onSelect={() => undefined}
|
||||
placeholder="Loading options..."
|
||||
isLoading={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Form field
|
||||
const FormFieldDemo = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
country: 'us',
|
||||
language: 'en',
|
||||
timezone: 'pst',
|
||||
})
|
||||
|
||||
const languages = [
|
||||
{ value: 'en', name: 'English' },
|
||||
{ value: 'es', name: 'Spanish' },
|
||||
{ value: 'fr', name: 'French' },
|
||||
{ value: 'de', name: 'German' },
|
||||
{ value: 'zh', name: 'Chinese' },
|
||||
]
|
||||
|
||||
const timezones = [
|
||||
{ value: 'pst', name: 'Pacific Time (PST)' },
|
||||
{ value: 'mst', name: 'Mountain Time (MST)' },
|
||||
{ value: 'cst', name: 'Central Time (CST)' },
|
||||
{ value: 'est', name: 'Eastern Time (EST)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">User Preferences</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Country</label>
|
||||
<SimpleSelect
|
||||
items={countries}
|
||||
defaultValue={formData.country}
|
||||
onSelect={item => setFormData({ ...formData, country: item.value as string })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Language</label>
|
||||
<SimpleSelect
|
||||
items={languages}
|
||||
defaultValue={formData.language}
|
||||
onSelect={item => setFormData({ ...formData, language: item.value as string })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Timezone</label>
|
||||
<SimpleSelect
|
||||
items={timezones}
|
||||
defaultValue={formData.timezone}
|
||||
onSelect={item => setFormData({ ...formData, timezone: item.value as string })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 rounded-lg bg-gray-50 p-3 text-xs text-gray-700">
|
||||
<div>
|
||||
<strong>Country:</strong>
|
||||
{' '}
|
||||
{formData.country}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Language:</strong>
|
||||
{' '}
|
||||
{formData.language}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Timezone:</strong>
|
||||
{' '}
|
||||
{formData.timezone}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormField: Story = {
|
||||
render: () => <FormFieldDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Filter selector
|
||||
const FilterSelectorDemo = () => {
|
||||
const [status, setStatus] = useState('all')
|
||||
const [priority, setPriority] = useState('all')
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all', name: 'All Status' },
|
||||
{ value: 'active', name: 'Active' },
|
||||
{ value: 'pending', name: 'Pending' },
|
||||
{ value: 'completed', name: 'Completed' },
|
||||
{ value: 'cancelled', name: 'Cancelled' },
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ value: 'all', name: 'All Priorities' },
|
||||
{ value: 'high', name: 'High Priority' },
|
||||
{ value: 'medium', name: 'Medium Priority' },
|
||||
{ value: 'low', name: 'Low Priority' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Task Filters</h3>
|
||||
<div className="mb-6 flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Status</label>
|
||||
<SimpleSelect
|
||||
items={statusOptions}
|
||||
defaultValue={status}
|
||||
onSelect={item => setStatus(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Priority</label>
|
||||
<SimpleSelect
|
||||
items={priorityOptions}
|
||||
defaultValue={priority}
|
||||
onSelect={item => setPriority(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-blue-50 p-4 text-sm">
|
||||
<div className="mb-2 font-medium text-gray-700">Active Filters:</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="rounded-sm bg-blue-200 px-2 py-1 text-xs text-blue-800">
|
||||
Status:
|
||||
{' '}
|
||||
{status}
|
||||
</span>
|
||||
<span className="rounded-sm bg-blue-200 px-2 py-1 text-xs text-blue-800">
|
||||
Priority:
|
||||
{' '}
|
||||
{priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FilterSelector: Story = {
|
||||
render: () => <FilterSelectorDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Version selector with badge
|
||||
const VersionSelectorDemo = () => {
|
||||
const [selectedVersion, setSelectedVersion] = useState('2.1.0')
|
||||
|
||||
const versions = [
|
||||
{ value: '3.0.0', name: 'v3.0.0 (Beta)' },
|
||||
{ value: '2.1.0', name: 'v2.1.0 (Latest)' },
|
||||
{ value: '2.0.5', name: 'v2.0.5' },
|
||||
{ value: '2.0.4', name: 'v2.0.4' },
|
||||
{ value: '1.9.8', name: 'v1.9.8' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Select Version</h3>
|
||||
<PortalSelect
|
||||
value={selectedVersion}
|
||||
items={versions}
|
||||
onSelect={item => setSelectedVersion(item.value as string)}
|
||||
installedValue="2.0.5"
|
||||
placeholder="Choose version..."
|
||||
/>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-3 text-sm text-gray-700">
|
||||
{selectedVersion !== '2.0.5' && (
|
||||
<div className="mb-2 text-yellow-600">
|
||||
⚠️ Version change detected
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
Current:
|
||||
<strong>{selectedVersion}</strong>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">Installed: 2.0.5</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VersionSelector: Story = {
|
||||
render: () => <VersionSelectorDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Settings dropdown
|
||||
const SettingsDropdownDemo = () => {
|
||||
const [theme, setTheme] = useState('light')
|
||||
const [fontSize, setFontSize] = useState('medium')
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light', name: '☀️ Light Mode' },
|
||||
{ value: 'dark', name: '🌙 Dark Mode' },
|
||||
{ value: 'auto', name: '🔄 Auto (System)' },
|
||||
]
|
||||
|
||||
const fontSizeOptions = [
|
||||
{ value: 'small', name: 'Small (12px)' },
|
||||
{ value: 'medium', name: 'Medium (14px)' },
|
||||
{ value: 'large', name: 'Large (16px)' },
|
||||
{ value: 'xlarge', name: 'Extra Large (18px)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Display Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Theme</label>
|
||||
<SimpleSelect
|
||||
items={themeOptions}
|
||||
defaultValue={theme}
|
||||
onSelect={item => setTheme(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Font Size</label>
|
||||
<SimpleSelect
|
||||
items={fontSizeOptions}
|
||||
defaultValue={fontSize}
|
||||
onSelect={item => setFontSize(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsDropdown: Story = {
|
||||
render: () => <SettingsDropdownDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Comparison of variants
|
||||
const VariantComparisonDemo = () => {
|
||||
const [simple, setSimple] = useState('apple')
|
||||
const [withSearch, setWithSearch] = useState('us')
|
||||
const [portal, setPortal] = useState('banana')
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-6 text-lg font-semibold">Select Variants Comparison</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">SimpleSelect (Basic)</h4>
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
items={fruits}
|
||||
defaultValue={simple}
|
||||
onSelect={item => setSimple(item.value as string)}
|
||||
placeholder="Choose a fruit..."
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Standard dropdown without search</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">Select (With Search)</h4>
|
||||
<div style={{ width: '300px' }}>
|
||||
<Select
|
||||
items={countries}
|
||||
defaultValue={withSearch}
|
||||
onSelect={item => setWithSearch(item.value as string)}
|
||||
allowSearch={true}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Dropdown with search/filter capability</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">PortalSelect (Portal-based)</h4>
|
||||
<div style={{ width: '300px' }}>
|
||||
<PortalSelect
|
||||
value={portal}
|
||||
items={fruits}
|
||||
onSelect={item => setPortal(item.value as string)}
|
||||
placeholder="Choose a fruit..."
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Portal-based positioning for better overflow handling</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VariantComparison: Story = {
|
||||
render: () => <VariantComparisonDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Interactive playground
|
||||
const PlaygroundDemo = () => {
|
||||
const [selected, setSelected] = useState('apple')
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }}>
|
||||
<SimpleSelect
|
||||
items={fruits}
|
||||
defaultValue={selected}
|
||||
onSelect={item => setSelected(item.value as string)}
|
||||
placeholder="Select an option..."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PlaygroundDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
@ -1,441 +0,0 @@
|
||||
'use client'
|
||||
/**
|
||||
* @deprecated Use `@langgenius/dify-ui/select` instead.
|
||||
* This component will be removed after migration is complete.
|
||||
* See: https://github.com/langgenius/dify/issues/32767
|
||||
*/
|
||||
import type { FC } from 'react'
|
||||
import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
|
||||
import { ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/solid'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiCheckLine, RiLoader4Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Badge from '../badge/index'
|
||||
|
||||
const defaultItems = [
|
||||
{ value: 1, name: 'option1' },
|
||||
{ value: 2, name: 'option2' },
|
||||
{ value: 3, name: 'option3' },
|
||||
{ value: 4, name: 'option4' },
|
||||
{ value: 5, name: 'option5' },
|
||||
{ value: 6, name: 'option6' },
|
||||
{ value: 7, name: 'option7' },
|
||||
]
|
||||
|
||||
export type Item = {
|
||||
value: number | string
|
||||
name: string
|
||||
isGroup?: boolean
|
||||
disabled?: boolean
|
||||
extra?: React.ReactNode
|
||||
} & Record<string, any>
|
||||
|
||||
type ISelectProps = {
|
||||
className?: string
|
||||
wrapperClassName?: string
|
||||
renderTrigger?: (value: Item | null, isOpen: boolean) => React.JSX.Element | null
|
||||
items?: Item[]
|
||||
defaultValue?: number | string
|
||||
disabled?: boolean
|
||||
onSelect: (value: Item) => void
|
||||
allowSearch?: boolean
|
||||
bgClassName?: string
|
||||
placeholder?: string
|
||||
overlayClassName?: string
|
||||
optionWrapClassName?: string
|
||||
optionClassName?: string
|
||||
hideChecked?: boolean
|
||||
notClearable?: boolean
|
||||
renderOption?: ({
|
||||
item,
|
||||
selected,
|
||||
}: {
|
||||
item: Item
|
||||
selected: boolean
|
||||
}) => React.ReactNode
|
||||
isLoading?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
const Select: FC<ISelectProps> = ({
|
||||
className,
|
||||
items = defaultItems,
|
||||
defaultValue = 1,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
allowSearch = true,
|
||||
bgClassName = 'bg-components-input-bg-normal',
|
||||
overlayClassName,
|
||||
optionClassName,
|
||||
renderOption,
|
||||
}) => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<Item | null>(null)
|
||||
// Ensure selectedItem is properly set when defaultValue or items change
|
||||
useEffect(() => {
|
||||
let defaultSelect = null
|
||||
// Handle cases where defaultValue might be undefined, null, or empty string
|
||||
defaultSelect = (defaultValue && items.find((item: Item) => item.value === defaultValue)) || null
|
||||
setSelectedItem(defaultSelect)
|
||||
}, [defaultValue, items])
|
||||
|
||||
const filteredItems: Item[]
|
||||
= query === ''
|
||||
? items
|
||||
: items.filter((item) => {
|
||||
return item.name.toLowerCase().includes(query.toLowerCase())
|
||||
})
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
disabled={disabled}
|
||||
value={selectedItem}
|
||||
className={className}
|
||||
onChange={(value) => {
|
||||
if (!disabled) {
|
||||
setSelectedItem(value)
|
||||
setOpen(false)
|
||||
onSelect(value as Item)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn('relative')}>
|
||||
<div className="group text-text-secondary">
|
||||
{allowSearch
|
||||
? (
|
||||
<ComboboxInput
|
||||
className={`w-full rounded-lg border-0 ${bgClassName} py-1.5 pr-10 pl-3 shadow-sm group-hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
onChange={(event) => {
|
||||
if (!disabled)
|
||||
setQuery(event.target.value)
|
||||
}}
|
||||
displayValue={(item: Item) => item?.name}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<ComboboxButton
|
||||
onClick={
|
||||
() => {
|
||||
if (!disabled)
|
||||
setOpen(!open)
|
||||
}
|
||||
}
|
||||
className={cn(`flex h-9 w-full items-center rounded-lg border-0 ${bgClassName} py-1.5 pr-10 pl-3 shadow-sm group-hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden sm:text-sm sm:leading-6`, optionClassName)}
|
||||
>
|
||||
<div className="w-0 grow truncate text-left" title={selectedItem?.name}>{selectedItem?.name}</div>
|
||||
</ComboboxButton>
|
||||
)}
|
||||
<ComboboxButton
|
||||
className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden"
|
||||
onClick={
|
||||
() => {
|
||||
if (!disabled)
|
||||
setOpen(!open)
|
||||
}
|
||||
}
|
||||
>
|
||||
{open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
|
||||
</ComboboxButton>
|
||||
</div>
|
||||
|
||||
{(filteredItems.length > 0 && open) && (
|
||||
<ComboboxOptions className={`absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-xs focus:outline-hidden sm:text-sm ${overlayClassName}`}>
|
||||
{filteredItems.map((item: Item) => (
|
||||
<ComboboxOption
|
||||
key={item.value}
|
||||
value={item}
|
||||
className={({ active }: { active: boolean }) =>
|
||||
cn('relative cursor-default rounded-lg py-2 pr-9 pl-3 text-text-secondary select-none hover:bg-state-base-hover', active ? 'bg-state-base-hover' : '', optionClassName)}
|
||||
>
|
||||
{({ /* active, */ selected }) => (
|
||||
<>
|
||||
{renderOption
|
||||
? renderOption({ item, selected })
|
||||
: (
|
||||
<>
|
||||
<span className={cn('block', selected && 'font-normal')}>{item.name}</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={cn('absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary')}
|
||||
>
|
||||
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
</ComboboxOptions>
|
||||
)}
|
||||
</div>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
const SimpleSelect: FC<ISelectProps> = ({
|
||||
className,
|
||||
wrapperClassName = '',
|
||||
renderTrigger,
|
||||
items = defaultItems,
|
||||
defaultValue = 1,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
onOpenChange,
|
||||
placeholder,
|
||||
optionWrapClassName,
|
||||
optionClassName,
|
||||
hideChecked,
|
||||
notClearable,
|
||||
renderOption,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const localPlaceholder = placeholder || t('placeholder.select', { ns: 'common' })
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<Item | null>(null)
|
||||
|
||||
// Enhanced: Preserve user selection, only reset when necessary
|
||||
useEffect(() => {
|
||||
// Only reset if no current selection or current selection is invalid
|
||||
const isCurrentSelectionValid = selectedItem && items.some(item => item.value === selectedItem.value)
|
||||
|
||||
if (!isCurrentSelectionValid) {
|
||||
let defaultSelect = null
|
||||
// Handle cases where defaultValue might be undefined, null, or empty string
|
||||
defaultSelect = items.find((item: Item) => item.value === defaultValue) ?? null
|
||||
setSelectedItem(defaultSelect)
|
||||
}
|
||||
}, [defaultValue, items, selectedItem])
|
||||
|
||||
const listboxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
ref={listboxRef}
|
||||
value={selectedItem}
|
||||
onChange={(value) => {
|
||||
if (!disabled) {
|
||||
setSelectedItem(value)
|
||||
onSelect(value as Item)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className={cn('group/simple-select relative h-9', wrapperClassName)}>
|
||||
{renderTrigger && <ListboxButton className="w-full">{renderTrigger(selectedItem, open)}</ListboxButton>}
|
||||
{!renderTrigger && (
|
||||
<ListboxButton
|
||||
onClick={() => {
|
||||
onOpenChange?.(open)
|
||||
}}
|
||||
className={cn(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pr-10 pl-3 group-hover/simple-select:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt focus-visible:outline-hidden sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}
|
||||
>
|
||||
<span className={cn('block truncate text-left system-sm-regular text-components-input-text-filled', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
{isLoading
|
||||
? <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />
|
||||
: (selectedItem && !notClearable)
|
||||
? (
|
||||
<XMarkIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedItem(null)
|
||||
onSelect({ name: '', value: '' })
|
||||
}}
|
||||
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
||||
aria-hidden="false"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
open
|
||||
? (
|
||||
<ChevronUpIcon
|
||||
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<ChevronDownIcon
|
||||
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</ListboxButton>
|
||||
)}
|
||||
|
||||
{(!disabled) && (
|
||||
<ListboxOptions className={cn('absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-xs focus:outline-hidden sm:text-sm', optionWrapClassName)}>
|
||||
{items.map((item: Item) =>
|
||||
item.isGroup ? (
|
||||
<div
|
||||
key={item.value}
|
||||
className="px-3 py-1.5 text-xs font-medium tracking-wide text-text-tertiary uppercase select-none"
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
) : (
|
||||
<ListboxOption
|
||||
key={item.value}
|
||||
className={
|
||||
cn('relative cursor-pointer rounded-lg py-2 pr-9 pl-3 text-text-secondary select-none hover:bg-state-base-hover', optionClassName)
|
||||
}
|
||||
value={item}
|
||||
disabled={item.disabled || disabled}
|
||||
>
|
||||
{({ /* active, */ selected }) => (
|
||||
<>
|
||||
{renderOption
|
||||
? renderOption({ item, selected })
|
||||
: (
|
||||
<>
|
||||
<span className={cn('block', selected && 'font-normal')}>{item.name}</span>
|
||||
{selected && !hideChecked && (
|
||||
<span
|
||||
className={cn('absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent')}
|
||||
>
|
||||
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
),
|
||||
)}
|
||||
</ListboxOptions>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
)
|
||||
}
|
||||
|
||||
type PortalSelectProps = {
|
||||
value: string | number
|
||||
onSelect: (value: Item) => void
|
||||
items: Item[]
|
||||
placeholder?: string
|
||||
installedValue?: string | number
|
||||
renderTrigger?: (value?: Item) => React.JSX.Element | null
|
||||
triggerClassName?: string
|
||||
triggerClassNameFn?: (open: boolean) => string
|
||||
popupClassName?: string
|
||||
popupInnerClassName?: string
|
||||
readonly?: boolean
|
||||
hideChecked?: boolean
|
||||
}
|
||||
const PortalSelect: FC<PortalSelectProps> = ({
|
||||
value,
|
||||
onSelect,
|
||||
items,
|
||||
placeholder,
|
||||
installedValue,
|
||||
renderTrigger,
|
||||
triggerClassName,
|
||||
triggerClassNameFn,
|
||||
popupClassName,
|
||||
popupInnerClassName,
|
||||
readonly,
|
||||
hideChecked,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const localPlaceholder = placeholder || t('placeholder.select', { ns: 'common' })
|
||||
const selectedItem = value ? items.find(item => item.value === value) : undefined
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
triggerPopupSameWidth={true}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)} className="w-full">
|
||||
{renderTrigger
|
||||
? renderTrigger(selectedItem)
|
||||
: (
|
||||
<div
|
||||
className={cn(`
|
||||
group flex h-9 items-center justify-between rounded-lg border-0 bg-components-input-bg-normal px-2.5 text-sm hover:bg-state-base-hover-alt ${readonly ? 'cursor-not-allowed' : 'cursor-pointer'}
|
||||
`, triggerClassName, triggerClassNameFn?.(open))}
|
||||
title={selectedItem?.name}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
grow truncate text-text-secondary
|
||||
${!selectedItem?.name && 'text-components-input-text-placeholder'}
|
||||
`}
|
||||
>
|
||||
{selectedItem?.name ?? localPlaceholder}
|
||||
</span>
|
||||
<div className="mx-0.5">
|
||||
{!!(installedValue && selectedItem && selectedItem.value !== installedValue) && (
|
||||
<Badge>
|
||||
{installedValue}
|
||||
{' '}
|
||||
{'->'}
|
||||
{' '}
|
||||
{selectedItem.value}
|
||||
{' '}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon className="h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={`z-20 ${popupClassName}`}>
|
||||
<div
|
||||
className={cn('max-h-60 overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-hidden sm:text-sm', popupInnerClassName)}
|
||||
>
|
||||
{items.map((item: Item) => (
|
||||
<div
|
||||
key={item.value}
|
||||
className={`
|
||||
flex h-9 cursor-pointer items-center justify-between rounded-lg px-2.5 text-text-secondary hover:bg-state-base-hover
|
||||
${item.value === value && 'bg-state-base-hover'}
|
||||
`}
|
||||
title={item.name}
|
||||
onClick={() => {
|
||||
onSelect(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-0 grow truncate"
|
||||
title={item.name}
|
||||
>
|
||||
<span className="truncate">{item.name}</span>
|
||||
{item.value === installedValue && (
|
||||
<Badge uppercase={true} className="ml-1 shrink-0">INSTALLED</Badge>
|
||||
)}
|
||||
</span>
|
||||
{!hideChecked && item.value === value && (
|
||||
<RiCheckLine className="h-4 w-4 shrink-0 text-text-accent" />
|
||||
)}
|
||||
{item.extra}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export { PortalSelect, SimpleSelect }
|
||||
export default React.memo(Select)
|
||||
@ -1,64 +0,0 @@
|
||||
'use client'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { GlobeAltIcon } from '@heroicons/react/24/outline'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
type ISelectProps = {
|
||||
items: Array<{ value: string, name: string }>
|
||||
value?: string
|
||||
className?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export default function LocaleSigninSelect({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
}: ISelectProps) {
|
||||
const item = items.filter(item => item.value === value)[0]
|
||||
|
||||
return (
|
||||
<div className="w-56 text-right">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton className="h-[44px]justify-center inline-flex w-full items-center rounded-lg border border-components-button-secondary-border px-[10px] py-[6px] text-[13px] font-medium text-text-primary hover:bg-state-base-hover">
|
||||
<GlobeAltIcon className="mr-1 h-5 w-5" aria-hidden="true" />
|
||||
{item?.name}
|
||||
</MenuButton>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems className="absolute right-0 z-10 mt-2 w-[200px] origin-top-right divide-y divide-divider-regular rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg focus:outline-hidden">
|
||||
<div className="max-h-96 overflow-y-auto mask-[linear-gradient(to_bottom,transparent_0px,black_8px,black_calc(100%-8px),transparent_100%)] px-1 py-1">
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<MenuItem key={item.value}>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center rounded-lg px-3 py-2 text-sm text-text-secondary data-active:bg-state-base-hover"
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault()
|
||||
onChange?.(item.value)
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
'use client'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { GlobeAltIcon } from '@heroicons/react/24/outline'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
type ISelectProps = {
|
||||
items: Array<{ value: string, name: string }>
|
||||
value?: string
|
||||
className?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
}: ISelectProps) {
|
||||
const item = items.filter(item => item.value === value)[0]
|
||||
|
||||
return (
|
||||
<div className="w-56 text-right">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton className="h-[44px]justify-center inline-flex w-full items-center rounded-lg border border-components-button-secondary-border px-[10px] py-[6px] text-[13px] font-medium text-text-primary hover:bg-state-base-hover">
|
||||
<GlobeAltIcon className="mr-1 h-5 w-5" aria-hidden="true" />
|
||||
{item?.name}
|
||||
</MenuButton>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems className="absolute right-0 z-10 mt-2 w-[200px] origin-top-right divide-y divide-divider-regular rounded-md bg-components-panel-bg shadow-lg ring-1 ring-black/5 focus:outline-hidden">
|
||||
<div className="px-1 py-1">
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<MenuItem key={item.value}>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center rounded-lg px-3 py-2 text-sm text-text-secondary data-active:bg-state-base-hover"
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault()
|
||||
onChange?.(item.value)
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,207 +0,0 @@
|
||||
import type {
|
||||
PortalToFollowElemOptions,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
export type Option = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type SharedPureSelectProps = {
|
||||
options: Option[]
|
||||
containerProps?: PortalToFollowElemOptions & {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
triggerProps?: {
|
||||
className?: string
|
||||
}
|
||||
popupProps?: {
|
||||
wrapperClassName?: string
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
title?: string
|
||||
titleClassName?: string
|
||||
}
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
triggerPopupSameWidth?: boolean
|
||||
}
|
||||
|
||||
type SingleSelectProps = {
|
||||
multiple?: false
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
type MultiSelectProps = {
|
||||
multiple: true
|
||||
value?: string[]
|
||||
onChange?: (value: string[]) => void
|
||||
}
|
||||
|
||||
export type PureSelectProps = SharedPureSelectProps & (SingleSelectProps | MultiSelectProps)
|
||||
const PureSelect = (props: PureSelectProps) => {
|
||||
const {
|
||||
options,
|
||||
containerProps,
|
||||
triggerProps,
|
||||
popupProps,
|
||||
placeholder,
|
||||
disabled,
|
||||
triggerPopupSameWidth,
|
||||
multiple,
|
||||
value,
|
||||
onChange,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
open,
|
||||
onOpenChange,
|
||||
placement,
|
||||
offset,
|
||||
} = containerProps || {}
|
||||
const {
|
||||
className: triggerClassName,
|
||||
} = triggerProps || {}
|
||||
const {
|
||||
wrapperClassName: popupWrapperClassName,
|
||||
className: popupClassName,
|
||||
itemClassName: popupItemClassName,
|
||||
title: popupTitle,
|
||||
titleClassName: popupTitleClassName,
|
||||
} = popupProps || {}
|
||||
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
const mergedOpen = open ?? localOpen
|
||||
|
||||
const handleOpenChange = useCallback((openValue: boolean) => {
|
||||
onOpenChange?.(openValue)
|
||||
setLocalOpen(openValue)
|
||||
}, [onOpenChange])
|
||||
|
||||
const triggerText = useMemo(() => {
|
||||
const placeholderText = placeholder || t('placeholder.select', { ns: 'common' })
|
||||
if (multiple)
|
||||
return value?.length ? t('dynamicSelect.selected', { ns: 'common', count: value.length }) : placeholderText
|
||||
|
||||
return options.find(option => option.value === value)?.label || placeholderText
|
||||
}, [multiple, value, options, placeholder])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={placement || 'bottom-start'}
|
||||
offset={offset || 4}
|
||||
open={mergedOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
triggerPopupSameWidth={triggerPopupSameWidth}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => !disabled && handleOpenChange(!mergedOpen)}
|
||||
asChild
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex h-8 items-center rounded-lg bg-components-input-bg-normal px-2 system-sm-regular text-components-input-text-filled',
|
||||
!disabled && 'cursor-pointer hover:bg-state-base-hover-alt',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
mergedOpen && !disabled && 'bg-state-base-hover-alt',
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="grow"
|
||||
title={triggerText}
|
||||
>
|
||||
{triggerText}
|
||||
</div>
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
mergedOpen && 'text-text-secondary',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={cn(
|
||||
'z-9999',
|
||||
popupWrapperClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-h-80 overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg',
|
||||
popupClassName,
|
||||
)}
|
||||
>
|
||||
{
|
||||
popupTitle && (
|
||||
<div className={cn(
|
||||
'flex h-[22px] items-center px-3 system-xs-medium-uppercase text-text-tertiary',
|
||||
popupTitleClassName,
|
||||
)}
|
||||
>
|
||||
{popupTitle}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg px-2 system-sm-medium text-text-secondary hover:bg-state-base-hover',
|
||||
popupItemClassName,
|
||||
)}
|
||||
title={option.label}
|
||||
onClick={() => {
|
||||
if (disabled)
|
||||
return
|
||||
if (multiple) {
|
||||
const currentValues = value ?? []
|
||||
const nextValues = currentValues.includes(option.value)
|
||||
? currentValues.filter(valueItem => valueItem !== option.value)
|
||||
: [...currentValues, option.value]
|
||||
onChange?.(nextValues)
|
||||
return
|
||||
}
|
||||
onChange?.(option.value)
|
||||
handleOpenChange(false)
|
||||
}}
|
||||
>
|
||||
<div className="mr-1 grow truncate px-1">
|
||||
{option.label}
|
||||
</div>
|
||||
{
|
||||
(
|
||||
multiple
|
||||
? (value ?? []).includes(option.value)
|
||||
: value === option.value
|
||||
) && <RiCheckLine className="h-4 w-4 shrink-0 text-text-accent" />
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default PureSelect
|
||||
@ -3,6 +3,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiSearchEyeLine,
|
||||
@ -10,7 +11,6 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import SummaryIndexSetting from '@/app/components/datasets/settings/summary-index-setting'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
@ -191,7 +191,18 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
|
||||
onSelect={onDocLanguageChange}
|
||||
disabled={currentDocForm !== ChunkingMode.qa}
|
||||
/>
|
||||
<Tooltip popupContent={t('stepTwo.QATip', { ns: 'datasetCreation' })} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 p-px">
|
||||
<span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('stepTwo.QATip', { ns: 'datasetCreation' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{currentDocForm === ChunkingMode.qa && (
|
||||
<div
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { FC } from 'react'
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { pick } from 'es-toolkit/object'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ChunkingModeLabel from '@/app/components/datasets/common/chunking-mode-label'
|
||||
import Operations from '@/app/components/datasets/documents/components/operations'
|
||||
import SummaryStatus from '@/app/components/datasets/documents/detail/completed/common/summary-status'
|
||||
@ -101,8 +101,15 @@ const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
|
||||
<div className="flex shrink-0 items-center">
|
||||
<DocumentSourceIcon doc={doc} fileType={fileType} />
|
||||
</div>
|
||||
<Tooltip popupContent={doc.name}>
|
||||
<span className="grow truncate text-sm">{doc.name}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="grow truncate text-sm">{doc.name}</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{doc.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{doc.summary_index_status && (
|
||||
<div className="ml-1 hidden shrink-0 group-hover:flex">
|
||||
@ -110,13 +117,20 @@ const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden shrink-0 group-hover:ml-auto group-hover:flex">
|
||||
<Tooltip popupContent={t('list.table.rename', { ns: 'datasetDocuments' })}>
|
||||
<div
|
||||
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
|
||||
onClick={handleRenameClick}
|
||||
>
|
||||
<span className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
|
||||
onClick={handleRenameClick}
|
||||
>
|
||||
<span className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('list.table.rename', { ns: 'datasetDocuments' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,9 +3,9 @@ import type { FC } from 'react'
|
||||
import type { DocType } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useMetadataMap } from '@/hooks/use-metadata'
|
||||
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
|
||||
import s from '../style.module.css'
|
||||
@ -17,13 +17,20 @@ const TypeIcon: FC<{ iconName: string, className?: string }> = ({ iconName, clas
|
||||
const IconButton: FC<{ type: DocType, isChecked: boolean }> = ({ type, isChecked = false }) => {
|
||||
const metadataMap = useMetadataMap()
|
||||
return (
|
||||
<Tooltip popupContent={metadataMap[type].text}>
|
||||
<button type="button" className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
|
||||
<TypeIcon
|
||||
iconName={metadataMap[type].iconName || ''}
|
||||
className={`group-hover:bg-primary-600 ${isChecked ? 'bg-primary-600!' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
|
||||
<TypeIcon
|
||||
iconName={metadataMap[type].iconName || ''}
|
||||
className={`group-hover:bg-primary-600 ${isChecked ? 'bg-primary-600!' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{metadataMap[type].text}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Corner } from '@/app/components/base/icons/src/vender/solid/shapes'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type TextareaProps = {
|
||||
text: string
|
||||
@ -36,14 +36,19 @@ const Textarea = ({
|
||||
/>
|
||||
{text.length > 200
|
||||
? (
|
||||
<Tooltip
|
||||
popupContent={t('input.countWarning', { ns: 'datasetHitTesting' })}
|
||||
>
|
||||
<div
|
||||
className={cn('bg-util-colors-red-red-100 py-1 pr-2 system-2xs-medium-uppercase text-util-colors-red-red-600')}
|
||||
>
|
||||
{`${text.length}/200`}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className={cn('bg-util-colors-red-red-100 py-1 pr-2 system-2xs-medium-uppercase text-util-colors-red-red-600')}
|
||||
>
|
||||
{`${text.length}/200`}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('input.countWarning', { ns: 'datasetHitTesting' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiFileTextFill, RiRobot2Fill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
|
||||
const EXTERNAL_PROVIDER = 'external'
|
||||
@ -39,18 +39,32 @@ const DatasetCardFooter = ({ dataset }: DatasetCardFooterProps) => {
|
||||
!dataset.embedding_available && 'opacity-30',
|
||||
)}
|
||||
>
|
||||
<Tooltip popupContent={documentCountTooltip}>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<RiFileTextFill className="size-3 text-text-quaternary" />
|
||||
<span className="system-xs-medium">{documentCount}</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="flex items-center gap-x-1">
|
||||
<RiFileTextFill className="size-3 text-text-quaternary" />
|
||||
<span className="system-xs-medium">{documentCount}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{documentCountTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{!isExternalProvider && (
|
||||
<Tooltip popupContent={`${dataset.app_count} ${t('appCount', { ns: 'dataset' })}`}>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<RiRobot2Fill className="size-3 text-text-quaternary" />
|
||||
<span className="system-xs-medium">{dataset.app_count}</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="flex items-center gap-x-1">
|
||||
<RiRobot2Fill className="size-3 text-text-quaternary" />
|
||||
<span className="system-xs-medium">{dataset.app_count}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{`${dataset.app_count} ${t('appCount', { ns: 'dataset' })}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="system-xs-regular text-divider-deep">/</span>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiResetLeftLine } from '@remixicon/react'
|
||||
import { useHover } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type Props = {
|
||||
onReset: () => void
|
||||
@ -22,10 +22,17 @@ const EditedBeacon: FC<Props> = ({
|
||||
<div ref={ref} className="size-4 cursor-pointer">
|
||||
{isHovering
|
||||
? (
|
||||
<Tooltip popupContent={t('operation.reset', { ns: 'common' })}>
|
||||
<div className="flex size-4 items-center justify-center rounded-full bg-text-accent-secondary" onClick={onReset}>
|
||||
<RiResetLeftLine className="size-[10px] text-text-primary-on-surface" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="flex size-4 items-center justify-center rounded-full bg-text-accent-secondary" onClick={onReset}>
|
||||
<RiResetLeftLine className="size-[10px] text-text-primary-on-surface" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.reset', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
|
||||
@ -7,10 +7,10 @@ import {
|
||||
NumberFieldInput,
|
||||
} from '@langgenius/dify-ui/number-field'
|
||||
import { Slider } from '@langgenius/dify-ui/slider'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
const MIN_KEYWORD_NUMBER = 0
|
||||
const MAX_KEYWORD_NUMBER = 50
|
||||
@ -36,10 +36,15 @@ const KeyWordNumber = ({
|
||||
<div className="truncate system-xs-medium text-text-secondary">
|
||||
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Tooltip
|
||||
popupContent={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
|
||||
>
|
||||
<span className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary" />
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Slider
|
||||
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
} from '@/app/components/base/chat/embedded-chatbot/context'
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiResetLeftLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
@ -21,7 +22,6 @@ import {
|
||||
useEmbeddedChatbot,
|
||||
} from '@/app/components/base/chat/embedded-chatbot/hooks'
|
||||
import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { useThemeContext } from '../../../base/chat/embedded-chatbot/theme/theme-context'
|
||||
@ -78,12 +78,17 @@ const TryApp: FC<Props> = ({
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{currentConversationId && (
|
||||
<Tooltip
|
||||
popupContent={t('chat.resetChat', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={handleNewConversation}>
|
||||
<RiResetLeftLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton size="l" onClick={handleNewConversation}>
|
||||
<RiResetLeftLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('chat.resetChat', { ns: 'share' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{currentConversationId && inputsForms.length > 0 && (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {
|
||||
ModelFeatureEnum,
|
||||
ModelFeatureTextEnum,
|
||||
@ -36,7 +37,7 @@ describe('FeatureIcon', () => {
|
||||
|
||||
for (const { feature, text } of cases) {
|
||||
const { container, unmount } = render(<FeatureIcon feature={feature} />)
|
||||
fireEvent.mouseEnter(container.firstElementChild as HTMLElement)
|
||||
await userEvent.hover(container.firstElementChild as HTMLElement)
|
||||
expect(await screen.findByText(`common.modelProvider.featureSupported:{"feature":"${text}"}`))
|
||||
.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiFileTextLine,
|
||||
RiFilmAiLine,
|
||||
@ -7,7 +8,6 @@ import {
|
||||
RiVoiceAiFill,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
ModelFeatureEnum,
|
||||
ModelFeatureTextEnum,
|
||||
@ -75,19 +75,24 @@ const FeatureIcon: FC<FeatureIconProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={t('modelProvider.featureSupported', { ns: 'common', feature: ModelFeatureTextEnum.vision })}
|
||||
>
|
||||
<div className="inline-block cursor-help">
|
||||
<ModelBadge
|
||||
className={cn(
|
||||
'w-[18px] justify-center px-0!',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RiImageCircleAiLine className="size-3" />
|
||||
</ModelBadge>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="inline-block cursor-help">
|
||||
<ModelBadge
|
||||
className={cn(
|
||||
'w-[18px] justify-center px-0!',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RiImageCircleAiLine className="size-3" />
|
||||
</ModelBadge>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('modelProvider.featureSupported', { ns: 'common', feature: ModelFeatureTextEnum.vision })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@ -105,19 +110,24 @@ const FeatureIcon: FC<FeatureIconProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={t('modelProvider.featureSupported', { ns: 'common', feature: ModelFeatureTextEnum.document })}
|
||||
>
|
||||
<div className="inline-block cursor-help">
|
||||
<ModelBadge
|
||||
className={cn(
|
||||
'w-[18px] justify-center px-0!',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RiFileTextLine className="size-3" />
|
||||
</ModelBadge>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="inline-block cursor-help">
|
||||
<ModelBadge
|
||||
className={cn(
|
||||
'w-[18px] justify-center px-0!',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RiFileTextLine className="size-3" />
|
||||
</ModelBadge>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('modelProvider.featureSupported', { ns: 'common', feature: ModelFeatureTextEnum.document })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@ -135,19 +145,24 @@ const FeatureIcon: FC<FeatureIconProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={t('modelProvider.featureSupported', { ns: 'common', feature: ModelFeatureTextEnum.audio })}
|
||||
>
|
||||
<div className="inline-block cursor-help">
|
||||
<ModelBadge
|
||||
className={cn(
|
||||
'w-[18px] justify-center px-0!',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RiVoiceAiFill className="size-3" />
|
||||
</ModelBadge>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="inline-block cursor-help">
|
||||
<ModelBadge
|
||||
className={cn(
|
||||
'w-[18px] justify-center px-0!',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RiVoiceAiFill className="size-3" />
|
||||
</ModelBadge>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('modelProvider.featureSupported', { ns: 'common', feature: ModelFeatureTextEnum.audio })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@ -165,19 +180,24 @@ const FeatureIcon: FC<FeatureIconProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={t('modelProvider.featureSupported', { ns: 'common', feature: ModelFeatureTextEnum.video })}
|
||||
>
|
||||
<div className="inline-block cursor-help">
|
||||
<ModelBadge
|
||||
className={cn(
|
||||
'w-[18px] justify-center px-0!',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RiFilmAiLine className="size-3" />
|
||||
</ModelBadge>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="inline-block cursor-help">
|
||||
<ModelBadge
|
||||
className={cn(
|
||||
'w-[18px] justify-center px-0!',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RiFilmAiLine className="size-3" />
|
||||
</ModelBadge>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('modelProvider.featureSupported', { ns: 'common', feature: ModelFeatureTextEnum.video })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useLatest } from 'ahooks'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SimplePieChart from '@/app/components/base/simple-pie-chart'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type CooldownTimerProps = {
|
||||
secondsRemaining?: number
|
||||
@ -54,8 +54,15 @@ const CooldownTimer = ({ secondsRemaining, onFinish }: CooldownTimerProps) => {
|
||||
|
||||
return displayTime
|
||||
? (
|
||||
<Tooltip popupContent={t('modelProvider.apiKeyRateLimit', { ns: 'common', seconds: displayTime })}>
|
||||
<SimplePieChart percentage={Math.round(displayTime / 60 * 100)} className="h-3 w-3" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<SimplePieChart percentage={Math.round(displayTime / 60 * 100)} className="h-3 w-3" />
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('modelProvider.apiKeyRateLimit', { ns: 'common', seconds: displayTime })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
: null
|
||||
|
||||
@ -4,6 +4,8 @@ import UsagePrioritySection from '../usage-priority-section'
|
||||
|
||||
describe('UsagePrioritySection', () => {
|
||||
const onSelect = vi.fn()
|
||||
const getAiCreditsButton = () => screen.getByRole('button', { name: /aiCreditsOption/ })
|
||||
const getApiKeyButton = () => screen.getByRole('button', { name: /apiKeyOption/ })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -15,7 +17,8 @@ describe('UsagePrioritySection', () => {
|
||||
render(<UsagePrioritySection value="credits" onSelect={onSelect} />)
|
||||
|
||||
expect(screen.getByText(/usagePriority/))!.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2)
|
||||
expect(getAiCreditsButton()).toBeInTheDocument()
|
||||
expect(getApiKeyButton()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,24 +27,21 @@ describe('UsagePrioritySection', () => {
|
||||
it('should highlight AI credits option when value is credits', () => {
|
||||
render(<UsagePrioritySection value="credits" onSelect={onSelect} />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons[0]!.className).toContain('border-components-option-card-option-selected-border')
|
||||
expect(buttons[1]!.className).not.toContain('border-components-option-card-option-selected-border')
|
||||
expect(getAiCreditsButton()).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(getApiKeyButton()).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('should highlight API key option when value is apiKey', () => {
|
||||
render(<UsagePrioritySection value="apiKey" onSelect={onSelect} />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons[0]!.className).not.toContain('border-components-option-card-option-selected-border')
|
||||
expect(buttons[1]!.className).toContain('border-components-option-card-option-selected-border')
|
||||
expect(getAiCreditsButton()).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(getApiKeyButton()).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('should highlight API key option when value is apiKeyOnly', () => {
|
||||
render(<UsagePrioritySection value="apiKeyOnly" onSelect={onSelect} />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons[1]!.className).toContain('border-components-option-card-option-selected-border')
|
||||
expect(getApiKeyButton()).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@ -50,7 +50,7 @@ describe('UsagePrioritySection', () => {
|
||||
it('should call onSelect with system when clicking AI credits option', () => {
|
||||
render(<UsagePrioritySection value="apiKey" onSelect={onSelect} />)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[0]!)
|
||||
fireEvent.click(getAiCreditsButton())
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(PreferredProviderTypeEnum.system)
|
||||
})
|
||||
@ -58,7 +58,7 @@ describe('UsagePrioritySection', () => {
|
||||
it('should call onSelect with custom when clicking API key option', () => {
|
||||
render(<UsagePrioritySection value="credits" onSelect={onSelect} />)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[1]!)
|
||||
fireEvent.click(getApiKeyButton())
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(PreferredProviderTypeEnum.custom)
|
||||
})
|
||||
|
||||
@ -26,7 +26,7 @@ export default function UsagePrioritySection({ value, disabled, onSelect }: Usag
|
||||
<div className="p-1">
|
||||
<div className="flex items-center gap-1 rounded-lg p-1">
|
||||
<div className="shrink-0 px-0.5 py-1">
|
||||
<span className="i-ri-arrow-up-double-line block h-4 w-4 text-text-tertiary" />
|
||||
<span aria-hidden="true" className="i-ri-arrow-up-double-line block h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-0.5 py-0.5">
|
||||
<span className="truncate system-sm-medium text-text-secondary">
|
||||
@ -37,22 +37,27 @@ export default function UsagePrioritySection({ value, disabled, onSelect }: Usag
|
||||
</Infotip>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
'shrink-0 rounded-md px-2 py-1 text-center whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:ring-components-button-primary-border focus-visible:outline-hidden disabled:opacity-50',
|
||||
selectedKey === option.key
|
||||
? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-panel-bg system-xs-medium text-text-primary shadow-xs'
|
||||
: 'border border-components-option-card-option-border bg-components-option-card-option-bg system-xs-regular text-text-secondary hover:bg-components-option-card-option-bg-hover',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(option.key)}
|
||||
>
|
||||
{t(option.labelKey, { ns: 'common' })}
|
||||
</button>
|
||||
))}
|
||||
{options.map((option) => {
|
||||
const selected = selectedKey === option.key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
'shrink-0 rounded-md px-2 py-1 text-center whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:ring-components-button-primary-border focus-visible:outline-hidden disabled:opacity-50',
|
||||
selected
|
||||
? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-panel-bg system-xs-medium text-text-primary shadow-xs'
|
||||
: 'border border-components-option-card-option-border bg-components-option-card-option-bg system-xs-regular text-text-secondary hover:bg-components-option-card-option-bg-hover',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(option.key)}
|
||||
>
|
||||
{t(option.labelKey, { ns: 'common' })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -10,11 +10,11 @@ import type {
|
||||
} from '../declarations'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge/index'
|
||||
import GridMask from '@/app/components/base/grid-mask'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import s from '@/app/components/custom/style.module.css'
|
||||
import { AddCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth'
|
||||
@ -152,11 +152,18 @@ const ModelLoadBalancingConfigs = ({
|
||||
<div className="grow">
|
||||
<div className="flex items-center gap-1 text-sm text-text-primary">
|
||||
{t('modelProvider.loadBalancing', { ns: 'common' })}
|
||||
<Tooltip
|
||||
popupContent={t('modelProvider.loadBalancingInfo', { ns: 'common' })}
|
||||
popupClassName="max-w-[300px]"
|
||||
triggerClassName="w-3 h-3"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="flex h-3 w-3 shrink-0 p-px">
|
||||
<span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
{t('modelProvider.loadBalancingInfo', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-xs text-text-tertiary">{t('modelProvider.loadBalancingDescription', { ns: 'common' })}</div>
|
||||
</div>
|
||||
@ -187,8 +194,15 @@ const ModelLoadBalancingConfigs = ({
|
||||
<CooldownTimer secondsRemaining={config.ttl} onFinish={() => clearCountdown(index)} />
|
||||
)
|
||||
: (
|
||||
<Tooltip popupContent={t('modelProvider.apiKeyStatusNormal', { ns: 'common' })}>
|
||||
<Indicator color={credential?.not_allowed_to_use ? 'gray' : 'green'} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<Indicator color={credential?.not_allowed_to_use ? 'gray' : 'green'} />
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('modelProvider.apiKeyStatusNormal', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@ -208,14 +222,21 @@ const ModelLoadBalancingConfigs = ({
|
||||
{!isProviderManaged && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Tooltip popupContent={t('operation.remove', { ns: 'common' })}>
|
||||
<span
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-components-button-secondary-bg text-text-tertiary transition-colors hover:bg-components-button-secondary-bg-hover"
|
||||
onClick={() => updateConfigEntry(index, () => undefined)}
|
||||
data-testid={`load-balancing-remove-${config.id || index}`}
|
||||
>
|
||||
<div className="i-ri-indeterminate-circle-line h-4 w-4" />
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-components-button-secondary-bg text-text-tertiary transition-colors hover:bg-components-button-secondary-bg-hover"
|
||||
onClick={() => updateConfigEntry(index, () => undefined)}
|
||||
data-testid={`load-balancing-remove-${config.id || index}`}
|
||||
>
|
||||
<div className="i-ri-indeterminate-circle-line h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronDownDouble } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
const PriorityUseTip = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={t('modelProvider.priorityUsing', { ns: 'common' }) || ''}
|
||||
>
|
||||
<div className="absolute -top-[5px] -right-[5px] cursor-pointer rounded-[5px] border-[0.5px] border-components-panel-border-subtle bg-util-colors-indigo-indigo-50 shadow-xs">
|
||||
<ChevronDownDouble className="h-3 w-3 rotate-180 text-util-colors-indigo-indigo-600" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="absolute -top-[5px] -right-[5px] cursor-pointer rounded-[5px] border-[0.5px] border-components-panel-border-subtle bg-util-colors-indigo-indigo-50 shadow-xs">
|
||||
<ChevronDownDouble className="h-3 w-3 rotate-180 text-util-colors-indigo-indigo-600" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('modelProvider.priorityUsing', { ns: 'common' }) || ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { OAuthClientSettingsProps } from '../oauth-client-settings'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory } from '../../types'
|
||||
|
||||
const mockGetPluginOAuthUrl = vi.fn().mockResolvedValue({ authorization_url: 'https://auth.example.com' })
|
||||
const mockOpenOAuthPopup = vi.fn()
|
||||
const mockWriteText = vi.fn()
|
||||
const mockOAuthClientSettingsProps: OAuthClientSettingsProps[] = []
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (obj: Record<string, string> | string) => typeof obj === 'string' ? obj : obj.en_US || '',
|
||||
@ -31,11 +35,37 @@ vi.mock('../../hooks/use-credential', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../oauth-client-settings', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div data-testid="oauth-settings-modal">
|
||||
<button data-testid="oauth-settings-close" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
default: (props: OAuthClientSettingsProps) => {
|
||||
mockOAuthClientSettingsProps.push(props)
|
||||
const {
|
||||
open = true,
|
||||
onClose,
|
||||
onOpenChange,
|
||||
schemas,
|
||||
} = props
|
||||
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange?.(false)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="oauth-settings-modal">
|
||||
<button data-testid="oauth-settings-close" onClick={handleClose}>Close</button>
|
||||
{schemas.map(schema => (
|
||||
<div key={schema.name} data-testid={`oauth-schema-${schema.name}`}>
|
||||
<div data-testid={`oauth-schema-label-${schema.name}`}>
|
||||
{React.isValidElement(schema.label) ? schema.label : String(schema.label || '')}
|
||||
</div>
|
||||
{String(schema.default || '')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/types', () => ({
|
||||
@ -56,6 +86,11 @@ describe('AddOAuthButton', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mockOAuthClientSettingsProps.length = 0
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: mockWriteText },
|
||||
})
|
||||
const mod = await import('../add-oauth-button')
|
||||
AddOAuthButton = mod.default
|
||||
})
|
||||
@ -72,6 +107,7 @@ describe('AddOAuthButton', () => {
|
||||
fireEvent.click(screen.getByTestId('oauth-settings-button'))
|
||||
|
||||
expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument()
|
||||
expect(mockOAuthClientSettingsProps.at(-1)?.open).toBe(true)
|
||||
})
|
||||
|
||||
it('should close OAuth settings modal', () => {
|
||||
@ -84,13 +120,37 @@ describe('AddOAuthButton', () => {
|
||||
})
|
||||
|
||||
it('should trigger OAuth flow on main button click', async () => {
|
||||
const mockOnUpdate = vi.fn()
|
||||
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" onUpdate={mockOnUpdate} />)
|
||||
|
||||
const button = screen.getByText('Use OAuth').closest('button')
|
||||
if (button)
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenOAuthPopup).toHaveBeenCalledWith('https://auth.example.com', expect.any(Function))
|
||||
})
|
||||
|
||||
const handleOAuthSuccess = mockOpenOAuthPopup.mock.calls[0]?.[1]
|
||||
expect(handleOAuthSuccess).toBeTypeOf('function')
|
||||
if (typeof handleOAuthSuccess === 'function')
|
||||
handleOAuthSuccess()
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not open OAuth popup when authorization URL is missing', async () => {
|
||||
mockGetPluginOAuthUrl.mockResolvedValueOnce({})
|
||||
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />)
|
||||
|
||||
const button = screen.getByText('Use OAuth').closest('button')
|
||||
if (button)
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockOpenOAuthPopup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should be disabled when disabled prop is true', () => {
|
||||
@ -99,4 +159,96 @@ describe('AddOAuthButton', () => {
|
||||
const button = screen.getByText('Use OAuth').closest('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should open OAuth settings from setup entry when OAuth is not configured', () => {
|
||||
render(
|
||||
<AddOAuthButton
|
||||
pluginPayload={basePayload}
|
||||
oAuthData={{
|
||||
schema: [],
|
||||
is_oauth_custom_client_enabled: false,
|
||||
is_system_oauth_params_exists: false,
|
||||
client_params: {},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
|
||||
|
||||
expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument()
|
||||
expect(mockOAuthClientSettingsProps.at(-1)?.editValues).toMatchObject({
|
||||
__oauth_client__: 'custom',
|
||||
})
|
||||
})
|
||||
|
||||
it('should show custom badge when OAuth custom client is enabled', () => {
|
||||
render(
|
||||
<AddOAuthButton
|
||||
pluginPayload={basePayload}
|
||||
buttonText="Use OAuth"
|
||||
oAuthData={{
|
||||
schema: [],
|
||||
is_oauth_custom_client_enabled: true,
|
||||
is_system_oauth_params_exists: true,
|
||||
client_params: {},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('plugin.auth.custom')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should build custom OAuth schema and edit values for settings modal', () => {
|
||||
const schema = [
|
||||
{
|
||||
name: 'client_id',
|
||||
label: { en_US: 'Client ID' },
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
default: 'schema-client-id',
|
||||
},
|
||||
] as FormSchema[]
|
||||
|
||||
render(
|
||||
<AddOAuthButton
|
||||
pluginPayload={basePayload}
|
||||
buttonText="Use OAuth"
|
||||
oAuthData={{
|
||||
schema,
|
||||
is_oauth_custom_client_enabled: true,
|
||||
is_system_oauth_params_exists: true,
|
||||
client_params: { client_id: 'stored-client-id' },
|
||||
redirect_uri: 'https://redirect.example.com',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('oauth-settings-button'))
|
||||
|
||||
const settingsProps = mockOAuthClientSettingsProps.at(-1)
|
||||
expect(settingsProps?.editValues).toMatchObject({
|
||||
__oauth_client__: 'custom',
|
||||
client_id: 'stored-client-id',
|
||||
})
|
||||
expect(settingsProps?.hasOriginalClientParams).toBe(true)
|
||||
expect(settingsProps?.schemas[0]).toMatchObject({
|
||||
name: '__oauth_client__',
|
||||
default: 'custom',
|
||||
})
|
||||
expect(settingsProps?.schemas[1]).toMatchObject({
|
||||
name: 'client_id',
|
||||
default: 'stored-client-id',
|
||||
show_on: [
|
||||
{
|
||||
variable: '__oauth_client__',
|
||||
value: 'custom',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(screen.getByText('https://redirect.example.com')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(screen.getByTestId('oauth-schema-label-client_id')).getByRole('button'))
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('https://redirect.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ApiKeyModalProps } from '../api-key-modal'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@ -384,6 +385,29 @@ describe('ApiKeyModal', () => {
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close on backdrop click when nested inside another dialog', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent backdropClassName="bg-transparent">
|
||||
<ControlledModalHarness ApiKeyModal={ApiKeyModal} onClose={mockOnClose} />
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
|
||||
const backdrop = document.querySelector('.bg-background-overlay')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
|
||||
fireEvent.pointerDown(backdrop!)
|
||||
fireEvent.mouseDown(backdrop!)
|
||||
fireEvent.click(backdrop!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
|
||||
})
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render readme entrance when detail is provided', () => {
|
||||
const payload = { ...basePayload, detail: { name: 'Test' } as never }
|
||||
render(<ApiKeyModal pluginPayload={payload} />)
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import type { OAuthClientSettingsProps } from '../oauth-client-settings'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory } from '../../types'
|
||||
@ -20,7 +24,8 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
const mockSetPluginOAuthCustomClient = vi.fn().mockResolvedValue({})
|
||||
const mockDeletePluginOAuthCustomClient = vi.fn().mockResolvedValue({})
|
||||
const mockInvalidPluginOAuthClientSchema = vi.fn()
|
||||
const mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
|
||||
let mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
|
||||
let mockAuthFormProps: Record<string, unknown> | undefined
|
||||
|
||||
vi.mock('../../hooks/use-credential', () => ({
|
||||
useSetPluginOAuthCustomClientHook: () => ({
|
||||
@ -40,36 +45,19 @@ vi.mock('../../../readme-panel/store', () => ({
|
||||
ReadmeShowType: { modal: 'modal' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/modal/modal', () => ({
|
||||
default: ({ children, title, onClose: _onClose, onConfirm, onCancel, onExtraButtonClick, footerSlot }: {
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
onClose?: () => void
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
onExtraButtonClick?: () => void
|
||||
footerSlot?: React.ReactNode
|
||||
[key: string]: unknown
|
||||
}) => (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
{children}
|
||||
<button data-testid="modal-confirm" onClick={onConfirm}>Save And Auth</button>
|
||||
<button data-testid="modal-cancel" onClick={onCancel}>Save Only</button>
|
||||
<button data-testid="modal-close" onClick={onExtraButtonClick}>Cancel</button>
|
||||
{!!footerSlot && <div data-testid="footer-slot">{footerSlot}</div>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
|
||||
default: React.forwardRef((_props: Record<string, unknown>, ref: React.Ref<unknown>) => {
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', () => {
|
||||
const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => {
|
||||
mockAuthFormProps = props
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getFormValues: () => mockFormValues,
|
||||
}))
|
||||
return <div data-testid="auth-form" />
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
default: MockAuthForm,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-form', () => ({
|
||||
useForm: (config: Record<string, unknown>) => ({
|
||||
@ -89,11 +77,72 @@ const defaultSchemas = [
|
||||
{ name: 'client_id', label: 'Client ID', type: 'text-input', required: true },
|
||||
] as never
|
||||
|
||||
const PopoverSettingsHarness = ({
|
||||
OAuthClientSettings,
|
||||
onClose,
|
||||
onPopoverClose,
|
||||
}: {
|
||||
OAuthClientSettings: React.FC<OAuthClientSettingsProps>
|
||||
onClose: () => void
|
||||
onPopoverClose: () => void
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(true)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen)
|
||||
onPopoverClose()
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger render={<button type="button">OAuth</button>} />
|
||||
<PopoverContent>
|
||||
<div data-testid="oauth-popover">
|
||||
<OAuthClientSettings
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const ControlledSettingsHarness = ({
|
||||
OAuthClientSettings,
|
||||
onClose,
|
||||
}: {
|
||||
OAuthClientSettings: React.FC<OAuthClientSettingsProps>
|
||||
onClose: () => void
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(true)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-testid="modal-open-state">{String(open)}</div>
|
||||
<OAuthClientSettings
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('OAuthClientSettings', () => {
|
||||
let OAuthClientSettings: (typeof import('../oauth-client-settings'))['default']
|
||||
let OAuthClientSettings: React.FC<OAuthClientSettingsProps>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
|
||||
mockAuthFormProps = undefined
|
||||
const mod = await import('../oauth-client-settings')
|
||||
OAuthClientSettings = mod.default
|
||||
})
|
||||
@ -120,6 +169,36 @@ describe('OAuthClientSettings', () => {
|
||||
expect(screen.getByTestId('auth-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render backdrop when nested inside another dialog', () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent backdropClassName="bg-transparent">
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
|
||||
expect(document.querySelector('.bg-background-overlay')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass schema defaults to auth form', () => {
|
||||
render(
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={[
|
||||
{ name: 'client_id', label: 'Client ID', type: 'text-input', required: true, default: 'default-client-id' },
|
||||
] as never}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockAuthFormProps?.defaultValues).toMatchObject({
|
||||
client_id: 'default-client-id',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onClose when cancel clicked', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
render(
|
||||
@ -134,6 +213,33 @@ describe('OAuthClientSettings', () => {
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close through controlled open state when cancel clicked', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
render(<ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-close'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
|
||||
})
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close when backdrop is clicked', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
render(<ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />)
|
||||
|
||||
const backdrop = document.querySelector('.bg-background-overlay')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(backdrop!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
|
||||
})
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save settings on save only button click', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnUpdate = vi.fn()
|
||||
@ -155,6 +261,38 @@ describe('OAuthClientSettings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore duplicate save clicks while action is pending', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
let resolveSave: (value: object) => void = () => {}
|
||||
mockSetPluginOAuthCustomClient.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveSave = resolve
|
||||
}))
|
||||
|
||||
render(
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
|
||||
expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveSave({})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should save and authorize on confirm button click', async () => {
|
||||
const mockOnAuth = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
@ -172,6 +310,34 @@ describe('OAuthClientSettings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove custom client settings', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnUpdate = vi.fn()
|
||||
render(
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
editValues={{ client_id: 'test-id' }}
|
||||
hasOriginalClientParams
|
||||
onClose={mockOnClose}
|
||||
onUpdate={mockOnUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-extra'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeletePluginOAuthCustomClient).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
expect(mockInvalidPluginOAuthClientSchema).toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'common.api.actionSuccess',
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render readme entrance when detail is provided', () => {
|
||||
const payload = { ...basePayload, detail: { name: 'Test' } as never }
|
||||
render(
|
||||
@ -183,4 +349,26 @@ describe('OAuthClientSettings', () => {
|
||||
|
||||
expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should stay open when clicking inside the modal from a popover', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnPopoverClose = vi.fn()
|
||||
|
||||
render(
|
||||
<PopoverSettingsHarness
|
||||
OAuthClientSettings={OAuthClientSettings}
|
||||
onClose={mockOnClose}
|
||||
onPopoverClose={mockOnPopoverClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
const form = await screen.findByTestId('auth-form')
|
||||
|
||||
await user.click(form)
|
||||
|
||||
expect(mockOnClose).not.toHaveBeenCalled()
|
||||
expect(mockOnPopoverClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,11 +3,6 @@ import type { PluginPayload } from '../types'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiClipboardLine,
|
||||
RiEqualizer2Line,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@ -40,10 +35,12 @@ export type AddOAuthButtonProps = {
|
||||
schema?: FormSchema[]
|
||||
is_oauth_custom_client_enabled?: boolean
|
||||
is_system_oauth_params_exists?: boolean
|
||||
client_params?: Record<string, any>
|
||||
client_params?: Record<string, unknown>
|
||||
redirect_uri?: string
|
||||
}
|
||||
}
|
||||
type OAuthData = NonNullable<AddOAuthButtonProps['oAuthData']>
|
||||
|
||||
const AddOAuthButton = ({
|
||||
pluginPayload,
|
||||
buttonVariant = 'primary',
|
||||
@ -59,22 +56,27 @@ const AddOAuthButton = ({
|
||||
const { t } = useTranslation()
|
||||
const renderI18nObject = useRenderI18nObject()
|
||||
const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false)
|
||||
const [isOAuthSettingsMounted, setIsOAuthSettingsMounted] = useState(false)
|
||||
const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload)
|
||||
const { data, isLoading } = useGetPluginOAuthClientSchemaHook(pluginPayload)
|
||||
const mergedOAuthData = useMemo(() => {
|
||||
const mergedOAuthData = useMemo<OAuthData>(() => {
|
||||
if (oAuthData)
|
||||
return oAuthData
|
||||
|
||||
return data
|
||||
return data || {}
|
||||
}, [oAuthData, data])
|
||||
const {
|
||||
schema = [],
|
||||
is_oauth_custom_client_enabled,
|
||||
is_system_oauth_params_exists,
|
||||
client_params,
|
||||
client_params = {},
|
||||
redirect_uri,
|
||||
} = mergedOAuthData as any || {}
|
||||
} = mergedOAuthData
|
||||
const isConfigured = is_system_oauth_params_exists || is_oauth_custom_client_enabled
|
||||
const openOAuthSettings = useCallback(() => {
|
||||
setIsOAuthSettingsMounted(true)
|
||||
setIsOAuthSettingsOpen(true)
|
||||
}, [])
|
||||
const handleOAuth = useCallback(async () => {
|
||||
const { authorization_url } = await getPluginOAuthUrl()
|
||||
|
||||
@ -91,7 +93,7 @@ const AddOAuthButton = ({
|
||||
<div className="w-full">
|
||||
<div className="mb-4 flex rounded-xl bg-background-section-burn p-4">
|
||||
<div className="mr-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg">
|
||||
<RiInformation2Fill className="h-5 w-5 text-text-accent" />
|
||||
<span className="i-ri-information-2-fill h-5 w-5 text-text-accent" />
|
||||
</div>
|
||||
<div className="w-0 grow">
|
||||
<div className="mb-1.5 system-sm-regular">
|
||||
@ -107,7 +109,7 @@ const AddOAuthButton = ({
|
||||
navigator.clipboard.writeText(redirect_uri || '')
|
||||
}}
|
||||
>
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
<span className="i-ri-clipboard-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
@ -232,10 +234,10 @@ const AddOAuthButton = ({
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsOAuthSettingsOpen(true)
|
||||
openOAuthSettings()
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line className="h-4 w-4" />
|
||||
<span className="i-ri-equalizer-2-line h-4 w-4" />
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
@ -244,18 +246,20 @@ const AddOAuthButton = ({
|
||||
!isConfigured && (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
onClick={() => setIsOAuthSettingsOpen(true)}
|
||||
onClick={openOAuthSettings}
|
||||
disabled={disabled}
|
||||
className="w-full"
|
||||
>
|
||||
<RiEqualizer2Line className="mr-0.5 h-4 w-4" />
|
||||
<span className="mr-0.5 i-ri-equalizer-2-line h-4 w-4" />
|
||||
{t('auth.setupOAuth', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{
|
||||
isOAuthSettingsOpen && (
|
||||
isOAuthSettingsMounted && (
|
||||
<OAuthClientSettings
|
||||
open={isOAuthSettingsOpen}
|
||||
onOpenChange={setIsOAuthSettingsOpen}
|
||||
pluginPayload={pluginPayload}
|
||||
onClose={() => setIsOAuthSettingsOpen(false)}
|
||||
disabled={disabled || isLoading}
|
||||
|
||||
@ -140,7 +140,10 @@ const ApiKeyModal = ({
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<DialogContent className="w-[640px]! max-w-[calc(100vw-2rem)]! p-0!">
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-[640px]! max-w-[calc(100vw-2rem)]! p-0!"
|
||||
>
|
||||
<div data-testid="modal" className="flex max-h-[80dvh] flex-col">
|
||||
<div className="relative shrink-0 p-6 pr-14 pb-3">
|
||||
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
|
||||
|
||||
@ -2,12 +2,12 @@ import type { PluginPayload } from '../types'
|
||||
import type { AddApiKeyButtonProps } from './add-api-key-button'
|
||||
import type { AddOAuthButtonProps } from './add-oauth-button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import AddApiKeyButton from './add-api-key-button'
|
||||
import AddOAuthButton from './add-oauth-button'
|
||||
|
||||
@ -79,8 +79,11 @@ const Authorize = ({
|
||||
|
||||
if (notAllowCustomCredential) {
|
||||
return (
|
||||
<Tooltip popupContent={t('auth.credentialUnavailable', { ns: 'plugin' })}>
|
||||
{Item}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={Item} />
|
||||
<TooltipContent>
|
||||
{t('auth.credentialUnavailable', { ns: 'plugin' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@ -100,8 +103,11 @@ const Authorize = ({
|
||||
|
||||
if (notAllowCustomCredential) {
|
||||
return (
|
||||
<Tooltip popupContent={t('auth.credentialUnavailable', { ns: 'plugin' })}>
|
||||
{Item}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={Item} />
|
||||
<TooltipContent>
|
||||
{t('auth.credentialUnavailable', { ns: 'plugin' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import type {
|
||||
FormSchema,
|
||||
} from '@/app/components/base/form/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import {
|
||||
useForm,
|
||||
@ -17,7 +18,6 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import { ReadmeShowType } from '../../readme-panel/store'
|
||||
import {
|
||||
@ -26,10 +26,12 @@ import {
|
||||
useSetPluginOAuthCustomClientHook,
|
||||
} from '../hooks/use-credential'
|
||||
|
||||
type OAuthClientSettingsProps = {
|
||||
export type OAuthClientSettingsProps = {
|
||||
pluginPayload: PluginPayload
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onClose?: () => void
|
||||
editValues?: Record<string, any>
|
||||
editValues?: Record<string, unknown>
|
||||
disabled?: boolean
|
||||
schemas: FormSchema[]
|
||||
onAuth?: () => Promise<void>
|
||||
@ -38,6 +40,8 @@ type OAuthClientSettingsProps = {
|
||||
}
|
||||
const OAuthClientSettings = ({
|
||||
pluginPayload,
|
||||
open = true,
|
||||
onOpenChange,
|
||||
onClose,
|
||||
editValues,
|
||||
disabled,
|
||||
@ -53,11 +57,16 @@ const OAuthClientSettings = ({
|
||||
doingActionRef.current = value
|
||||
setDoingAction(value)
|
||||
}, [])
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
onOpenChange?.(nextOpen)
|
||||
if (!nextOpen)
|
||||
onClose?.()
|
||||
}, [onClose, onOpenChange])
|
||||
const defaultValues = schemas.reduce((acc, schema) => {
|
||||
if (schema.default)
|
||||
acc[schema.name] = schema.default
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
}, {} as Record<string, unknown>)
|
||||
const { mutateAsync: setPluginOAuthCustomClient } = useSetPluginOAuthCustomClientHook(pluginPayload)
|
||||
const invalidPluginOAuthClientSchema = useInvalidPluginOAuthClientSchemaHook(pluginPayload)
|
||||
const formRef = useRef<FormRefObject>(null)
|
||||
@ -87,6 +96,7 @@ const OAuthClientSettings = ({
|
||||
})
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
|
||||
onOpenChange?.(false)
|
||||
onClose?.()
|
||||
onUpdate?.()
|
||||
invalidPluginOAuthClientSchema()
|
||||
@ -94,7 +104,7 @@ const OAuthClientSettings = ({
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [onClose, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, t, handleSetDoingAction])
|
||||
}, [onClose, onOpenChange, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, t, handleSetDoingAction])
|
||||
|
||||
const handleConfirmAndAuthorize = useCallback(async () => {
|
||||
await handleConfirm()
|
||||
@ -110,6 +120,7 @@ const OAuthClientSettings = ({
|
||||
handleSetDoingAction(true)
|
||||
await deletePluginOAuthCustomClient()
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
onOpenChange?.(false)
|
||||
onClose?.()
|
||||
onUpdate?.()
|
||||
invalidPluginOAuthClientSchema()
|
||||
@ -117,53 +128,89 @@ const OAuthClientSettings = ({
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, t, handleSetDoingAction, onClose])
|
||||
}, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, t, handleSetDoingAction, onClose, onOpenChange])
|
||||
const form = useForm({
|
||||
defaultValues: editValues || defaultValues,
|
||||
})
|
||||
const __oauth_client__ = useStore(form.store, s => s.values.__oauth_client__)
|
||||
const isDisabled = disabled || doingAction
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('auth.oauthClientSettings', { ns: 'plugin' })}
|
||||
confirmButtonText={t('auth.saveAndAuth', { ns: 'plugin' })}
|
||||
cancelButtonText={t('auth.saveOnly', { ns: 'plugin' })}
|
||||
extraButtonText={t('operation.cancel', { ns: 'common' })}
|
||||
showExtraButton
|
||||
extraButtonVariant="secondary"
|
||||
onExtraButtonClick={onClose}
|
||||
onClose={onClose}
|
||||
onCancel={handleConfirm}
|
||||
onConfirm={handleConfirmAndAuthorize}
|
||||
disabled={disabled || doingAction}
|
||||
footerSlot={
|
||||
__oauth_client__ === 'custom' && hasOriginalClientParams && (
|
||||
<div className="grow">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="text-components-button-destructive-secondary-text"
|
||||
disabled={disabled || doingAction || !editValues}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
containerClassName="pt-0"
|
||||
wrapperClassName="z-1002!"
|
||||
clickOutsideNotClose={true}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
<AuthForm
|
||||
formFromProps={form}
|
||||
ref={formRef}
|
||||
formSchemas={schemas}
|
||||
defaultValues={editValues || defaultValues}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Modal>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-[480px]! max-w-[calc(100vw-2rem)]! p-0!"
|
||||
>
|
||||
<div data-testid="modal" className="flex max-h-[80dvh] flex-col">
|
||||
<div className="relative shrink-0 p-6 pr-14 pb-3">
|
||||
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
|
||||
{t('auth.oauthClientSettings', { ns: 'plugin' })}
|
||||
</DialogTitle>
|
||||
<DialogCloseButton
|
||||
data-testid="modal-x-close"
|
||||
className="top-5 right-5 h-8 w-8 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3 pt-0">
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
<AuthForm
|
||||
formFromProps={form}
|
||||
ref={formRef}
|
||||
formSchemas={schemas}
|
||||
defaultValues={editValues || defaultValues}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-between p-6 pt-5">
|
||||
<div>
|
||||
{__oauth_client__ === 'custom' && hasOriginalClientParams && (
|
||||
<Button
|
||||
data-testid="modal-extra"
|
||||
variant="secondary"
|
||||
className="text-components-button-destructive-secondary-text"
|
||||
disabled={isDisabled || !editValues}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
data-testid="modal-close"
|
||||
variant="secondary"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<div className="mx-3 h-4 w-px bg-divider-regular"></div>
|
||||
<Button
|
||||
data-testid="modal-cancel"
|
||||
onClick={handleConfirm}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{t('auth.saveOnly', { ns: 'plugin' })}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="modal-confirm"
|
||||
className="ml-2"
|
||||
variant="primary"
|
||||
onClick={handleConfirmAndAuthorize}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{t('auth.saveAndAuth', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -110,7 +110,7 @@ describe('Item Component', () => {
|
||||
|
||||
const { container } = render(<Item credential={credential} />)
|
||||
|
||||
expect(container.querySelector('[data-state]')).toBeInTheDocument()
|
||||
expect(container.firstElementChild).toHaveClass('cursor-not-allowed', 'opacity-50')
|
||||
})
|
||||
|
||||
it('should not call onItemClick when disabled is true', () => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Credential } from '../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiDeleteBinLine,
|
||||
@ -16,7 +17,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
|
||||
@ -172,55 +172,76 @@ const Item = ({
|
||||
}
|
||||
{
|
||||
!disableRename && !credential.from_enterprise && !credential.not_allowed_to_use && (
|
||||
<Tooltip popupContent={t('operation.rename', { ns: 'common' })}>
|
||||
<ActionButton
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setRenaming(true)
|
||||
setRenameValue(credential.name)
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setRenaming(true)
|
||||
setRenameValue(credential.name)
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.rename', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isOAuth && !disableEdit && !credential.from_enterprise && !credential.not_allowed_to_use && (
|
||||
<Tooltip popupContent={t('operation.edit', { ns: 'common' })}>
|
||||
<ActionButton
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit?.(
|
||||
credential.id,
|
||||
{
|
||||
...credential.credentials,
|
||||
__name__: credential.name,
|
||||
__credential_id__: credential.id,
|
||||
},
|
||||
)
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit?.(
|
||||
credential.id,
|
||||
{
|
||||
...credential.credentials,
|
||||
__name__: credential.name,
|
||||
__credential_id__: credential.id,
|
||||
},
|
||||
)
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.edit', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
!disableDelete && !credential.from_enterprise && (
|
||||
<Tooltip popupContent={t('operation.delete', { ns: 'common' })}>
|
||||
<ActionButton
|
||||
className="hover:bg-transparent"
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete?.(credential.id)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 text-text-tertiary hover:text-text-destructive" />
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
className="hover:bg-transparent"
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete?.(credential.id)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 text-text-tertiary hover:text-text-destructive" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@ -232,8 +253,11 @@ const Item = ({
|
||||
|
||||
if (credential.not_allowed_to_use) {
|
||||
return (
|
||||
<Tooltip popupContent={t('auth.customCredentialUnavailable', { ns: 'plugin' })}>
|
||||
{CredentialItem}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={CredentialItem} />
|
||||
<TooltipContent>
|
||||
{t('auth.customCredentialUnavailable', { ns: 'plugin' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import type { Node } from 'reactflow'
|
||||
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
import type { NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiQuestionLine,
|
||||
@ -11,7 +12,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector'
|
||||
import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
|
||||
import { useAllMCPTools } from '@/service/use-tools'
|
||||
@ -112,10 +112,15 @@ const MultipleToolSelector = ({
|
||||
<div className="flex h-6 items-center system-sm-semibold-uppercase text-text-secondary">{label}</div>
|
||||
{required && <div className="text-red-500">*</div>}
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
popupContent={tooltip}
|
||||
>
|
||||
<div><RiQuestionLine className="h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" /></div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div><RiQuestionLine className="h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" /></div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{supportCollapse && (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type * as React from 'react'
|
||||
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
@ -134,36 +134,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/modal/modal', () => ({
|
||||
default: ({
|
||||
children,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
confirmButtonText,
|
||||
bottomSlot,
|
||||
size,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
confirmButtonText: string
|
||||
bottomSlot?: React.ReactNode
|
||||
size?: string
|
||||
disabled?: boolean
|
||||
}) => (
|
||||
<div data-testid="modal" data-size={size} data-disabled={disabled}>
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<div data-testid="modal-content">{children}</div>
|
||||
<div data-testid="modal-bottom-slot">{bottomSlot}</div>
|
||||
<button data-testid="modal-confirm" onClick={onConfirm} disabled={disabled}>{confirmButtonText}</button>
|
||||
<button data-testid="modal-close" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
type MockFormValuesConfig = {
|
||||
values: Record<string, unknown>
|
||||
isCheckValidated: boolean
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
'use client'
|
||||
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import {
|
||||
ConfigurationStepContent,
|
||||
@ -48,46 +55,93 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
|
||||
const isApiKeyType = createType === SupportedCreationMethods.APIKEY
|
||||
const isVerifyStep = currentStep === ApiKeyStep.Verify
|
||||
const isConfigurationStep = currentStep === ApiKeyStep.Configuration
|
||||
const isDisabled = isVerifyingCredentials || isBuilding
|
||||
const modalSize = createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t(MODAL_TITLE_KEY_MAP[createType], { ns: 'pluginTrigger' })}
|
||||
confirmButtonText={confirmButtonText}
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
disabled={isVerifyingCredentials || isBuilding}
|
||||
bottomSlot={isVerifyStep ? <EncryptedBottom /> : null}
|
||||
size={createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'}
|
||||
containerClassName="min-h-[360px]"
|
||||
clickOutsideNotClose
|
||||
>
|
||||
{isApiKeyType && <MultiSteps currentStep={currentStep} />}
|
||||
<Dialog open disablePointerDismissal>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className={cn(
|
||||
'flex max-h-[80%] min-h-[360px] flex-col overflow-hidden p-0 shadow-xs',
|
||||
modalSize === 'md'
|
||||
? 'w-[640px] max-w-[calc(100vw-2rem)]'
|
||||
: 'w-[480px] max-w-[calc(100vw-2rem)]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
data-testid="modal"
|
||||
data-size={modalSize}
|
||||
data-disabled={isDisabled}
|
||||
>
|
||||
<div className="relative shrink-0 p-6 pr-14 pb-3">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary" data-testid="modal-title">
|
||||
{t(MODAL_TITLE_KEY_MAP[createType], { ns: 'pluginTrigger' })}
|
||||
</DialogTitle>
|
||||
<DialogCloseButton
|
||||
className="top-5 right-5 h-8 w-8 rounded-lg [&>span]:h-5 [&>span]:w-5"
|
||||
data-testid="modal-close"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isVerifyStep && (
|
||||
<VerifyStepContent
|
||||
apiKeyCredentialsSchema={apiKeyCredentialsSchema}
|
||||
apiKeyCredentialsFormRef={formRefs.apiKeyCredentialsFormRef}
|
||||
onChange={handleApiKeyCredentialsChange}
|
||||
/>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
{isApiKeyType && <MultiSteps currentStep={currentStep} />}
|
||||
|
||||
{isConfigurationStep && (
|
||||
<ConfigurationStepContent
|
||||
createType={createType}
|
||||
subscriptionBuilder={subscriptionBuilder}
|
||||
subscriptionFormRef={formRefs.subscriptionFormRef}
|
||||
autoCommonParametersSchema={autoCommonParametersSchema}
|
||||
autoCommonParametersFormRef={formRefs.autoCommonParametersFormRef}
|
||||
manualPropertiesSchema={manualPropertiesSchema}
|
||||
manualPropertiesFormRef={formRefs.manualPropertiesFormRef}
|
||||
onManualPropertiesChange={handleManualPropertiesChange}
|
||||
logs={logData?.logs || []}
|
||||
pluginId={detail?.plugin_id || ''}
|
||||
pluginName={detail?.name || ''}
|
||||
provider={detail?.provider || ''}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
{isVerifyStep && (
|
||||
<VerifyStepContent
|
||||
apiKeyCredentialsSchema={apiKeyCredentialsSchema}
|
||||
apiKeyCredentialsFormRef={formRefs.apiKeyCredentialsFormRef}
|
||||
onChange={handleApiKeyCredentialsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isConfigurationStep && (
|
||||
<ConfigurationStepContent
|
||||
createType={createType}
|
||||
subscriptionBuilder={subscriptionBuilder}
|
||||
subscriptionFormRef={formRefs.subscriptionFormRef}
|
||||
autoCommonParametersSchema={autoCommonParametersSchema}
|
||||
autoCommonParametersFormRef={formRefs.autoCommonParametersFormRef}
|
||||
manualPropertiesSchema={manualPropertiesSchema}
|
||||
manualPropertiesFormRef={formRefs.manualPropertiesFormRef}
|
||||
onManualPropertiesChange={handleManualPropertiesChange}
|
||||
logs={logData?.logs || []}
|
||||
pluginId={detail?.plugin_id || ''}
|
||||
pluginName={detail?.name || ''}
|
||||
provider={detail?.provider || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end p-6 pt-5">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
disabled={isDisabled}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-2"
|
||||
variant="primary"
|
||||
disabled={isDisabled}
|
||||
data-testid="modal-confirm"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{confirmButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isVerifyStep && (
|
||||
<div className="shrink-0">
|
||||
<EncryptedBottom />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,13 +3,13 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiAddLine, RiEqualizer2Line } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ActionButton, ActionButtonState } from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||
import { useInitiateTriggerOAuth, useTriggerOAuthConfig, useTriggerProviderInfo } from '@/service/use-triggers'
|
||||
import { SupportedCreationMethods } from '../../../types'
|
||||
@ -86,10 +86,17 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
</Badge>
|
||||
),
|
||||
extra: (
|
||||
<Tooltip popupContent={t('subscription.addType.options.oauth.clientSettings', { ns: 'pluginTrigger' })}>
|
||||
<ActionButton onClick={onClickClientSettings}>
|
||||
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton onClick={onClickClientSettings}>
|
||||
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('subscription.addType.options.oauth.clientSettings', { ns: 'pluginTrigger' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
show: supportedMethods.includes(SupportedCreationMethods.OAUTH),
|
||||
@ -102,7 +109,20 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
{
|
||||
value: SupportedCreationMethods.MANUAL,
|
||||
label: t('subscription.addType.options.manual.description', { ns: 'pluginTrigger' }),
|
||||
extra: <Tooltip popupContent={t('subscription.addType.options.manual.tip', { ns: 'pluginTrigger' })} />,
|
||||
extra: (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 p-px">
|
||||
<span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('subscription.addType.options.manual.tip', { ns: 'pluginTrigger' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
show: supportedMethods.includes(SupportedCreationMethods.MANUAL),
|
||||
},
|
||||
]
|
||||
@ -196,30 +216,42 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
&& (
|
||||
<div className="ml-auto flex items-center">
|
||||
<div className="h-4 w-px bg-text-primary-on-surface opacity-15" />
|
||||
<Tooltip popupContent={t('subscription.addType.options.oauth.clientSettings', { ns: 'pluginTrigger' })}>
|
||||
<div onClick={onClickClientSettings} className="p-2">
|
||||
<RiEqualizer2Line className="size-4 text-components-button-primary-text" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div onClick={onClickClientSettings} className="p-2">
|
||||
<RiEqualizer2Line className="size-4 text-components-button-primary-text" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('subscription.addType.options.oauth.clientSettings', { ns: 'pluginTrigger' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Tooltip
|
||||
popupContent={subscriptionCount >= MAX_COUNT ? t('subscription.maxCount', { ns: 'pluginTrigger', num: MAX_COUNT }) : t(`subscription.addType.options.${methodType!.toLowerCase() as Lowercase<SupportedCreationMethods>}.description`, { ns: 'pluginTrigger' })}
|
||||
disabled={!(supportedMethods?.length === 1 || subscriptionCount >= MAX_COUNT)}
|
||||
>
|
||||
<ActionButton
|
||||
onClick={onClickCreate}
|
||||
className={cn(
|
||||
'float-right',
|
||||
shape === 'circle' && 'rounded-full! border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg-hover text-components-button-secondary-accent-text shadow-xs hover:border-components-button-secondary-border-disabled hover:bg-components-button-secondary-bg-disabled hover:text-components-button-secondary-accent-text-disabled',
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
disabled={!(supportedMethods?.length === 1 || subscriptionCount >= MAX_COUNT)}
|
||||
render={(
|
||||
<ActionButton
|
||||
onClick={onClickCreate}
|
||||
className={cn(
|
||||
'float-right',
|
||||
shape === 'circle' && 'rounded-full! border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg-hover text-components-button-secondary-accent-text shadow-xs hover:border-components-button-secondary-border-disabled hover:bg-components-button-secondary-bg-disabled hover:text-components-button-secondary-accent-text-disabled',
|
||||
)}
|
||||
state={subscriptionCount >= MAX_COUNT ? ActionButtonState.Disabled : ActionButtonState.Default}
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
state={subscriptionCount >= MAX_COUNT ? ActionButtonState.Disabled : ActionButtonState.Default}
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</ActionButton>
|
||||
/>
|
||||
<TooltipContent>
|
||||
{subscriptionCount >= MAX_COUNT ? t('subscription.maxCount', { ns: 'pluginTrigger', num: MAX_COUNT }) : t(`subscription.addType.options.${methodType!.toLowerCase() as Lowercase<SupportedCreationMethods>}.description`, { ns: 'pluginTrigger' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { CreateButtonType, CreateSubscriptionButton } from './create'
|
||||
import SubscriptionCard from './subscription-card'
|
||||
import { useSubscriptionList } from './use-subscription-list'
|
||||
@ -30,7 +30,18 @@ export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
|
||||
<span className="system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('subscription.listNum', { ns: 'pluginTrigger', num: subscriptionCount })}
|
||||
</span>
|
||||
<Tooltip popupContent={t('subscription.list.tip', { ns: 'pluginTrigger' })} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 p-px">
|
||||
<span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('subscription.list.tip', { ns: 'pluginTrigger' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<CreateSubscriptionButton
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiCheckLine, RiDeleteBinLine, RiWebhookLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { CreateButtonType, CreateSubscriptionButton } from './create'
|
||||
import { DeleteConfirm } from './delete-confirm'
|
||||
import { useSubscriptionList } from './use-subscription-list'
|
||||
@ -33,7 +33,18 @@ export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({
|
||||
<span className="system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('subscription.listNum', { ns: 'pluginTrigger', num: subscriptionCount })}
|
||||
</span>
|
||||
<Tooltip popupContent={t('subscription.list.tip', { ns: 'pluginTrigger' })} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 p-px">
|
||||
<span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('subscription.list.tip', { ns: 'pluginTrigger' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<CreateSubscriptionButton
|
||||
buttonType={CreateButtonType.ICON_BUTTON}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ToolItem from '../tool-item'
|
||||
|
||||
@ -26,21 +27,6 @@ vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version'
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({
|
||||
children,
|
||||
popupContent,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
popupContent: React.ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
{children}
|
||||
<div>{popupContent}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ToolItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -102,7 +88,7 @@ describe('ToolItem', () => {
|
||||
expect(onInstall).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('blocks unsupported MCP tools and still exposes error state', () => {
|
||||
it('blocks unsupported MCP tools and still exposes error state', async () => {
|
||||
mcpAllowed = false
|
||||
const { rerender } = render(
|
||||
<ToolItem
|
||||
@ -125,6 +111,7 @@ describe('ToolItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('tool failed')).toBeInTheDocument()
|
||||
await userEvent.hover(screen.getByLabelText('tool failed'))
|
||||
expect(await screen.findByText('tool failed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEqualizer2Line,
|
||||
@ -13,7 +14,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { Group } from '@/app/components/base/icons/src/vender/other'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { ToolTipContent } from '@/app/components/base/tooltip/content'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
@ -167,12 +167,17 @@ const ToolItem = ({
|
||||
/>
|
||||
)}
|
||||
{isError && (
|
||||
<Tooltip
|
||||
popupContent={errorTip}
|
||||
>
|
||||
<div>
|
||||
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div aria-label={typeof errorTip === 'string' ? errorTip : undefined}>
|
||||
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{errorTip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -4,6 +4,7 @@ import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../t
|
||||
import type { PluginPageTab } from './context'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiBookOpenLine,
|
||||
RiDragDropLine,
|
||||
@ -15,7 +16,6 @@ import { noop } from 'es-toolkit/function'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TabSlider from '@/app/components/base/tab-slider'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
|
||||
import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
@ -218,16 +218,21 @@ const PluginPage = ({
|
||||
}
|
||||
{
|
||||
canSetPermissions && (
|
||||
<Tooltip
|
||||
popupContent={t('privilege.title', { ns: 'plugin' })}
|
||||
>
|
||||
<Button
|
||||
data-testid="plugin-settings-button"
|
||||
className="group h-full w-full p-2 text-components-button-secondary-text"
|
||||
onClick={setShowPluginSettingModal}
|
||||
>
|
||||
<RiEqualizer2Line className="h-4 w-4" />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<Button
|
||||
data-testid="plugin-settings-button"
|
||||
className="group h-full w-full p-2 text-components-button-secondary-text"
|
||||
onClick={setShowPluginSettingModal}
|
||||
>
|
||||
<RiEqualizer2Line className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('privilege.title', { ns: 'plugin' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -330,6 +330,27 @@ describe('publisher', () => {
|
||||
})
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep confirm dialog mounted when first publish opens follow-up overlay', async () => {
|
||||
mockPublishedAt.mockReturnValue(null)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow.common.publishUpdate/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
|
||||
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
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 {
|
||||
memo,
|
||||
useCallback,
|
||||
@ -13,13 +15,19 @@ import Popup from './popup'
|
||||
const Publisher = () => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmVisible, { setFalse: hideConfirm, setTrue: showConfirm }] = useBoolean(false)
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
if (!newOpen && confirmVisible)
|
||||
return
|
||||
if (newOpen)
|
||||
handleSyncWorkflowDraft(true)
|
||||
setOpen(newOpen)
|
||||
}, [handleSyncWorkflowDraft])
|
||||
}, [confirmVisible, handleSyncWorkflowDraft])
|
||||
const closePopover = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@ -42,9 +50,14 @@ const Publisher = () => {
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={40}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
popupClassName={cn('border-none bg-transparent shadow-none', confirmVisible && 'hidden')}
|
||||
>
|
||||
<Popup onRequestClose={() => handleOpenChange(false)} />
|
||||
<Popup
|
||||
onRequestClose={closePopover}
|
||||
confirmVisible={confirmVisible}
|
||||
onShowConfirm={showConfirm}
|
||||
onHideConfirm={hideConfirm}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@ -41,9 +41,17 @@ import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
type PopupProps = {
|
||||
onRequestClose?: () => void
|
||||
confirmVisible?: boolean
|
||||
onShowConfirm?: () => void
|
||||
onHideConfirm?: () => void
|
||||
}
|
||||
|
||||
const Popup = ({ onRequestClose }: PopupProps) => {
|
||||
const Popup = ({
|
||||
onRequestClose,
|
||||
confirmVisible: controlledConfirmVisible,
|
||||
onShowConfirm,
|
||||
onHideConfirm,
|
||||
}: PopupProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { datasetId } = useParams()
|
||||
const { push } = useRouter()
|
||||
@ -60,24 +68,32 @@ const Popup = ({ onRequestClose }: PopupProps) => {
|
||||
const isAllowPublishAsCustomKnowledgePipelineTemplate = useProviderContextSelector(s => s.isAllowPublishAsCustomKnowledgePipelineTemplate)
|
||||
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
|
||||
const apiReferenceUrl = useDatasetApiAccessUrl()
|
||||
const [confirmVisible, { setFalse: hideConfirm, setTrue: showConfirm }] = useBoolean(false)
|
||||
const [localConfirmVisible, { setFalse: hideLocalConfirm, setTrue: showLocalConfirm }] = useBoolean(false)
|
||||
const confirmVisible = controlledConfirmVisible ?? localConfirmVisible
|
||||
const showConfirm = onShowConfirm ?? showLocalConfirm
|
||||
const hideConfirm = onHideConfirm ?? hideLocalConfirm
|
||||
const [publishing, { setFalse: hidePublishing, setTrue: showPublishing }] = useBoolean(false)
|
||||
const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline()
|
||||
const [showPublishAsKnowledgePipelineModal, { setFalse: hidePublishAsKnowledgePipelineModal, setTrue: setShowPublishAsKnowledgePipelineModal }] = useBoolean(false)
|
||||
const [isPublishingAsCustomizedPipeline, { setFalse: hidePublishingAsCustomizedPipeline, setTrue: showPublishingAsCustomizedPipeline }] = useBoolean(false)
|
||||
const invalidPublishedPipelineInfo = useInvalid([...publishedPipelineInfoQueryKeyPrefix, pipelineId])
|
||||
const invalidDatasetList = useInvalidDatasetList()
|
||||
const handleHideConfirm = useCallback(() => {
|
||||
hideConfirm()
|
||||
onRequestClose?.()
|
||||
}, [hideConfirm, onRequestClose])
|
||||
const handlePublish = useCallback(async (params?: PublishWorkflowParams) => {
|
||||
if (publishing)
|
||||
return
|
||||
let startedPublishing = false
|
||||
try {
|
||||
const checked = await handleCheckBeforePublish()
|
||||
if (checked) {
|
||||
if (!publishedAt && !confirmVisible) {
|
||||
onRequestClose?.()
|
||||
showConfirm()
|
||||
return
|
||||
}
|
||||
startedPublishing = true
|
||||
showPublishing()
|
||||
const res = await publishWorkflow({
|
||||
url: `/rag/pipelines/${pipelineId}/workflows/publish`,
|
||||
@ -114,12 +130,12 @@ const Popup = ({ onRequestClose }: PopupProps) => {
|
||||
toast.error(t('publishPipeline.error.message', { ns: 'datasetPipeline' }))
|
||||
}
|
||||
finally {
|
||||
if (publishing)
|
||||
if (startedPublishing)
|
||||
hidePublishing()
|
||||
if (confirmVisible)
|
||||
hideConfirm()
|
||||
handleHideConfirm()
|
||||
}
|
||||
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm, onRequestClose])
|
||||
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, handleHideConfirm])
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||
e.preventDefault()
|
||||
if (published)
|
||||
@ -163,10 +179,12 @@ const Popup = ({ onRequestClose }: PopupProps) => {
|
||||
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink])
|
||||
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
|
||||
onRequestClose?.()
|
||||
if (!isAllowPublishAsCustomKnowledgePipelineTemplate)
|
||||
if (!isAllowPublishAsCustomKnowledgePipelineTemplate) {
|
||||
setShowPricingModal()
|
||||
else
|
||||
}
|
||||
else {
|
||||
setShowPublishAsKnowledgePipelineModal()
|
||||
}
|
||||
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
|
||||
return (
|
||||
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}>
|
||||
@ -238,7 +256,7 @@ const Popup = ({ onRequestClose }: PopupProps) => {
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<AlertDialog open={confirmVisible} onOpenChange={open => !open && hideConfirm()}>
|
||||
<AlertDialog open={confirmVisible} onOpenChange={open => !open && handleHideConfirm()}>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle
|
||||
|
||||
@ -13,6 +13,7 @@ import type {
|
||||
OnSelectBlock,
|
||||
ToolWithProvider,
|
||||
} from '../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@ -177,24 +178,27 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
}, [activeTab, t])
|
||||
|
||||
const defaultTriggerElement = (
|
||||
<div
|
||||
className={`
|
||||
z-10 flex h-4
|
||||
w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover
|
||||
${triggerClassName?.(open)}
|
||||
`}
|
||||
<PopoverTrigger
|
||||
aria-label={t('common.addBlock', { ns: 'workflow' })}
|
||||
className={cn(
|
||||
'z-10 flex h-4 w-4 cursor-pointer items-center justify-center rounded-full border-0 bg-components-button-primary-bg p-0 text-text-primary-on-surface hover:bg-components-button-primary-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden',
|
||||
triggerClassName?.(open),
|
||||
)}
|
||||
style={triggerStyle}
|
||||
onClick={handleTrigger}
|
||||
>
|
||||
<Plus02 className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
<Plus02 aria-hidden className="h-2.5 w-2.5" />
|
||||
</PopoverTrigger>
|
||||
)
|
||||
const triggerElement = trigger ? trigger(open) : defaultTriggerElement
|
||||
const triggerElement = trigger?.(open)
|
||||
const shouldRenderTriggerElementAsRoot = React.isValidElement(triggerElement)
|
||||
&& (asChild || triggerElement.type === 'button')
|
||||
const triggerElementProps = React.isValidElement(triggerElement)
|
||||
? (triggerElement.props as {
|
||||
onClick?: MouseEventHandler<HTMLElement>
|
||||
})
|
||||
: null
|
||||
const resolvedTriggerElement = asChild && React.isValidElement(triggerElement)
|
||||
const resolvedTriggerElement = shouldRenderTriggerElementAsRoot
|
||||
? React.cloneElement(
|
||||
triggerElement as React.ReactElement<{
|
||||
onClick?: MouseEventHandler<HTMLElement>
|
||||
@ -215,8 +219,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
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 ?? 0)
|
||||
const nativeButton = asChild
|
||||
&& React.isValidElement(triggerElement)
|
||||
const nativeButton = shouldRenderTriggerElementAsRoot
|
||||
&& (typeof triggerElement.type !== 'string' || triggerElement.type === 'button')
|
||||
|
||||
return (
|
||||
@ -224,7 +227,9 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<PopoverTrigger nativeButton={nativeButton} render={resolvedTriggerElement as React.ReactElement} />
|
||||
{trigger
|
||||
? <PopoverTrigger nativeButton={nativeButton} render={resolvedTriggerElement as React.ReactElement} />
|
||||
: defaultTriggerElement}
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ValueSelector, Var, VisionSetting } from '@/app/components/workflow/types'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import ResolutionPicker from '@/app/components/workflow/nodes/llm/components/resolution-picker'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
@ -61,11 +61,16 @@ const ConfigVision: FC<Props> = ({
|
||||
title={t(`${i18nPrefix}.vision`, { ns: 'workflow' })}
|
||||
tooltip={t('vision.description', { ns: 'appDebug' })!}
|
||||
operations={(
|
||||
<Tooltip
|
||||
popupContent={t('vision.onlySupportVisionModelTip', { ns: 'appDebug' })!}
|
||||
disabled={isVisionModel}
|
||||
>
|
||||
<Switch disabled={readOnly || !isVisionModel} size="md" checked={!isVisionModel ? false : enabled} onCheckedChange={onEnabledChange} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
disabled={isVisionModel}
|
||||
render={(
|
||||
<Switch disabled={readOnly || !isVisionModel} size="md" checked={!isVisionModel ? false : enabled} onCheckedChange={onEnabledChange} />
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('vision.onlySupportVisionModelTip', { ns: 'appDebug' })!}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
@ -12,7 +13,6 @@ import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
@ -119,12 +119,17 @@ const Editor: FC<Props> = ({
|
||||
{readOnly && <div className="absolute inset-0 z-10"></div>}
|
||||
{isFocus && (
|
||||
<div className={cn('absolute z-10', insertVarTipToLeft ? 'top-1.5 left-[-12px]' : 'top-[-9px] right-1')}>
|
||||
<Tooltip
|
||||
popupContent={`${t('common.insertVarTip', { ns: 'workflow' })}`}
|
||||
>
|
||||
<div className="cursor-pointer rounded-[5px] border-[0.5px] border-divider-regular bg-components-badge-white-to-dark p-0.5 shadow-lg">
|
||||
<Variable02 className="h-3.5 w-3.5 text-components-button-secondary-accent-text" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="cursor-pointer rounded-[5px] border-[0.5px] border-divider-regular bg-components-badge-white-to-dark p-0.5 shadow-lg">
|
||||
<Variable02 className="h-3.5 w-3.5 text-components-button-secondary-accent-text" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{`${t('common.insertVarTip', { ns: 'workflow' })}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ComponentProps, PropsWithChildren, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { memo } from 'react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
|
||||
type SettingItemProps = PropsWithChildren<{
|
||||
@ -18,10 +18,18 @@ export const SettingItem = memo(({ label, children, status, tooltip }: SettingIt
|
||||
<div className={cn('max-w-full shrink-0 truncate system-xs-medium-uppercase text-text-tertiary', !!children && 'max-w-[100px]')}>
|
||||
{label}
|
||||
</div>
|
||||
<Tooltip popupContent={tooltip} disabled={!needTooltip}>
|
||||
<div className="truncate text-right system-xs-medium text-text-secondary">
|
||||
{children}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
disabled={!needTooltip}
|
||||
render={(
|
||||
<div className="truncate text-right system-xs-medium text-text-secondary">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{indicator && <Indicator color={indicator} className="absolute -top-0.5 -right-0.5" />}
|
||||
</div>
|
||||
|
||||
@ -3,10 +3,10 @@ import type { FC } from 'react'
|
||||
import type { Field as FieldType } from '../../../../../llm/types'
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Type } from '../../../../../llm/types'
|
||||
import { getFieldType } from '../../../../../llm/utils'
|
||||
import TreeIndentLine from '../tree-indent-line'
|
||||
@ -38,24 +38,32 @@ const Field: FC<Props> = ({
|
||||
return null
|
||||
return (
|
||||
<div>
|
||||
<Tooltip popupContent={t('structOutput.moreFillTip', { ns: 'app' })} disabled={depth !== MAX_DEPTH + 1}>
|
||||
<div
|
||||
className={cn('flex items-center justify-between rounded-md pr-2', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
|
||||
onMouseDown={() => !readonly && onSelect?.([...valueSelector, name])}
|
||||
>
|
||||
<div className="flex grow items-stretch">
|
||||
<TreeIndentLine depth={depth} />
|
||||
{depth === MAX_DEPTH + 1
|
||||
? (
|
||||
<RiMoreFill className="h-3 w-3 text-text-tertiary" />
|
||||
)
|
||||
: (<div className={cn('h-6 w-0 grow truncate system-sm-medium leading-6 text-text-secondary', isHighlight && 'text-text-accent')}>{name}</div>)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
disabled={depth !== MAX_DEPTH + 1}
|
||||
render={(
|
||||
<div
|
||||
className={cn('flex items-center justify-between rounded-md pr-2', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
|
||||
onMouseDown={() => !readonly && onSelect?.([...valueSelector, name])}
|
||||
>
|
||||
<div className="flex grow items-stretch">
|
||||
<TreeIndentLine depth={depth} />
|
||||
{depth === MAX_DEPTH + 1
|
||||
? (
|
||||
<RiMoreFill className="h-3 w-3 text-text-tertiary" />
|
||||
)
|
||||
: (<div className={cn('h-6 w-0 grow truncate system-sm-medium leading-6 text-text-secondary', isHighlight && 'text-text-accent')}>{name}</div>)}
|
||||
|
||||
</div>
|
||||
{depth < MAX_DEPTH + 1 && (
|
||||
<div className="ml-2 shrink-0 system-xs-regular text-text-tertiary">{getFieldType(payload)}</div>
|
||||
</div>
|
||||
{depth < MAX_DEPTH + 1 && (
|
||||
<div className="ml-2 shrink-0 system-xs-regular text-text-tertiary">{getFieldType(payload)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('structOutput.moreFillTip', { ns: 'app' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{depth <= MAX_DEPTH && payload.type === Type.object && payload.properties && (
|
||||
|
||||
@ -230,6 +230,7 @@ const VarReferencePickerTrigger: FC<Props> = ({
|
||||
? variablePicker
|
||||
: (
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={variablePicker}
|
||||
onClick={handleTriggerReadonlyClick}
|
||||
/>
|
||||
@ -344,6 +345,7 @@ const VarReferencePickerTrigger: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={triggerContent}
|
||||
onClick={handleTriggerReadonlyClick}
|
||||
/>
|
||||
|
||||
@ -3,10 +3,10 @@ import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
|
||||
import MethodItem from './method-item'
|
||||
import MethodSelector from './method-selector'
|
||||
@ -71,9 +71,18 @@ const DeliveryMethodForm: React.FC<Props> = ({
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="system-sm-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.title`, { ns: 'workflow' })}</div>
|
||||
<Tooltip
|
||||
popupContent={t(`${i18nPrefix}.deliveryMethod.tooltip`, { ns: 'workflow' })}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 p-px">
|
||||
<span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t(`${i18nPrefix}.deliveryMethod.tooltip`, { ns: 'workflow' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!readonly && (
|
||||
<div className="flex items-center px-1">
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Slider } from '@langgenius/dify-ui/slider'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
@ -11,7 +12,6 @@ import {
|
||||
HighQuality,
|
||||
} from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Field } from '@/app/components/workflow/nodes/_base/components/layout'
|
||||
import {
|
||||
ChunkStructureEnum,
|
||||
@ -97,10 +97,13 @@ const IndexMethod = ({
|
||||
<div className="truncate system-xs-medium text-text-secondary">
|
||||
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Tooltip
|
||||
popupContent="number of keywords"
|
||||
>
|
||||
<RiQuestionLine className="ml-0.5 h-3.5 w-3.5 text-text-quaternary" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<RiQuestionLine className="ml-0.5 h-3.5 w-3.5 text-text-quaternary" />}
|
||||
/>
|
||||
<TooltipContent>
|
||||
number of keywords
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Slider
|
||||
|
||||
@ -2,12 +2,12 @@ import type { FC } from 'react'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { Model } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiCloseLine, RiSparklingFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
|
||||
type ModelInfo = {
|
||||
@ -75,7 +75,18 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
<div className="flex flex-col gap-y-1 px-4 py-2">
|
||||
<div className="flex h-6 items-center system-sm-semibold-uppercase text-text-secondary">
|
||||
<span>{t('nodes.llm.jsonSchema.instruction', { ns: 'workflow' })}</span>
|
||||
<Tooltip popupContent={t('nodes.llm.jsonSchema.promptTooltip', { ns: 'workflow' })} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 p-px">
|
||||
<span aria-hidden className="i-ri-question-line h-full w-full text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('nodes.llm.jsonSchema.promptTooltip', { ns: 'workflow' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Textarea
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type MonthlyDaysSelectorProps = {
|
||||
selectedDays: (number | 'last')[]
|
||||
@ -57,10 +57,15 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp
|
||||
? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span>{t('nodes.triggerSchedule.lastDay', { ns: 'workflow' })}</span>
|
||||
<Tooltip
|
||||
popupContent={t('nodes.triggerSchedule.lastDayTooltip', { ns: 'workflow' })}
|
||||
>
|
||||
<RiQuestionLine className="h-3 w-3 text-text-quaternary" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<RiQuestionLine className="h-3 w-3 text-text-quaternary" />
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('nodes.triggerSchedule.lastDayTooltip', { ns: 'workflow' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user