Compare commits

..

20 Commits

Author SHA1 Message Date
458fab1c48 fix: fix structured_output_enabled miss in second validate (#35747)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
2026-04-30 10:34:33 +00:00
yyh
88196c186e refactor(web): workflow hotkeys and history state (#35736)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-30 09:43:16 +00:00
dcf21a6a84 fix: prevent workflow node titles from overflowing (#35740) 2026-04-30 09:20:37 +00:00
91f92c7083 chore: generate enterprise console API (#35735) 2026-04-30 09:15:08 +00:00
0ca339103f fix: var reference picker can not choose sub vars (#35732)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-30 08:33:29 +00:00
5cf741895f fix(plugin): preserve multi-value HTTP response headers (#35726)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:09:56 +00:00
yyh
11c52e90f6 refactor(web/select): base selects to dify-ui (#35720)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-30 06:15:08 +00:00
f01e099729 fix: ToolEntity data validation failed during workflow synchronization (#35696)
Co-authored-by: DESKTOP-ETT0IAR\MINIO <eldoradoel@163.com>
2026-04-30 05:42:35 +00:00
yyh
195ff4711d refactor(web): migrate subscription create modal to dialog (#35721)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-30 05:33:37 +00:00
yyh
fe2f7a8920 refactor(web): migrate short tooltips to dify-ui (#35715)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-30 03:29:17 +00:00
3b1458c08f refactor: port WorkflowDraftVariableFile (#30923)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-29 22:21:20 +00:00
9f47317032 refactor(auth): update OAuth button and settings modal for improved state management and UI consistency (#35702)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-29 15:54:15 +00:00
e751ec323e fix(publisher): enhance confirm dialog handling and improve popup interactions (#35701)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-04-29 15:08:35 +00:00
f1d72eb5d2 chore: allow configurable Next.js dev origins (#35683) 2026-04-29 09:40:26 +00:00
yyh
44242d03b4 fix(web): disable pnpm dependency checks during Docker build (#35686) 2026-04-29 07:56:36 +00:00
ed7ea68f7d fix: restore app nav create submenu interaction (#35681) 2026-04-29 07:03:28 +00:00
afbc30c9ed chore: update to pnpm 11 (#35673)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-04-29 06:45:11 +00:00
yyh
0e55dcb297 refactor(web): migrate rich tooltip overlays (#35675)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-29 06:44:39 +00:00
yyh
25973c7d77 chore: update eslint suppressions codeowner (#35679) 2026-04-29 06:35:02 +00:00
73ecdd5494 fix: ensure generated password satisfies the password policy (#35672) 2026-04-29 06:28:21 +00:00
205 changed files with 7892 additions and 6751 deletions

3
.github/CODEOWNERS vendored
View File

@ -6,6 +6,9 @@
* @crazywoola @laipz8200 @Yeuoly
# ESLint suppression file is maintained by autofix.ci pruning.
/eslint-suppressions.json
# CODEOWNERS file
/.github/CODEOWNERS @laipz8200 @crazywoola

View File

@ -4,7 +4,7 @@ runs:
using: composite
steps:
- name: Setup Vite+
uses: voidzero-dev/setup-vp@20553a7a7429c429a74894104a2835d7fed28a72 # v1.3.0
uses: voidzero-dev/setup-vp@4f5aa3e38c781f1b01e78fb9255527cee8a6efa6 # v1.8.0
with:
node-version-file: .nvmrc
cache: true

1
.github/labeler.yml vendored
View File

@ -6,5 +6,4 @@ web:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.npmrc'
- '.nvmrc'

View File

@ -43,7 +43,6 @@ jobs:
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
.nvmrc
- name: Check api inputs
if: github.event_name != 'merge_group'

View File

@ -74,7 +74,7 @@ jobs:
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Set up Depot CLI
uses: depot/setup-action@v1
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
- name: Extract metadata for Docker
id: meta
@ -84,7 +84,7 @@ jobs:
- name: Build Docker image
id: build
uses: depot/build-push-action@v1
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: ${{ matrix.build_context }}
@ -124,10 +124,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@98e3b2c9eab4f4f98a95c0c0a3ea5e5e672fd2a8 # v3.10.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Validate Docker image
uses: docker/build-push-action@5cd29d66b4a8d8e6f4d5dfe2e9329f0b1d446289 # v6.18.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.build_context }}

View File

@ -44,10 +44,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Depot CLI
uses: depot/setup-action@v1
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
- name: Build Docker Image
uses: depot/build-push-action@v1
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
push: false
@ -71,10 +71,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@98e3b2c9eab4f4f98a95c0c0a3ea5e5e672fd2a8 # v3.10.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build Docker Image
uses: docker/build-push-action@5cd29d66b4a8d8e6f4d5dfe2e9329f0b1d446289 # v6.18.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.context }}

View File

@ -69,7 +69,6 @@ jobs:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.npmrc'
- '.nvmrc'
- '.github/workflows/web-tests.yml'
- '.github/actions/setup-web/**'
@ -83,7 +82,6 @@ jobs:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.npmrc'
- '.nvmrc'
- 'docker/docker-compose.middleware.yaml'
- 'docker/middleware.env.example'

View File

@ -83,7 +83,6 @@ jobs:
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
.nvmrc
.github/workflows/style.yml
.github/actions/setup-web/**
@ -110,8 +109,6 @@ jobs:
- name: Web tsslint
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
env:
NODE_OPTIONS: --max-old-space-size=4096
run: vp run lint:tss
- name: Web type check

View File

@ -9,7 +9,6 @@ on:
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .npmrc
concurrency:
group: sdk-tests-${{ github.head_ref || github.run_id }}

View File

@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1.0.107
uses: anthropics/claude-code-action@ef50f123a3a9be95b60040d042717517407c7256 # v1.0.110
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

1
.npmrc
View File

@ -1 +0,0 @@
save-exact=true

View File

@ -113,8 +113,18 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
# Validates name encoding for non-Latin characters.
name = name.strip().encode("utf-8").decode("utf-8") if name else None
# generate random password
new_password = secrets.token_urlsafe(16)
# Generate a random password that satisfies the password policy.
# The iteration limit guards against infinite loops caused by unexpected bugs in valid_password.
for _ in range(100):
new_password = secrets.token_urlsafe(16)
try:
valid_password(new_password)
break
except Exception:
continue
else:
click.echo(click.style("Failed to generate a valid password. Please try again.", fg="red"))
return
# register account
account = RegisterService.register(

View File

@ -151,6 +151,12 @@ def deserialize_response(raw_data: bytes) -> Response:
response = Response(response=body, status=status_code)
# Replace Flask's default headers (e.g. Content-Type, Content-Length) with the
# parsed ones so we faithfully reproduce the original response. Use Headers.add
# rather than dict-style assignment so that repeated headers such as Set-Cookie
# (and any other multi-valued header per RFC 9110) are preserved instead of
# being overwritten.
response.headers.clear()
for line in lines[1:]:
if not line:
continue
@ -158,6 +164,6 @@ def deserialize_response(raw_data: bytes) -> Response:
if ":" not in line_str:
continue
name, value = line_str.split(":", 1)
response.headers[name] = value.strip()
response.headers.add(name, value.strip())
return response

View File

@ -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,

View File

@ -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

View File

@ -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:

View File

@ -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": "",

View File

@ -323,6 +323,50 @@ class TestDeserializeResponse:
with pytest.raises(ValueError, match="Invalid status line"):
deserialize_response(raw_data)
def test_deserialize_response_preserves_duplicate_set_cookie_headers(self):
# Regression test for https://github.com/langgenius/dify/issues/35722
# Multiple Set-Cookie headers must be preserved per RFC 9110, not collapsed
# into a single value by dict-style assignment.
raw_data = (
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: text/plain\r\n"
b"Set-Cookie: session=abc; Path=/; HttpOnly\r\n"
b"Set-Cookie: tracking=xyz; Path=/; Secure\r\n"
b"\r\n"
b"ok"
)
response = deserialize_response(raw_data)
cookies = response.headers.getlist("Set-Cookie")
assert cookies == [
"session=abc; Path=/; HttpOnly",
"tracking=xyz; Path=/; Secure",
]
# Single-valued headers should still be readable normally.
assert response.headers.get("Content-Type") == "text/plain"
def test_deserialize_response_preserves_duplicate_generic_headers(self):
# Any header name (not just Set-Cookie) may legitimately repeat; verify the
# parser preserves all values rather than overwriting earlier ones.
raw_data = b"HTTP/1.1 200 OK\r\nX-Custom: first\r\nX-Custom: second\r\n\r\n"
response = deserialize_response(raw_data)
assert response.headers.getlist("X-Custom") == ["first", "second"]
def test_deserialize_response_does_not_inject_default_content_type(self):
# Flask's Response constructor adds a default Content-Type header. When the
# raw response has no Content-Type, the parsed response should not silently
# gain one from the framework default.
raw_data = b"HTTP/1.1 204 No Content\r\nX-Trace-Id: abc\r\n\r\n"
response = deserialize_response(raw_data)
header_names = [name for name, _ in response.headers.items()]
assert "Content-Type" not in header_names
assert response.headers.get("X-Trace-Id") == "abc"
def test_roundtrip_response(self):
# Test that serialize -> deserialize produces equivalent response
original_response = Response(

View File

@ -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

View 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)

View File

@ -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
@ -385,9 +375,6 @@
}
},
"web/app/components/app/configuration/config/agent/agent-tools/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 9
}
@ -529,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
@ -562,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
@ -623,9 +600,6 @@
}
},
"web/app/components/app/log/list.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 6
},
@ -641,11 +615,6 @@
"count": 2
}
},
"web/app/components/app/overview/app-card.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/overview/customize/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -653,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
@ -799,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
@ -879,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
}
@ -999,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
@ -1045,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
@ -1101,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
@ -1131,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
}
@ -1176,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
}
@ -1201,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
@ -1223,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
@ -1601,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
}
@ -1772,9 +1694,6 @@
}
},
"web/app/components/base/new-audio-button/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -1952,11 +1871,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
@ -1985,25 +1899,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
@ -2265,11 +2160,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
@ -2377,11 +2267,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
@ -2556,11 +2441,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
@ -2602,14 +2482,6 @@
"count": 1
}
},
"web/app/components/datasets/external-api/external-api-modal/index.tsx": {
"no-restricted-imports": {
"count": 2
},
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -2620,11 +2492,6 @@
"count": 1
}
},
"web/app/components/datasets/extra-info/statistics.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/formatted-text/flavours/type.ts": {
"ts/no-empty-object-type": {
"count": 1
@ -2635,11 +2502,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
@ -2655,21 +2517,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
@ -2738,11 +2590,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
@ -2806,11 +2653,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
@ -3059,15 +2901,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
}
@ -3083,9 +2917,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
}
@ -3101,11 +2932,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
@ -3198,20 +3024,12 @@
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorize/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"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
}
@ -3281,9 +3099,6 @@
}
},
"web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -3309,9 +3124,6 @@
}
},
"web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -3339,9 +3151,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": {
@ -3377,11 +3186,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
@ -3390,11 +3194,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
@ -3417,7 +3216,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": {
@ -3458,11 +3257,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
@ -3741,11 +3535,6 @@
"count": 1
}
},
"web/app/components/tools/mcp/detail/tool-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/mcp/mcp-server-modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -3759,11 +3548,6 @@
"count": 1
}
},
"web/app/components/tools/mcp/mcp-service-card.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/mcp/modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -3980,11 +3764,6 @@
"count": 1
}
},
"web/app/components/workflow/header/version-history-button.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/hooks-store/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
@ -4007,7 +3786,7 @@
},
"web/app/components/workflow/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 27
"count": 26
}
},
"web/app/components/workflow/hooks/use-checklist.ts": {
@ -4114,11 +3893,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
@ -4167,9 +3941,6 @@
}
},
"web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -4218,9 +3989,6 @@
}
},
"web/app/components/workflow/nodes/_base/components/prompt/editor.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
@ -4236,9 +4004,6 @@
}
},
"web/app/components/workflow/nodes/_base/components/setting-item.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -4253,11 +4018,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
@ -4571,11 +4331,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
@ -4691,11 +4446,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
@ -4833,11 +4583,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
@ -5171,11 +4916,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
@ -5244,11 +4984,6 @@
"count": 1
}
},
"web/app/components/workflow/operator/tip-popup.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/operator/zoom-in-out.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -5313,11 +5048,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
@ -5542,9 +5272,6 @@
}
},
"web/app/components/workflow/variable-inspect/group.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -5568,17 +5295,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
}
@ -5602,11 +5323,6 @@
"count": 5
}
},
"web/app/components/workflow/workflow-history-store.tsx": {
"react-refresh/only-export-components": {
"count": 2
}
},
"web/app/components/workflow/workflow-preview/components/nodes/base.tsx": {
"no-restricted-imports": {
"count": 1
@ -5672,11 +5388,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
@ -5688,9 +5399,6 @@
}
},
"web/app/signin/one-more-step.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}

View File

@ -2,11 +2,12 @@
"name": "dify",
"type": "module",
"private": true,
"packageManager": "pnpm@10.33.2",
"packageManager": "pnpm@11.0.0",
"engines": {
"node": "^22.22.1"
},
"scripts": {
"dev": "concurrently -k -n vinext,proxy \"vp run dify-web#dev:vinext\" \"vp run dify-web#dev:proxy\"",
"prepare": "vp config",
"type-check": "vp run -r type-check",
"lint": "eslint --cache --concurrency=auto",
@ -16,6 +17,7 @@
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"concurrently": "catalog:",
"eslint": "catalog:",
"eslint-markdown": "catalog:",
"eslint-plugin-markdown-preferences": "catalog:",

View File

@ -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', () => {

View File

@ -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',

View File

@ -125,19 +125,9 @@ describe('@langgenius/dify-ui/toast', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should respect custom timeout values including zero', async () => {
it('should keep zero-timeout toasts persistent', async () => {
const screen = await render(<ToastHost />)
toast('Custom timeout', {
timeout: 1000,
})
await expect.element(screen.getByText('Custom timeout')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(1000)
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Custom timeout')
})
toast('Persistent', {
timeout: 0,
})

2335
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,11 @@
saveExact: true
catalogMode: prefer
dedupeDirectDeps: true
engineStrict: true
minimumReleaseAge: 1440
optimisticRepeatInstall: true
verifyDepsBeforeRun: install
resolutionMode: time-based
allowBuilds:
'@parcel/watcher': false
canvas: false
@ -5,7 +13,6 @@ allowBuilds:
sharp: false
autoInstallPeers: false
blockExoticSubdeps: true
catalogMode: prefer
shellEmulator: true
strictDepBuilds: true
trustPolicy: no-downgrade
@ -42,13 +49,13 @@ overrides:
svgo@>=3.0.0 <3.3.3: 3.3.3
tar@<=7.5.10: 7.5.11
undici@>=7.0.0 <7.24.0: 7.24.0
vite: npm:@voidzero-dev/vite-plus-core@0.1.19
vitest: npm:@voidzero-dev/vite-plus-test@0.1.19
vite: npm:@voidzero-dev/vite-plus-core@0.1.20
vitest: npm:@voidzero-dev/vite-plus-test@0.1.20
yaml@>=2.0.0 <2.8.3: 2.8.3
yauzl@<3.2.1: 3.2.1
catalog:
'@amplitude/analytics-browser': 2.41.1
'@amplitude/plugin-session-replay-browser': 1.28.0
'@amplitude/analytics-browser': 2.42.0
'@amplitude/plugin-session-replay-browser': 1.28.1
'@antfu/eslint-config': 8.2.0
'@base-ui/react': 1.4.1
'@chromatic-com/storybook': 5.1.2
@ -61,16 +68,17 @@ catalog:
'@formatjs/intl-localematcher': 0.8.4
'@headlessui/react': 2.2.10
'@heroicons/react': 2.2.0
'@hono/node-server': 1.19.14
'@hey-api/openapi-ts': 0.97.0
'@hono/node-server': 2.0.0
'@iconify-json/heroicons': 1.2.3
'@iconify-json/ri': 1.2.10
'@lexical/code': 0.43.0
'@lexical/link': 0.43.0
'@lexical/list': 0.43.0
'@lexical/react': 0.43.0
'@lexical/selection': 0.43.0
'@lexical/text': 0.43.0
'@lexical/utils': 0.43.0
'@lexical/code': 0.44.0
'@lexical/link': 0.44.0
'@lexical/list': 0.44.0
'@lexical/react': 0.44.0
'@lexical/selection': 0.44.0
'@lexical/text': 0.44.0
'@lexical/utils': 0.44.0
'@mdx-js/loader': 3.1.1
'@mdx-js/react': 3.1.1
'@mdx-js/rollup': 3.1.1
@ -98,20 +106,21 @@ catalog:
'@tailwindcss/postcss': 4.2.4
'@tailwindcss/typography': 0.5.19
'@tailwindcss/vite': 4.2.4
'@tanstack/eslint-plugin-query': 5.100.5
'@tanstack/eslint-plugin-query': 5.100.6
'@tanstack/react-devtools': 0.10.2
'@tanstack/react-form': 1.29.1
'@tanstack/react-form-devtools': 0.2.22
'@tanstack/react-query': 5.100.5
'@tanstack/react-query-devtools': 5.100.5
'@tanstack/react-hotkeys': 0.10.0
'@tanstack/react-query': 5.100.6
'@tanstack/react-query-devtools': 5.100.6
'@tanstack/react-virtual': 3.13.24
'@testing-library/dom': 10.4.1
'@testing-library/jest-dom': 6.9.1
'@testing-library/react': 16.3.2
'@testing-library/user-event': 14.6.1
'@tsslint/cli': 3.0.4
'@tsslint/compat-eslint': 3.0.4
'@tsslint/config': 3.0.4
'@tsslint/cli': 3.1.0
'@tsslint/compat-eslint': 3.1.0
'@tsslint/config': 3.1.0
'@types/js-cookie': 3.0.6
'@types/js-yaml': 4.0.9
'@types/negotiator': 0.6.4
@ -120,9 +129,9 @@ catalog:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3
'@types/sortablejs': 1.15.9
'@typescript-eslint/eslint-plugin': 8.59.0
'@typescript-eslint/parser': 8.59.0
'@typescript/native-preview': 7.0.0-dev.20260426.1
'@typescript-eslint/eslint-plugin': 8.59.1
'@typescript-eslint/parser': 8.59.1
'@typescript/native-preview': 7.0.0-dev.20260428.1
'@vitejs/plugin-react': 6.0.1
'@vitejs/plugin-rsc': 0.5.25
'@vitest/coverage-v8': 4.1.5
@ -134,7 +143,8 @@ catalog:
clsx: 2.1.1
cmdk: 1.1.1
code-inspector-plugin: 1.5.1
copy-to-clipboard: 3.3.3
concurrently: ^9.2.1
copy-to-clipboard: 4.0.2
cron-parser: 5.5.0
dayjs: 1.11.20
decimal.js: 10.6.0
@ -147,8 +157,8 @@ catalog:
emoji-mart: 5.6.0
es-toolkit: 1.46.0
eslint: 10.2.1
eslint-markdown: 0.6.1
eslint-plugin-better-tailwindcss: 4.4.1
eslint-markdown: 0.7.0
eslint-plugin-better-tailwindcss: 4.5.0
eslint-plugin-hyoban: 0.14.1
eslint-plugin-markdown-preferences: 0.41.1
eslint-plugin-no-barrel-files: 1.3.1
@ -161,7 +171,7 @@ catalog:
hono: 4.12.15
html-entities: 2.6.0
html-to-image: 1.11.13
i18next: 26.0.6
i18next: 26.0.8
i18next-resources-to-backend: 1.2.1
iconify-import-svg: 0.2.0
immer: 11.1.4
@ -174,7 +184,7 @@ catalog:
knip: 6.7.0
ky: 2.0.2
lamejs: 1.2.1
lexical: 0.43.0
lexical: 0.44.0
loro-crdt: 1.12.0
mermaid: 11.14.0
mime: 4.1.0
@ -214,18 +224,18 @@ catalog:
string-ts: 2.3.1
tailwind-merge: 3.5.0
tailwindcss: 4.2.4
tldts: 7.0.28
tldts: 7.0.29
tsx: 4.21.0
typescript: 6.0.3
uglify-js: 3.19.3
unist-util-visit: 5.1.0
use-context-selector: 2.0.0
uuid: 13.0.0
vinext: 0.0.41
vite: npm:@voidzero-dev/vite-plus-core@0.1.19
uuid: 14.0.0
vinext: 0.0.45
vite: npm:@voidzero-dev/vite-plus-core@0.1.20
vite-plugin-inspect: 12.0.0-beta.1
vite-plus: 0.1.19
vitest: npm:@voidzero-dev/vite-plus-test@0.1.19
vite-plus: 0.1.20
vitest: npm:@voidzero-dev/vite-plus-test@0.1.20
vitest-browser-react: 2.2.0
vitest-canvas-mock: 1.1.4
zod: 4.3.6

View File

@ -4,4 +4,8 @@ export default defineConfig({
staged: {
'*': 'eslint --fix --pass-on-unpruned-suppressions',
},
fmt: {
singleQuote: true,
semi: false,
},
})

View File

@ -1 +0,0 @@
save-exact=true

View File

@ -41,6 +41,7 @@ COPY . .
WORKDIR /app/web
ENV NODE_OPTIONS="--max-old-space-size=4096"
ENV pnpm_config_verify_deps_before_run=false
RUN pnpm build && pnpm build:vinext

View File

@ -195,9 +195,19 @@ describe('Header Nav Flow', () => {
renderNav()
fireEvent.click(screen.getByRole('button', { name: /Alpha/i }))
fireEvent.click(await screen.findByText('menus.newApp'))
const openCreateMenu = async () => {
fireEvent.click(await screen.findByText('menus.newApp'))
return screen.findByText('newApp.startFromBlank')
}
await openCreateMenu()
fireEvent.click(await screen.findByText('newApp.startFromBlank'))
await openCreateMenu()
fireEvent.click(await screen.findByText('newApp.startFromTemplate'))
await openCreateMenu()
fireEvent.click(await screen.findByText('importDSL'))
expect(mockOnCreate).toHaveBeenNthCalledWith(1, 'blank')

View File

@ -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}

View File

@ -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()
})

View File

@ -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'}

View File

@ -6,7 +6,9 @@ import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { AgentTool } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiDeleteBinLine,
RiEqualizer2Line,
@ -23,7 +25,6 @@ import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
import AppIcon from '@/app/components/base/app-icon'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import { CollectionType } from '@/app/components/tools/types'
import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
@ -154,13 +155,23 @@ const AgentTools: FC = () => {
title={(
<div className="flex items-center">
<div className="mr-1">{t('agent.tools.name', { ns: 'appDebug' })}</div>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('agent.tools.description', { ns: 'appDebug' })}
</div>
)}
/>
<Popover>
<PopoverTrigger
openOnHover
aria-label={t('agent.tools.description', { ns: 'appDebug' })}
render={(
<button
type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm p-px outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
>
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</button>
)}
/>
<PopoverContent popupClassName="w-[180px] px-3 py-2 system-xs-regular text-text-tertiary">
{t('agent.tools.description', { ns: 'appDebug' })}
</PopoverContent>
</Popover>
</div>
)}
headerRight={(
@ -216,34 +227,59 @@ const AgentTools: FC = () => {
<span className="pr-1.5 system-xs-medium text-text-secondary">{getProviderShowName(item)}</span>
<span className="text-text-tertiary">{item.tool_label}</span>
{!item.isDeleted && !readonly && (
<Tooltip
popupContent={(
<Popover>
<span className="h-4 w-4">
<PopoverTrigger
openOnHover
aria-label={item.tool_name}
render={(
<button
type="button"
className="ml-0.5 hidden h-4 w-4 items-center justify-center rounded-sm outline-hidden group-hover:inline-flex hover:bg-state-base-hover focus-visible:inline-flex focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
data-testid="tool-info-tooltip"
>
<RiInformation2Line className="h-4 w-4 text-text-tertiary" />
</button>
)}
/>
</span>
<PopoverContent popupClassName="w-[180px] px-3 py-2 system-xs-regular">
<div className="w-[180px]">
<div className="mb-1.5 text-text-secondary">{item.tool_name}</div>
<div className="mb-1.5 text-text-tertiary">{t('toolNameUsageTip', { ns: 'tools' })}</div>
<div className="cursor-pointer text-text-accent" onClick={() => copy(item.tool_name)}>{t('copyToolName', { ns: 'tools' })}</div>
<button
type="button"
className="cursor-pointer rounded-sm text-text-accent outline-hidden hover:underline focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
onClick={() => copy(item.tool_name)}
>
{t('copyToolName', { ns: 'tools' })}
</button>
</div>
)}
>
<div className="h-4 w-4">
<div className="ml-0.5 hidden group-hover:inline-block" data-testid="tool-info-tooltip">
<RiInformation2Line className="h-4 w-4 text-text-tertiary" />
</div>
</div>
</Tooltip>
</PopoverContent>
</Popover>
)}
</div>
</div>
<div className="ml-1 flex shrink-0 items-center">
{item.isDeleted && (
<div className="mr-2 flex items-center">
<Tooltip
popupContent={t('toolRemoved', { ns: 'tools' })}
>
<div className="mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5">
<AlertTriangle className="h-4 w-4 text-[#F79009]" />
</div>
</Tooltip>
<Popover>
<PopoverTrigger
openOnHover
aria-label={t('toolRemoved', { ns: 'tools' })}
render={(
<button
type="button"
className="mr-1 cursor-pointer rounded-md p-1 outline-hidden hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
>
<AlertTriangle className="h-4 w-4 text-[#F79009]" />
</button>
)}
/>
<PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary">
{t('toolRemoved', { ns: 'tools' })}
</PopoverContent>
</Popover>
<div
className="cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive"
onClick={() => {
@ -263,19 +299,25 @@ const AgentTools: FC = () => {
{!item.isDeleted && !readonly && (
<div className="mr-2 hidden items-center gap-1 group-hover:flex">
{!item.notAuthor && (
<Tooltip
popupContent={t('setBuiltInTools.infoAndSetting', { ns: 'tools' })}
needsDelay={false}
>
<div
className="cursor-pointer rounded-md p-1 hover:bg-black/5"
onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}
>
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</div>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
className="cursor-pointer rounded-md p-1 outline-hidden hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
aria-label={t('setBuiltInTools.infoAndSetting', { ns: 'tools' })}
onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}
>
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</button>
)}
/>
<TooltipContent>
{t('setBuiltInTools.infoAndSetting', { ns: 'tools' })}
</TooltipContent>
</Tooltip>
)}
<div

View File

@ -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', () => {

View File

@ -1,5 +1,6 @@
import type { CSSProperties, FC } from 'react'
import type { ModelAndParameter } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -92,11 +93,16 @@ const DebugItem: FC<DebugItemProps> = ({
modelAndParameter={modelAndParameter}
/>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger render={<div />}>
<ActionButton className={open ? 'bg-state-base-hover' : ''}>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={(
<ActionButton
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
aria-label={t('operation.more', { ns: 'common' })}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</ActionButton>
)}
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}

View File

@ -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>
)
}

View File

@ -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 && (

View File

@ -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>
)
}

View File

@ -270,6 +270,7 @@ describe('AppCard', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.enableTooltip.description' }))
fireEvent.click(screen.getByText('overview.appInfo.enableTooltip.learnMore'))
expect(mockWindowOpen).toHaveBeenCalledWith('https://docs.example.com/use-dify/nodes/user-input', '_blank')

View File

@ -2,6 +2,7 @@
import type { ConfigParams } from './settings'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Switch } from '@langgenius/dify-ui/switch'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
@ -9,7 +10,6 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppBasic from '@/app/components/app-sidebar/basic'
import { useStore as useAppStore } from '@/app/components/app/store'
import Tooltip from '@/app/components/base/tooltip'
import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button'
import Indicator from '@/app/components/header/indicator'
import { useAppContext } from '@/context/app-context'
@ -179,6 +179,31 @@ function AppCard({
triggerModeDisabled,
])
const missingStartNodeContent = cardState.appUnpublished || cardState.missingStartNode
? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<button
type="button"
className="cursor-pointer rounded-sm text-xs font-normal text-text-accent outline-hidden hover:underline focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</button>
</>
)
: ''
const statusPopoverContent = cardState.toggleDisabled
? (
triggerModeDisabled && triggerModeMessage
? triggerModeMessage
: missingStartNodeContent
)
: ''
return (
<div
className={`${isInPanel ? 'border-t border-l-[0.5px]' : 'border-[0.5px] shadow-xs'} w-full max-w-full rounded-xl border-effects-highlight ${className ?? ''} ${cardState.isMinimalState ? 'h-12' : ''}`}
@ -187,13 +212,19 @@ function AppCard({
{triggerModeDisabled && (
triggerModeMessage
? (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true" />
</Tooltip>
<Popover>
<PopoverTrigger
openOnHover
aria-label={typeof triggerModeMessage === 'string' ? triggerModeMessage : basicName}
render={<button type="button" className="absolute inset-0 z-10 cursor-not-allowed rounded-xl outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-hover" />}
/>
<PopoverContent
placement="right"
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
>
{triggerModeMessage}
</PopoverContent>
</Popover>
)
: <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true" />
)}
@ -219,38 +250,31 @@ function AppCard({
: t('overview.status.disable', { ns: 'appOverview' })}
</div>
</div>
<Tooltip
popupContent={
cardState.toggleDisabled
? (
triggerModeDisabled && triggerModeMessage
? triggerModeMessage
: (cardState.appUnpublished || cardState.missingStartNode)
? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</>
)
: ''
)
: ''
}
position="right"
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
offset={24}
>
<div>
<Switch checked={cardState.runningStatus} onCheckedChange={onChangeStatus} disabled={cardState.toggleDisabled} />
</div>
</Tooltip>
{cardState.toggleDisabled && statusPopoverContent
? (
<Popover>
<PopoverTrigger
openOnHover
nativeButton={false}
aria-label={typeof statusPopoverContent === 'string' ? statusPopoverContent : t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
render={(
<div>
<Switch checked={cardState.runningStatus} onCheckedChange={onChangeStatus} disabled={cardState.toggleDisabled} />
</div>
)}
/>
<PopoverContent
placement="right"
sideOffset={24}
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
>
{statusPopoverContent}
</PopoverContent>
</Popover>
)
: (
<Switch checked={cardState.runningStatus} onCheckedChange={onChangeStatus} disabled={cardState.toggleDisabled} />
)}
</div>
{!cardState.isMinimalState && (
<AppCardUrlSection

View File

@ -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">

View File

@ -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>
)

View File

@ -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 && (

View File

@ -92,12 +92,12 @@ const WorkflowProcessItem = ({
)
}
<div
className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}
className="min-w-0 grow truncate system-xs-medium text-text-secondary"
data-testid="workflow-process-title"
>
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
</div>
<div className={cn('ml-1 i-ri-arrow-right-s-line h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
<div className={cn('ml-1 i-ri-arrow-right-s-line h-4 w-4 shrink-0 text-text-tertiary', !collapse && 'rotate-90')} />
</div>
{
!collapse && (

View File

@ -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 && (

View File

@ -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>
)
}

View File

@ -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>
)}
</>

View File

@ -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>

View File

@ -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>

View File

@ -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>
)}
</>

View File

@ -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,

View File

@ -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 () => {

View File

@ -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 && (

View File

@ -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')
})
})

View File

@ -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')
})
})

View File

@ -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

View File

@ -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')
})

View File

@ -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')
})
})

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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'

View File

@ -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,

View File

@ -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>

View File

@ -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,

View File

@ -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)}

View File

@ -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>
)
}

View File

@ -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

View File

@ -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>
)
}

View File

@ -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)
})
})
})

View File

@ -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()
})
})
})

View File

@ -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)
})
})
})

View File

@ -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')
})
})
})

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -363,9 +363,7 @@ describe('Breadcrumbs', () => {
render(<Breadcrumbs {...props} />)
// Assert - Dropdown trigger (more button) should be present
// Assert - Dropdown trigger (more button) should be present
expect(screen.getByRole('button', { name: '' }))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.more' }))!.toBeInTheDocument()
})
it('should not show dropdown when breadcrumbs do not exceed displayBreadcrumbNum', () => {

View File

@ -6,6 +6,7 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Menu from './menu'
type DropdownProps = {
@ -19,6 +20,7 @@ const Dropdown = ({
breadcrumbs,
onBreadcrumbClick,
}: DropdownProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleBreadCrumbClick = useCallback((index: number) => {
@ -31,17 +33,21 @@ const Dropdown = ({
open={open}
onOpenChange={setOpen}
>
<DropdownMenuTrigger render={<div />}>
<button
type="button"
className={cn(
'flex size-6 items-center justify-center rounded-md',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
</button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={(
<button
type="button"
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex size-6 items-center justify-center rounded-md',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
</button>
)}
/>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}

View File

@ -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>
)
}

View File

@ -10,13 +10,13 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { RiBook2Line, RiCloseLine, RiInformation2Line, RiLock2Fill } from '@remixicon/react'
import { memo, useEffect, useState } from 'react'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { PortalToFollowElem, PortalToFollowElemContent } from '@/app/components/base/portal-to-follow-elem'
import Tooltip from '@/app/components/base/tooltip'
import { createExternalAPI } from '@/service/datasets'
import Form from './Form'
@ -57,15 +57,20 @@ const formSchemas: FormSchema[] = [
required: true,
},
]
const emptyExternalAPIFormData: CreateExternalAPIReq = {
name: '',
settings: {
endpoint: '',
api_key: '',
},
}
const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCancel, datasetBindings, isEditMode, onEdit }) => {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [formData, setFormData] = useState<CreateExternalAPIReq>({ name: '', settings: { endpoint: '', api_key: '' } })
useEffect(() => {
if (isEditMode && data)
setFormData(data)
}, [isEditMode, data])
const [formData, setFormData] = useState<CreateExternalAPIReq>(() => isEditMode && data ? data : emptyExternalAPIFormData)
const hasEmptyInputs = Object.values(formData).some(value => typeof value === 'string' ? value.trim() === '' : Object.values(value).some(v => v.trim() === ''))
const handleDataChange = (val: CreateExternalAPIReq) => {
setFormData(val)
@ -108,106 +113,121 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
}
}
return (
<PortalToFollowElem open>
<PortalToFollowElemContent className="z-60 h-full w-full">
<div className="fixed inset-0 flex items-center justify-center bg-black/25">
<div className="shadows-shadow-xl relative flex w-[480px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg">
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">
<div className="grow self-stretch title-2xl-semi-bold text-text-primary">
{isEditMode ? t('editExternalAPIFormTitle', { ns: 'dataset' }) : t('createExternalAPI', { ns: 'dataset' })}
</div>
{isEditMode && (datasetBindings?.length ?? 0) > 0 && (
<div className="flex items-center system-xs-regular text-text-tertiary">
{t('editExternalAPIFormWarning.front', { ns: 'dataset' })}
<span className="flex cursor-pointer items-center text-text-accent">
&nbsp;
{datasetBindings?.length}
{' '}
{t('editExternalAPIFormWarning.end', { ns: 'dataset' })}
<Dialog
open
disablePointerDismissal
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent className="w-[480px]! max-w-none! overflow-visible! rounded-2xl! border-[0.5px]! border-components-panel-border! bg-components-panel-bg! p-0! shadow-xl!">
<div className="relative flex w-full flex-col items-start">
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="grow self-stretch title-2xl-semi-bold text-text-primary">
{isEditMode ? t('editExternalAPIFormTitle', { ns: 'dataset' }) : t('createExternalAPI', { ns: 'dataset' })}
</DialogTitle>
{isEditMode && (datasetBindings?.length ?? 0) > 0 && (
<div className="flex items-center system-xs-regular text-text-tertiary">
{t('editExternalAPIFormWarning.front', { ns: 'dataset' })}
<span className="flex cursor-pointer items-center text-text-accent">
&nbsp;
{datasetBindings?.length}
{' '}
{t('editExternalAPIFormWarning.end', { ns: 'dataset' })}
&nbsp;
<Tooltip
popupClassName="flex items-center self-stretch w-[320px]"
popupContent={(
<div className="p-1">
<div className="flex items-start self-stretch pt-1 pr-3 pb-0.5 pl-2">
<div className="system-xs-medium-uppercase text-text-tertiary">{`${datasetBindings?.length} ${t('editExternalAPITooltipTitle', { ns: 'dataset' })}`}</div>
</div>
{datasetBindings?.map(binding => (
<div key={binding.id} className="flex items-center gap-1 self-stretch px-2 py-1">
<RiBook2Line className="h-4 w-4 text-text-secondary" />
<div className="system-sm-medium text-text-secondary">{binding.name}</div>
</div>
))}
</div>
<Popover>
<PopoverTrigger
openOnHover
aria-label={t('editExternalAPIFormWarning.end', { ns: 'dataset' })}
render={(
<button
type="button"
className="flex h-3.5 w-3.5 items-center justify-center rounded-sm outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
>
<RiInformation2Line className="h-3.5 w-3.5" />
</button>
)}
asChild={false}
position="bottom"
/>
<PopoverContent
placement="bottom"
popupClassName="flex w-[320px] items-center self-stretch px-3 py-2"
>
<RiInformation2Line className="h-3.5 w-3.5" />
</Tooltip>
</span>
</div>
)}
</div>
<ActionButton className="absolute top-5 right-5" onClick={onCancel}>
<RiCloseLine className="h-[18px] w-[18px] shrink-0 text-text-tertiary" />
</ActionButton>
<Form value={formData} onChange={handleDataChange} formSchemas={formSchemas} className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3" />
<div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5">
<Button type="button" variant="secondary" onClick={onCancel}>
{t('externalAPIForm.cancel', { ns: 'dataset' })}
</Button>
<Button
type="submit"
variant="primary"
onClick={() => {
if (isEditMode && (datasetBindings?.length ?? 0) > 0)
setShowConfirm(true)
else if (isEditMode && onEdit)
onEdit(formData)
else
handleSave()
}}
disabled={hasEmptyInputs || loading}
>
{t('externalAPIForm.save', { ns: 'dataset' })}
</Button>
</div>
<div className="flex items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px] border-divider-subtle
bg-background-soft px-2 py-3 system-xs-regular text-text-tertiary"
>
<RiLock2Fill className="h-3 w-3 text-text-quaternary" />
{t('externalAPIForm.encrypted.front', { ns: 'dataset' })}
<a className="text-text-accent" target="_blank" rel="noopener noreferrer" href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html">
PKCS1_OAEP
</a>
{t('externalAPIForm.encrypted.end', { ns: 'dataset' })}
</div>
</div>
<AlertDialog
open={showConfirm && (datasetBindings?.length ?? 0) > 0}
onOpenChange={open => !open && setShowConfirm(false)}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
Warning
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{`${t('editExternalAPIConfirmWarningContent.front', { ns: 'dataset' })} ${datasetBindings?.length} ${t('editExternalAPIConfirmWarningContent.end', { ns: 'dataset' })}`}
</AlertDialogDescription>
<div className="p-1">
<div className="flex items-start self-stretch pt-1 pr-3 pb-0.5 pl-2">
<div className="system-xs-medium-uppercase text-text-tertiary">{`${datasetBindings?.length} ${t('editExternalAPITooltipTitle', { ns: 'dataset' })}`}</div>
</div>
{datasetBindings?.map(binding => (
<div key={binding.id} className="flex items-center gap-1 self-stretch px-2 py-1">
<RiBook2Line className="h-4 w-4 text-text-secondary" />
<div className="system-sm-medium text-text-secondary">{binding.name}</div>
</div>
))}
</div>
</PopoverContent>
</Popover>
</span>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleSave}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<ActionButton className="absolute top-5 right-5" onClick={onCancel}>
<RiCloseLine className="h-[18px] w-[18px] shrink-0 text-text-tertiary" />
</ActionButton>
<Form value={formData} onChange={handleDataChange} formSchemas={formSchemas} className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3" />
<div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5">
<Button type="button" variant="secondary" onClick={onCancel}>
{t('externalAPIForm.cancel', { ns: 'dataset' })}
</Button>
<Button
type="submit"
variant="primary"
onClick={() => {
if (isEditMode && (datasetBindings?.length ?? 0) > 0)
setShowConfirm(true)
else if (isEditMode && onEdit)
onEdit(formData)
else
handleSave()
}}
disabled={hasEmptyInputs || loading}
>
{t('externalAPIForm.save', { ns: 'dataset' })}
</Button>
</div>
<div className="flex items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px] border-divider-subtle
bg-background-soft px-2 py-3 system-xs-regular text-text-tertiary"
>
<RiLock2Fill className="h-3 w-3 text-text-quaternary" />
{t('externalAPIForm.encrypted.front', { ns: 'dataset' })}
<a className="text-text-accent" target="_blank" rel="noopener noreferrer" href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html">
PKCS1_OAEP
</a>
{t('externalAPIForm.encrypted.end', { ns: 'dataset' })}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<AlertDialog
open={showConfirm && (datasetBindings?.length ?? 0) > 0}
onOpenChange={open => !open && setShowConfirm(false)}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
Warning
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{`${t('editExternalAPIConfirmWarningContent.front', { ns: 'dataset' })} ${datasetBindings?.length} ${t('editExternalAPIConfirmWarningContent.end', { ns: 'dataset' })}`}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleSave}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</DialogContent>
</Dialog>
)
}
export default memo(AddExternalAPIModal)

View File

@ -1,10 +1,10 @@
import type { RelatedAppResponse } from '@/models/datasets'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiInformation2Line } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import LinkedAppsPanel from '@/app/components/base/linked-apps-panel'
import Tooltip from '@/app/components/base/tooltip'
import NoLinkedAppsPanel from '../no-linked-apps-panel'
type StatisticsProps = {
@ -40,26 +40,34 @@ const Statistics = ({
<div className="system-md-semibold-uppercase text-text-secondary">
{relatedAppsTotal ?? '--'}
</div>
<Tooltip
position="top-start"
noDecoration
needsDelay
popupContent={
hasRelatedApps
<Popover>
<PopoverTrigger
openOnHover
aria-label={t('datasetMenus.relatedApp', { ns: 'common' })}
render={(
<button
type="button"
className="flex cursor-pointer items-center gap-x-0.5 rounded-sm system-2xs-medium-uppercase text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
>
<span>{t('datasetMenus.relatedApp', { ns: 'common' })}</span>
<RiInformation2Line className="size-3" />
</button>
)}
/>
<PopoverContent
placement="top-start"
popupClassName="border-0 bg-transparent p-0 shadow-none"
>
{hasRelatedApps
? (
<LinkedAppsPanel
relatedApps={relatedApps.data}
isMobile={!expand}
/>
)
: <NoLinkedAppsPanel />
}
>
<div className="flex cursor-pointer items-center gap-x-0.5 system-2xs-medium-uppercase text-text-tertiary">
<span>{t('datasetMenus.relatedApp', { ns: 'common' })}</span>
<RiInformation2Line className="size-3" />
</div>
</Tooltip>
: <NoLinkedAppsPanel />}
</PopoverContent>
</Popover>
</div>
</div>
)

View File

@ -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>
)
: (

View File

@ -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>

View File

@ -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>
)
: (

View File

@ -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

View File

@ -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 && (

View File

@ -1,5 +1,6 @@
import { subscribeWorkflowCommand, WorkflowCommand } from '@/app/components/workflow/shortcuts/commands'
import { registerCommands, unregisterCommands } from '../command-bus'
import { ZEN_TOGGLE_EVENT, zenCommand } from '../zen'
import { zenCommand } from '../zen'
vi.mock('../command-bus')
@ -24,10 +25,6 @@ describe('zenCommand', () => {
expect(zenCommand.execute).toBeDefined()
})
it('exports ZEN_TOGGLE_EVENT constant', () => {
expect(ZEN_TOGGLE_EVENT).toBe('zen-toggle-maximize')
})
describe('isAvailable', () => {
it('delegates to isInWorkflowPage', async () => {
const { isInWorkflowPage } = vi.mocked(
@ -43,15 +40,14 @@ describe('zenCommand', () => {
})
describe('execute', () => {
it('dispatches custom zen-toggle event', () => {
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
it('emits the workflow canvas maximize command', () => {
const listener = vi.fn()
const unsubscribe = subscribeWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize, listener)
zenCommand.execute?.()
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: ZEN_TOGGLE_EVENT }),
)
dispatchSpy.mockRestore()
expect(listener).toHaveBeenCalledTimes(1)
unsubscribe()
})
})

View File

@ -3,17 +3,17 @@ import { RiFullscreenLine } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { isInWorkflowPage } from '@/app/components/workflow/constants'
import {
emitWorkflowCommand,
WorkflowCommand,
} from '@/app/components/workflow/shortcuts/commands'
import { registerCommands, unregisterCommands } from './command-bus'
// Zen command dependency types - no external dependencies needed
type ZenDeps = Record<string, never>
// Custom event name for zen toggle
export const ZEN_TOGGLE_EVENT = 'zen-toggle-maximize'
// Shared function to dispatch zen toggle event
const toggleZenMode = () => {
window.dispatchEvent(new CustomEvent(ZEN_TOGGLE_EVENT))
emitWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize)
}
/**

View File

@ -1,6 +1,7 @@
import type {
DataSourceCredential,
} from './types'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -45,11 +46,17 @@ const Operator = ({
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger render={<div />}>
<ActionButton size="l" className={open ? 'bg-state-base-hover' : ''}>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={(
<ActionButton
size="l"
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
aria-label={t('operation.more', { ns: 'common' })}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</ActionButton>
)}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[200px]">
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('setDefault')}>
<span aria-hidden className="i-ri-home-9-line h-4 w-4 text-text-tertiary" />

View File

@ -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()

View File

@ -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>
)
}

View File

@ -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

View File

@ -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)
})

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