Merge branch 'feat/dropdown-migaration' into deploy/dev

This commit is contained in:
CodingOnStar
2026-04-16 16:58:08 +08:00
130 changed files with 5781 additions and 5118 deletions

View File

@ -2,20 +2,37 @@ from typing import Literal
import sqlalchemy as sa
from flask import abort, request
from flask_restx import Resource, fields, marshal_with
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, or_
from sqlalchemy.orm import selectinload
from werkzeug.exceptions import NotFound
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from fields.raws import FilesContainedField
from fields.conversation_fields import (
Conversation as ConversationResponse,
)
from fields.conversation_fields import (
ConversationDetail as ConversationDetailResponse,
)
from fields.conversation_fields import (
ConversationMessageDetail as ConversationMessageDetailResponse,
)
from fields.conversation_fields import (
ConversationPagination as ConversationPaginationResponse,
)
from fields.conversation_fields import (
ConversationWithSummaryPagination as ConversationWithSummaryPaginationResponse,
)
from fields.conversation_fields import (
ResultResponse,
)
from libs.datetime_utils import naive_utc_now, parse_time_range
from libs.helper import TimestampField
from libs.login import current_account_with_tenant, login_required
from models import Conversation, EndUser, Message, MessageAnnotation
from models.model import AppMode
@ -62,267 +79,16 @@ console_ns.schema_model(
ChatConversationQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
# Register models for flask_restx to avoid dict type issues in Swagger
# Register in dependency order: base models first, then dependent models
# Base models
simple_account_model = console_ns.model(
"SimpleAccount",
{
"id": fields.String,
"name": fields.String,
"email": fields.String,
},
)
feedback_stat_model = console_ns.model(
"FeedbackStat",
{
"like": fields.Integer,
"dislike": fields.Integer,
},
)
status_count_model = console_ns.model(
"StatusCount",
{
"success": fields.Integer,
"failed": fields.Integer,
"partial_success": fields.Integer,
"paused": fields.Integer,
},
)
message_file_model = console_ns.model(
"MessageFile",
{
"id": fields.String,
"filename": fields.String,
"type": fields.String,
"url": fields.String,
"mime_type": fields.String,
"size": fields.Integer,
"transfer_method": fields.String,
"belongs_to": fields.String(default="user"),
"upload_file_id": fields.String(default=None),
},
)
agent_thought_model = console_ns.model(
"AgentThought",
{
"id": fields.String,
"chain_id": fields.String,
"message_id": fields.String,
"position": fields.Integer,
"thought": fields.String,
"tool": fields.String,
"tool_labels": fields.Raw,
"tool_input": fields.String,
"created_at": TimestampField,
"observation": fields.String,
"files": fields.List(fields.String),
},
)
simple_model_config_model = console_ns.model(
"SimpleModelConfig",
{
"model": fields.Raw(attribute="model_dict"),
"pre_prompt": fields.String,
},
)
model_config_model = console_ns.model(
"ModelConfig",
{
"opening_statement": fields.String,
"suggested_questions": fields.Raw,
"model": fields.Raw,
"user_input_form": fields.Raw,
"pre_prompt": fields.String,
"agent_mode": fields.Raw,
},
)
# Models that depend on simple_account_model
feedback_model = console_ns.model(
"Feedback",
{
"rating": fields.String,
"content": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account": fields.Nested(simple_account_model, allow_null=True),
},
)
annotation_model = console_ns.model(
"Annotation",
{
"id": fields.String,
"question": fields.String,
"content": fields.String,
"account": fields.Nested(simple_account_model, allow_null=True),
"created_at": TimestampField,
},
)
annotation_hit_history_model = console_ns.model(
"AnnotationHitHistory",
{
"annotation_id": fields.String(attribute="id"),
"annotation_create_account": fields.Nested(simple_account_model, allow_null=True),
"created_at": TimestampField,
},
)
class MessageTextField(fields.Raw):
def format(self, value):
return value[0]["text"] if value else ""
# Simple message detail model
simple_message_detail_model = console_ns.model(
"SimpleMessageDetail",
{
"inputs": FilesContainedField,
"query": fields.String,
"message": MessageTextField,
"answer": fields.String,
},
)
# Message detail model that depends on multiple models
message_detail_model = console_ns.model(
"MessageDetail",
{
"id": fields.String,
"conversation_id": fields.String,
"inputs": FilesContainedField,
"query": fields.String,
"message": fields.Raw,
"message_tokens": fields.Integer,
"answer": fields.String(attribute="re_sign_file_url_answer"),
"answer_tokens": fields.Integer,
"provider_response_latency": fields.Float,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"feedbacks": fields.List(fields.Nested(feedback_model)),
"workflow_run_id": fields.String,
"annotation": fields.Nested(annotation_model, allow_null=True),
"annotation_hit_history": fields.Nested(annotation_hit_history_model, allow_null=True),
"created_at": TimestampField,
"agent_thoughts": fields.List(fields.Nested(agent_thought_model)),
"message_files": fields.List(fields.Nested(message_file_model)),
"metadata": fields.Raw(attribute="message_metadata_dict"),
"status": fields.String,
"error": fields.String,
"parent_message_id": fields.String,
},
)
# Conversation models
conversation_fields_model = console_ns.model(
"Conversation",
{
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_end_user_session_id": fields.String(),
"from_account_id": fields.String,
"from_account_name": fields.String,
"read_at": TimestampField,
"created_at": TimestampField,
"updated_at": TimestampField,
"annotation": fields.Nested(annotation_model, allow_null=True),
"model_config": fields.Nested(simple_model_config_model),
"user_feedback_stats": fields.Nested(feedback_stat_model),
"admin_feedback_stats": fields.Nested(feedback_stat_model),
"message": fields.Nested(simple_message_detail_model, attribute="first_message"),
},
)
conversation_pagination_model = console_ns.model(
"ConversationPagination",
{
"page": fields.Integer,
"limit": fields.Integer(attribute="per_page"),
"total": fields.Integer,
"has_more": fields.Boolean(attribute="has_next"),
"data": fields.List(fields.Nested(conversation_fields_model), attribute="items"),
},
)
conversation_message_detail_model = console_ns.model(
"ConversationMessageDetail",
{
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"created_at": TimestampField,
"model_config": fields.Nested(model_config_model),
"message": fields.Nested(message_detail_model, attribute="first_message"),
},
)
conversation_with_summary_model = console_ns.model(
"ConversationWithSummary",
{
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_end_user_session_id": fields.String,
"from_account_id": fields.String,
"from_account_name": fields.String,
"name": fields.String,
"summary": fields.String(attribute="summary_or_query"),
"read_at": TimestampField,
"created_at": TimestampField,
"updated_at": TimestampField,
"annotated": fields.Boolean,
"model_config": fields.Nested(simple_model_config_model),
"message_count": fields.Integer,
"user_feedback_stats": fields.Nested(feedback_stat_model),
"admin_feedback_stats": fields.Nested(feedback_stat_model),
"status_count": fields.Nested(status_count_model),
},
)
conversation_with_summary_pagination_model = console_ns.model(
"ConversationWithSummaryPagination",
{
"page": fields.Integer,
"limit": fields.Integer(attribute="per_page"),
"total": fields.Integer,
"has_more": fields.Boolean(attribute="has_next"),
"data": fields.List(fields.Nested(conversation_with_summary_model), attribute="items"),
},
)
conversation_detail_model = console_ns.model(
"ConversationDetail",
{
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"created_at": TimestampField,
"updated_at": TimestampField,
"annotated": fields.Boolean,
"introduction": fields.String,
"model_config": fields.Nested(model_config_model),
"message_count": fields.Integer,
"user_feedback_stats": fields.Nested(feedback_stat_model),
"admin_feedback_stats": fields.Nested(feedback_stat_model),
},
register_schema_models(
console_ns,
CompletionConversationQuery,
ChatConversationQuery,
ConversationResponse,
ConversationPaginationResponse,
ConversationMessageDetailResponse,
ConversationWithSummaryPaginationResponse,
ConversationDetailResponse,
ResultResponse,
)
@ -332,13 +98,12 @@ class CompletionConversationApi(Resource):
@console_ns.doc(description="Get completion conversations with pagination and filtering")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[CompletionConversationQuery.__name__])
@console_ns.response(200, "Success", conversation_pagination_model)
@console_ns.response(200, "Success", console_ns.models[ConversationPaginationResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
@marshal_with(conversation_pagination_model)
@edit_permission_required
def get(self, app_model):
current_user, _ = current_account_with_tenant()
@ -394,7 +159,9 @@ class CompletionConversationApi(Resource):
conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False)
return conversations
return ConversationPaginationResponse.model_validate(conversations, from_attributes=True).model_dump(
mode="json"
)
@console_ns.route("/apps/<uuid:app_id>/completion-conversations/<uuid:conversation_id>")
@ -402,19 +169,19 @@ class CompletionConversationDetailApi(Resource):
@console_ns.doc("get_completion_conversation")
@console_ns.doc(description="Get completion conversation details with messages")
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
@console_ns.response(200, "Success", conversation_message_detail_model)
@console_ns.response(200, "Success", console_ns.models[ConversationMessageDetailResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Conversation not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
@marshal_with(conversation_message_detail_model)
@edit_permission_required
def get(self, app_model, conversation_id):
conversation_id = str(conversation_id)
return _get_conversation(app_model, conversation_id)
return ConversationMessageDetailResponse.model_validate(
_get_conversation(app_model, conversation_id), from_attributes=True
).model_dump(mode="json")
@console_ns.doc("delete_completion_conversation")
@console_ns.doc(description="Delete a completion conversation")
@ -436,7 +203,7 @@ class CompletionConversationDetailApi(Resource):
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
return {"result": "success"}, 204
return ResultResponse(result="success").model_dump(mode="json"), 204
@console_ns.route("/apps/<uuid:app_id>/chat-conversations")
@ -445,13 +212,12 @@ class ChatConversationApi(Resource):
@console_ns.doc(description="Get chat conversations with pagination, filtering and summary")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[ChatConversationQuery.__name__])
@console_ns.response(200, "Success", conversation_with_summary_pagination_model)
@console_ns.response(200, "Success", console_ns.models[ConversationWithSummaryPaginationResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@marshal_with(conversation_with_summary_pagination_model)
@edit_permission_required
def get(self, app_model):
current_user, _ = current_account_with_tenant()
@ -546,7 +312,9 @@ class ChatConversationApi(Resource):
conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False)
return conversations
return ConversationWithSummaryPaginationResponse.model_validate(conversations, from_attributes=True).model_dump(
mode="json"
)
@console_ns.route("/apps/<uuid:app_id>/chat-conversations/<uuid:conversation_id>")
@ -554,19 +322,19 @@ class ChatConversationDetailApi(Resource):
@console_ns.doc("get_chat_conversation")
@console_ns.doc(description="Get chat conversation details")
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
@console_ns.response(200, "Success", conversation_detail_model)
@console_ns.response(200, "Success", console_ns.models[ConversationDetailResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Conversation not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@marshal_with(conversation_detail_model)
@edit_permission_required
def get(self, app_model, conversation_id):
conversation_id = str(conversation_id)
return _get_conversation(app_model, conversation_id)
return ConversationDetailResponse.model_validate(
_get_conversation(app_model, conversation_id), from_attributes=True
).model_dump(mode="json")
@console_ns.doc("delete_chat_conversation")
@console_ns.doc(description="Delete a chat conversation")
@ -588,7 +356,7 @@ class ChatConversationDetailApi(Resource):
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
return {"result": "success"}, 204
return ResultResponse(result="success").model_dump(mode="json"), 204
def _get_conversation(app_model, conversation_id):

View File

@ -15,7 +15,7 @@ from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.controller_schemas import DefaultBlockConfigQuery
from controllers.console import console_ns
from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync
from controllers.console.app.workflow_run import workflow_run_node_execution_model
@ -170,6 +170,7 @@ class WorkflowUpdatePayload(BaseModel):
class WorkflowTypeConvertQuery(BaseModel):
target_type: Literal["workflow", "evaluation"]
class WorkflowFeaturesPayload(BaseModel):
features: dict[str, Any] = Field(..., description="Workflow feature configuration")

View File

@ -3,11 +3,12 @@ from __future__ import annotations
import logging
from collections.abc import Callable
from functools import wraps
from typing import TYPE_CHECKING, ParamSpec, TypeVar, Union
from typing import TYPE_CHECKING, Union
from urllib.parse import quote
from flask import Response, request
from flask_restx import Resource, fields, marshal
from graphon.file import helpers as file_helpers
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
@ -25,7 +26,6 @@ from core.evaluation.entities.evaluation_entity import EvaluationCategory, Evalu
from extensions.ext_database import db
from extensions.ext_storage import storage
from fields.member_fields import simple_account_fields
from graphon.file import helpers as file_helpers
from libs.helper import TimestampField
from libs.login import current_account_with_tenant, login_required
from models import App, Dataset
@ -45,9 +45,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
P = ParamSpec("P")
R = TypeVar("R")
# Valid evaluation target types
EVALUATE_TARGET_TYPES = {"app", "snippets"}
@ -184,7 +181,7 @@ evaluation_default_metrics_response_model = console_ns.model(
)
def get_evaluation_target(view_func: Callable[P, R]):
def get_evaluation_target[**P, R](view_func: Callable[P, R]) -> Callable[P, R]:
"""
Decorator to resolve polymorphic evaluation target (app or snippet).
@ -193,7 +190,7 @@ def get_evaluation_target(view_func: Callable[P, R]):
"""
@wraps(view_func)
def decorated_view(*args: P.args, **kwargs: P.kwargs):
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
target_type = kwargs.get("evaluate_target_type")
target_id = kwargs.get("evaluate_target_id")

View File

@ -1,10 +1,10 @@
import logging
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar
from flask import request
from flask_restx import Resource, fields, marshal_with
from graphon.graph_engine.manager import GraphEngineManager
from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError, NotFound
@ -36,7 +36,6 @@ from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from graphon.graph_engine.manager import GraphEngineManager
from libs import helper
from libs.helper import TimestampField
from libs.login import current_account_with_tenant, login_required
@ -47,9 +46,6 @@ from services.snippet_service import SnippetService
logger = logging.getLogger(__name__)
P = ParamSpec("P")
R = TypeVar("R")
# Register Pydantic models with Swagger
register_schema_models(
console_ns,
@ -74,7 +70,7 @@ class SnippetNotFoundError(Exception):
pass
def get_snippet(view_func: Callable[P, R]):
def get_snippet[**P, R](view_func: Callable[P, R]) -> Callable[P, R]:
"""Decorator to fetch and validate snippet access."""
@wraps(view_func)

View File

@ -12,10 +12,11 @@ Other routes mirror `workflow_draft_variable` app APIs under `/snippets/...`.
from collections.abc import Callable
from functools import wraps
from typing import Any, ParamSpec, TypeVar
from typing import Any
from flask import Response, request
from flask_restx import Resource, marshal, marshal_with
from graphon.variables.types import SegmentType
from sqlalchemy.orm import Session
from controllers.console import console_ns
@ -37,16 +38,12 @@ from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTE
from extensions.ext_database import db
from factories.file_factory import build_from_mapping, build_from_mappings
from factories.variable_factory import build_segment_with_type
from graphon.variables.types import SegmentType
from libs.login import current_user, login_required
from models.snippet import CustomizedSnippet
from models.workflow import WorkflowDraftVariable
from services.snippet_service import SnippetService
from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService
P = ParamSpec("P")
R = TypeVar("R")
_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS: frozenset[str] = frozenset(
{SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID}
)
@ -62,7 +59,7 @@ def _ensure_snippet_draft_variable_row_allowed(
raise NotFoundError(description=f"variable not found, id={variable_id}")
def _snippet_draft_var_prerequisite(f: Callable[P, R]) -> Callable[P, R]:
def _snippet_draft_var_prerequisite[**P, R](f: Callable[P, R]) -> Callable[P, R]:
"""Setup, auth, snippet resolution, and tenant edit permission (same stack as snippet workflow APIs)."""
@setup_required

View File

@ -96,7 +96,7 @@ class ConversationAnnotation(ResponseModel):
class ConversationAnnotationHitHistory(ResponseModel):
annotation_id: str
annotation_id: str = Field(validation_alias="id")
annotation_create_account: SimpleAccount | None = None
created_at: int | None = None
@ -143,7 +143,7 @@ class MessageDetail(ResponseModel):
query: str
message: JSONValue
message_tokens: int
answer: str
answer: str = Field(validation_alias="re_sign_file_url_answer")
answer_tokens: int
provider_response_latency: float
from_source: str
@ -156,7 +156,7 @@ class MessageDetail(ResponseModel):
created_at: int | None = None
agent_thoughts: list[AgentThought]
message_files: list[MessageFile]
metadata: JSONValue
metadata: JSONValue = Field(validation_alias="message_metadata_dict")
status: str
error: str | None = None
parent_message_id: str | None = None
@ -196,7 +196,7 @@ class ModelConfig(ResponseModel):
class SimpleModelConfig(ResponseModel):
model: JSONValue | None = None
model: JSONValue | None = Field(default=None, validation_alias="model_dict")
pre_prompt: str | None = None
@ -211,6 +211,11 @@ class SimpleMessageDetail(ResponseModel):
def _normalize_inputs(cls, value: JSONValue) -> JSONValue:
return format_files_contained(value)
@field_validator("message", mode="before")
@classmethod
def _normalize_message(cls, value: JSONValue) -> str:
return message_text(value)
class Conversation(ResponseModel):
id: str
@ -227,15 +232,22 @@ class Conversation(ResponseModel):
model_config_: SimpleModelConfig | None = Field(default=None, alias="model_config")
user_feedback_stats: FeedbackStat | None = None
admin_feedback_stats: FeedbackStat | None = None
message: SimpleMessageDetail | None = None
message: SimpleMessageDetail | None = Field(default=None, validation_alias="first_message")
@field_validator("read_at", "created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
class ConversationPagination(ResponseModel):
page: int
limit: int
limit: int = Field(validation_alias="per_page")
total: int
has_more: bool
data: list[Conversation]
has_more: bool = Field(validation_alias="has_next")
data: list[Conversation] = Field(validation_alias="items")
class ConversationMessageDetail(ResponseModel):
@ -246,7 +258,14 @@ class ConversationMessageDetail(ResponseModel):
from_account_id: str | None = None
created_at: int | None = None
model_config_: ModelConfig | None = Field(default=None, alias="model_config")
message: MessageDetail | None = None
message: MessageDetail | None = Field(default=None, validation_alias="first_message")
@field_validator("created_at", mode="before")
@classmethod
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
class ConversationWithSummary(ResponseModel):
@ -258,7 +277,7 @@ class ConversationWithSummary(ResponseModel):
from_account_id: str | None = None
from_account_name: str | None = None
name: str
summary: str
summary: str = Field(validation_alias="summary_or_query")
read_at: int | None = None
created_at: int | None = None
updated_at: int | None = None
@ -269,13 +288,20 @@ class ConversationWithSummary(ResponseModel):
admin_feedback_stats: FeedbackStat | None = None
status_count: StatusCount | None = None
@field_validator("read_at", "created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
class ConversationWithSummaryPagination(ResponseModel):
page: int
limit: int
limit: int = Field(validation_alias="per_page")
total: int
has_more: bool
data: list[ConversationWithSummary]
has_more: bool = Field(validation_alias="has_next")
data: list[ConversationWithSummary] = Field(validation_alias="items")
class ConversationDetail(ResponseModel):
@ -293,6 +319,13 @@ class ConversationDetail(ResponseModel):
user_feedback_stats: FeedbackStat | None = None
admin_feedback_stats: FeedbackStat | None = None
@field_validator("created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
def to_timestamp(value: datetime | None) -> int | None:
if value is None:

View File

@ -1,5 +1,13 @@
from flask_restx import Namespace, fields
from __future__ import annotations
from datetime import datetime
from typing import Any
from flask_restx import Namespace, fields
from graphon.variables.types import SegmentType
from pydantic import field_validator
from fields.base import ResponseModel
from libs.helper import TimestampField
from ._value_type_serializer import serialize_value_type
@ -29,6 +37,74 @@ conversation_variable_infinite_scroll_pagination_fields = {
}
def _to_timestamp(value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return int(value.timestamp())
return value
class ConversationVariableResponse(ResponseModel):
id: str
name: str
value_type: str
value: str | None = None
description: str | None = None
created_at: int | None = None
updated_at: int | None = None
@field_validator("value_type", mode="before")
@classmethod
def _normalize_value_type(cls, value: Any) -> str:
exposed_type = getattr(value, "exposed_type", None)
if callable(exposed_type):
return str(exposed_type().value)
if isinstance(value, str):
try:
return str(SegmentType(value).exposed_type().value)
except ValueError:
return value
try:
return serialize_value_type(value)
except (AttributeError, TypeError, ValueError):
pass
try:
return serialize_value_type({"value_type": value})
except (AttributeError, TypeError, ValueError):
value_attr = getattr(value, "value", None)
if value_attr is not None:
return str(value_attr)
return str(value)
@field_validator("value", mode="before")
@classmethod
def _normalize_value(cls, value: Any | None) -> str | None:
if value is None:
return None
if isinstance(value, str):
return value
return str(value)
@field_validator("created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value)
class PaginatedConversationVariableResponse(ResponseModel):
page: int
limit: int
total: int
has_more: bool
data: list[ConversationVariableResponse]
class ConversationVariableInfiniteScrollPaginationResponse(ResponseModel):
limit: int
has_more: bool
data: list[ConversationVariableResponse]
def build_conversation_variable_model(api_or_ns: Namespace):
"""Build the conversation variable model for the API or Namespace."""
return api_or_ns.model("ConversationVariable", conversation_variable_fields)

View File

@ -1,8 +1,17 @@
from flask_restx import Namespace, fields
from __future__ import annotations
from fields.end_user_fields import simple_end_user_fields
from fields.member_fields import simple_account_fields
from datetime import datetime
from typing import Any
from flask_restx import Namespace, fields
from pydantic import field_validator
from fields.base import ResponseModel
from fields.end_user_fields import SimpleEndUser, simple_end_user_fields
from fields.member_fields import SimpleAccount, simple_account_fields
from fields.workflow_run_fields import (
WorkflowRunForArchivedLogResponse,
WorkflowRunForLogResponse,
build_workflow_run_for_archived_log_model,
build_workflow_run_for_log_model,
workflow_run_for_archived_log_fields,
@ -86,3 +95,55 @@ def build_workflow_archived_log_pagination_model(api_or_ns: Namespace):
copied_fields = workflow_archived_log_pagination_fields.copy()
copied_fields["data"] = fields.List(fields.Nested(workflow_archived_log_partial_model))
return api_or_ns.model("WorkflowArchivedLogPagination", copied_fields)
def _to_timestamp(value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return int(value.timestamp())
return value
class WorkflowAppLogPartialResponse(ResponseModel):
id: str
workflow_run: WorkflowRunForLogResponse | None = None
details: Any = None
created_from: str | None = None
created_by_role: str | None = None
created_by_account: SimpleAccount | None = None
created_by_end_user: SimpleEndUser | None = None
created_at: int | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value)
class WorkflowArchivedLogPartialResponse(ResponseModel):
id: str
workflow_run: WorkflowRunForArchivedLogResponse | None = None
trigger_metadata: Any = None
created_by_account: SimpleAccount | None = None
created_by_end_user: SimpleEndUser | None = None
created_at: int | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value)
class WorkflowAppLogPaginationResponse(ResponseModel):
page: int
limit: int
total: int
has_more: bool
data: list[WorkflowAppLogPartialResponse]
class WorkflowArchivedLogPaginationResponse(ResponseModel):
page: int
limit: int
total: int
has_more: bool
data: list[WorkflowArchivedLogPartialResponse]

View File

@ -1,7 +1,14 @@
from flask_restx import Namespace, fields
from __future__ import annotations
from fields.end_user_fields import simple_end_user_fields
from fields.member_fields import simple_account_fields
from datetime import datetime
from typing import Any
from flask_restx import Namespace, fields
from pydantic import Field, field_validator
from fields.base import ResponseModel
from fields.end_user_fields import SimpleEndUser, simple_end_user_fields
from fields.member_fields import SimpleAccount, simple_account_fields
from libs.helper import TimestampField
workflow_run_for_log_fields = {
@ -147,3 +154,174 @@ workflow_run_node_execution_fields = {
workflow_run_node_execution_list_fields = {
"data": fields.List(fields.Nested(workflow_run_node_execution_fields)),
}
def _to_timestamp(value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return int(value.timestamp())
return value
class WorkflowRunForLogResponse(ResponseModel):
id: str
version: str | None = None
status: str | None = None
triggered_from: str | None = None
error: str | None = None
elapsed_time: float | None = None
total_tokens: int | None = None
total_steps: int | None = None
created_at: int | None = None
finished_at: int | None = None
exceptions_count: int | None = None
@field_validator("status", mode="before")
@classmethod
def _normalize_status(cls, value: Any) -> str | None:
if value is None or isinstance(value, str):
return value
return str(getattr(value, "value", value))
@field_validator("created_at", "finished_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value)
class WorkflowRunForArchivedLogResponse(ResponseModel):
id: str
status: str | None = None
triggered_from: str | None = None
elapsed_time: float | None = None
total_tokens: int | None = None
@field_validator("status", mode="before")
@classmethod
def _normalize_status(cls, value: Any) -> str | None:
if value is None or isinstance(value, str):
return value
return str(getattr(value, "value", value))
class WorkflowRunForListResponse(ResponseModel):
id: str
version: str | None = None
status: str | None = None
elapsed_time: float | None = None
total_tokens: int | None = None
total_steps: int | None = None
created_by_account: SimpleAccount | None = None
created_at: int | None = None
finished_at: int | None = None
exceptions_count: int | None = None
retry_index: int | None = None
@field_validator("status", mode="before")
@classmethod
def _normalize_status(cls, value: Any) -> str | None:
if value is None or isinstance(value, str):
return value
return str(getattr(value, "value", value))
@field_validator("created_at", "finished_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value)
class AdvancedChatWorkflowRunForListResponse(WorkflowRunForListResponse):
conversation_id: str | None = None
message_id: str | None = None
class AdvancedChatWorkflowRunPaginationResponse(ResponseModel):
limit: int
has_more: bool
data: list[AdvancedChatWorkflowRunForListResponse]
class WorkflowRunPaginationResponse(ResponseModel):
limit: int
has_more: bool
data: list[WorkflowRunForListResponse]
class WorkflowRunCountResponse(ResponseModel):
total: int
running: int
succeeded: int
failed: int
stopped: int
partial_succeeded: int = Field(validation_alias="partial-succeeded")
class WorkflowRunDetailResponse(ResponseModel):
id: str
version: str | None = None
graph: Any = Field(validation_alias="graph_dict")
inputs: Any = Field(validation_alias="inputs_dict")
status: str | None = None
outputs: Any = Field(validation_alias="outputs_dict")
error: str | None = None
elapsed_time: float | None = None
total_tokens: int | None = None
total_steps: int | None = None
created_by_role: str | None = None
created_by_account: SimpleAccount | None = None
created_by_end_user: SimpleEndUser | None = None
created_at: int | None = None
finished_at: int | None = None
exceptions_count: int | None = None
@field_validator("status", mode="before")
@classmethod
def _normalize_status(cls, value: Any) -> str | None:
if value is None or isinstance(value, str):
return value
return str(getattr(value, "value", value))
@field_validator("created_at", "finished_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value)
class WorkflowRunNodeExecutionResponse(ResponseModel):
id: str
index: int | None = None
predecessor_node_id: str | None = None
node_id: str | None = None
node_type: str | None = None
title: str | None = None
inputs: Any = Field(default=None, validation_alias="inputs_dict")
process_data: Any = Field(default=None, validation_alias="process_data_dict")
outputs: Any = Field(default=None, validation_alias="outputs_dict")
status: str | None = None
error: str | None = None
elapsed_time: float | None = None
execution_metadata: Any = Field(default=None, validation_alias="execution_metadata_dict")
extras: Any = None
created_at: int | None = None
created_by_role: str | None = None
created_by_account: SimpleAccount | None = None
created_by_end_user: SimpleEndUser | None = None
finished_at: int | None = None
inputs_truncated: bool | None = None
outputs_truncated: bool | None = None
process_data_truncated: bool | None = None
@field_validator("status", mode="before")
@classmethod
def _normalize_status(cls, value: Any) -> str | None:
if value is None or isinstance(value, str):
return value
return str(getattr(value, "value", value))
@field_validator("created_at", "finished_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value)
class WorkflowRunNodeExecutionListResponse(ResponseModel):
data: list[WorkflowRunNodeExecutionResponse]

View File

@ -33,7 +33,7 @@ dependencies = [
"google-cloud-aiplatform>=1.147.0,<2.0.0",
"httpx[socks]>=0.28.1,<1.0.0",
"langfuse>=4.2.0,<5.0.0",
"langsmith>=0.7.30,<1.0.0",
"langsmith>=0.7.31,<1.0.0",
"mlflow-skinny>=3.11.1,<4.0.0",
"opentelemetry-distro>=0.62b0,<1.0.0",
"opentelemetry-instrumentation-celery>=0.62b0,<1.0.0",

View File

@ -11,6 +11,7 @@ from unittest.mock import ANY, Mock, patch
import pytest
from faker import Faker
from sqlalchemy import select
from core.rag.index_processor.constant.index_type import IndexStructureType
from models.dataset import Dataset, Document, DocumentSegment
@ -221,7 +222,9 @@ class TestDealDatasetVectorIndexTask:
deal_dataset_vector_index_task(dataset.id, "add")
# Verify document status was updated to indexing then completed
updated_document = db_session_with_containers.query(Document).filter_by(id=document.id).first()
updated_document = db_session_with_containers.scalar(
select(Document).where(Document.id == document.id).limit(1)
)
assert updated_document.indexing_status == IndexingStatus.COMPLETED
# Verify index processor load method was called
@ -322,7 +325,9 @@ class TestDealDatasetVectorIndexTask:
deal_dataset_vector_index_task(dataset.id, "update")
# Verify document status was updated to indexing then completed
updated_document = db_session_with_containers.query(Document).filter_by(id=document.id).first()
updated_document = db_session_with_containers.scalar(
select(Document).where(Document.id == document.id).limit(1)
)
assert updated_document.indexing_status == IndexingStatus.COMPLETED
# Verify index processor clean and load methods were called
@ -431,7 +436,9 @@ class TestDealDatasetVectorIndexTask:
deal_dataset_vector_index_task(dataset.id, "add")
# Verify document status was updated to indexing then completed
updated_document = db_session_with_containers.query(Document).filter_by(id=document.id).first()
updated_document = db_session_with_containers.scalar(
select(Document).where(Document.id == document.id).limit(1)
)
assert updated_document.indexing_status == IndexingStatus.COMPLETED
# Verify that no index processor load was called since no segments exist
@ -564,7 +571,9 @@ class TestDealDatasetVectorIndexTask:
deal_dataset_vector_index_task(dataset.id, "add")
# Verify document status was updated to error
updated_document = db_session_with_containers.query(Document).filter_by(id=document.id).first()
updated_document = db_session_with_containers.scalar(
select(Document).where(Document.id == document.id).limit(1)
)
assert updated_document.indexing_status == IndexingStatus.ERROR
assert "Test exception during indexing" in updated_document.error
@ -635,7 +644,9 @@ class TestDealDatasetVectorIndexTask:
deal_dataset_vector_index_task(dataset.id, "add")
# Verify document status was updated to indexing then completed
updated_document = db_session_with_containers.query(Document).filter_by(id=document.id).first()
updated_document = db_session_with_containers.scalar(
select(Document).where(Document.id == document.id).limit(1)
)
assert updated_document.indexing_status == IndexingStatus.COMPLETED
# Verify index processor was initialized with custom index type
@ -711,7 +722,9 @@ class TestDealDatasetVectorIndexTask:
deal_dataset_vector_index_task(dataset.id, "add")
# Verify document status was updated to indexing then completed
updated_document = db_session_with_containers.query(Document).filter_by(id=document.id).first()
updated_document = db_session_with_containers.scalar(
select(Document).where(Document.id == document.id).limit(1)
)
assert updated_document.indexing_status == IndexingStatus.COMPLETED
# Verify index processor was initialized with the document's index type
@ -815,7 +828,9 @@ class TestDealDatasetVectorIndexTask:
# Verify all documents were processed
for document in documents:
updated_document = db_session_with_containers.query(Document).filter_by(id=document.id).first()
updated_document = db_session_with_containers.scalar(
select(Document).where(Document.id == document.id).limit(1)
)
assert updated_document.indexing_status == IndexingStatus.COMPLETED
# Verify index processor load was called multiple times
@ -917,7 +932,9 @@ class TestDealDatasetVectorIndexTask:
deal_dataset_vector_index_task(dataset.id, "add")
# Verify final document status
updated_document = db_session_with_containers.query(Document).filter_by(id=document.id).first()
updated_document = db_session_with_containers.scalar(
select(Document).where(Document.id == document.id).limit(1)
)
assert updated_document.indexing_status == IndexingStatus.COMPLETED
def test_deal_dataset_vector_index_task_with_disabled_documents(
@ -1027,12 +1044,14 @@ class TestDealDatasetVectorIndexTask:
deal_dataset_vector_index_task(dataset.id, "add")
# Verify only enabled document was processed
updated_enabled_document = db_session_with_containers.query(Document).filter_by(id=enabled_document.id).first()
updated_enabled_document = db_session_with_containers.scalar(
select(Document).where(Document.id == enabled_document.id).limit(1)
)
assert updated_enabled_document.indexing_status == IndexingStatus.COMPLETED
# Verify disabled document status remains unchanged
updated_disabled_document = (
db_session_with_containers.query(Document).filter_by(id=disabled_document.id).first()
updated_disabled_document = db_session_with_containers.scalar(
select(Document).where(Document.id == disabled_document.id).limit(1)
)
assert updated_disabled_document.indexing_status == IndexingStatus.COMPLETED # Should not change
@ -1148,12 +1167,14 @@ class TestDealDatasetVectorIndexTask:
deal_dataset_vector_index_task(dataset.id, "add")
# Verify only active document was processed
updated_active_document = db_session_with_containers.query(Document).filter_by(id=active_document.id).first()
updated_active_document = db_session_with_containers.scalar(
select(Document).where(Document.id == active_document.id).limit(1)
)
assert updated_active_document.indexing_status == IndexingStatus.COMPLETED
# Verify archived document status remains unchanged
updated_archived_document = (
db_session_with_containers.query(Document).filter_by(id=archived_document.id).first()
updated_archived_document = db_session_with_containers.scalar(
select(Document).where(Document.id == archived_document.id).limit(1)
)
assert updated_archived_document.indexing_status == IndexingStatus.COMPLETED # Should not change
@ -1269,14 +1290,14 @@ class TestDealDatasetVectorIndexTask:
deal_dataset_vector_index_task(dataset.id, "add")
# Verify only completed document was processed
updated_completed_document = (
db_session_with_containers.query(Document).filter_by(id=completed_document.id).first()
updated_completed_document = db_session_with_containers.scalar(
select(Document).where(Document.id == completed_document.id).limit(1)
)
assert updated_completed_document.indexing_status == IndexingStatus.COMPLETED
# Verify incomplete document status remains unchanged
updated_incomplete_document = (
db_session_with_containers.query(Document).filter_by(id=incomplete_document.id).first()
updated_incomplete_document = db_session_with_containers.scalar(
select(Document).where(Document.id == incomplete_document.id).limit(1)
)
assert updated_incomplete_document.indexing_status == IndexingStatus.INDEXING # Should not change

View File

@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch
import pytest
from faker import Faker
from sqlalchemy import select
from core.indexing_runner import DocumentIsPausedError
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
@ -317,7 +318,7 @@ class TestDuplicateDocumentIndexingTasks:
# Verify documents were updated to parsing status
# Re-query documents from database since _duplicate_document_indexing_task uses a different session
for doc_id in document_ids:
updated_document = db_session_with_containers.query(Document).where(Document.id == doc_id).first()
updated_document = db_session_with_containers.scalar(select(Document).where(Document.id == doc_id).limit(1))
assert updated_document.indexing_status == IndexingStatus.PARSING
assert updated_document.processing_started_at is not None
@ -362,14 +363,14 @@ class TestDuplicateDocumentIndexingTasks:
# Verify segments were deleted from database
# Re-query segments from database using captured IDs to avoid stale ORM instances
for seg_id in segment_ids:
deleted_segment = (
db_session_with_containers.query(DocumentSegment).where(DocumentSegment.id == seg_id).first()
deleted_segment = db_session_with_containers.scalar(
select(DocumentSegment).where(DocumentSegment.id == seg_id).limit(1)
)
assert deleted_segment is None
# Verify documents were updated to parsing status
for doc_id in document_ids:
updated_document = db_session_with_containers.query(Document).where(Document.id == doc_id).first()
updated_document = db_session_with_containers.scalar(select(Document).where(Document.id == doc_id).limit(1))
assert updated_document.indexing_status == IndexingStatus.PARSING
assert updated_document.processing_started_at is not None
@ -438,7 +439,7 @@ class TestDuplicateDocumentIndexingTasks:
# Verify only existing documents were updated
# Re-query documents from database since _duplicate_document_indexing_task uses a different session
for doc_id in existing_document_ids:
updated_document = db_session_with_containers.query(Document).where(Document.id == doc_id).first()
updated_document = db_session_with_containers.scalar(select(Document).where(Document.id == doc_id).limit(1))
assert updated_document.indexing_status == IndexingStatus.PARSING
assert updated_document.processing_started_at is not None
@ -485,7 +486,7 @@ class TestDuplicateDocumentIndexingTasks:
# Verify documents were still updated to parsing status before the exception
# Re-query documents from database since _duplicate_document_indexing_task close the session
for doc_id in document_ids:
updated_document = db_session_with_containers.query(Document).where(Document.id == doc_id).first()
updated_document = db_session_with_containers.scalar(select(Document).where(Document.id == doc_id).limit(1))
assert updated_document.indexing_status == IndexingStatus.PARSING
assert updated_document.processing_started_at is not None
@ -543,7 +544,7 @@ class TestDuplicateDocumentIndexingTasks:
# Assert: Verify error handling
# Re-query documents from database since _duplicate_document_indexing_task uses a different session
for doc_id in document_ids:
updated_document = db_session_with_containers.query(Document).where(Document.id == doc_id).first()
updated_document = db_session_with_containers.scalar(select(Document).where(Document.id == doc_id).limit(1))
assert updated_document.indexing_status == IndexingStatus.ERROR
assert updated_document.error is not None
assert "batch upload" in updated_document.error.lower()
@ -585,7 +586,7 @@ class TestDuplicateDocumentIndexingTasks:
# Assert: Verify error handling
# Re-query documents from database since _duplicate_document_indexing_task uses a different session
for doc_id in document_ids:
updated_document = db_session_with_containers.query(Document).where(Document.id == doc_id).first()
updated_document = db_session_with_containers.scalar(select(Document).where(Document.id == doc_id).limit(1))
assert updated_document.indexing_status == IndexingStatus.ERROR
assert updated_document.error is not None
assert "limit" in updated_document.error.lower()
@ -649,7 +650,7 @@ class TestDuplicateDocumentIndexingTasks:
# Verify documents were processed
for doc_id in document_ids:
updated_document = db_session_with_containers.query(Document).where(Document.id == doc_id).first()
updated_document = db_session_with_containers.scalar(select(Document).where(Document.id == doc_id).limit(1))
assert updated_document.indexing_status == IndexingStatus.PARSING
@patch("tasks.duplicate_document_indexing_task.TenantIsolatedTaskQueue", autospec=True)
@ -692,7 +693,7 @@ class TestDuplicateDocumentIndexingTasks:
# Verify documents were processed
for doc_id in document_ids:
updated_document = db_session_with_containers.query(Document).where(Document.id == doc_id).first()
updated_document = db_session_with_containers.scalar(select(Document).where(Document.id == doc_id).limit(1))
assert updated_document.indexing_status == IndexingStatus.PARSING
@patch("tasks.duplicate_document_indexing_task.TenantIsolatedTaskQueue", autospec=True)
@ -736,7 +737,7 @@ class TestDuplicateDocumentIndexingTasks:
# Verify documents were processed
for doc_id in document_ids:
updated_document = db_session_with_containers.query(Document).where(Document.id == doc_id).first()
updated_document = db_session_with_containers.scalar(select(Document).where(Document.id == doc_id).limit(1))
assert updated_document.indexing_status == IndexingStatus.PARSING
@patch("tasks.duplicate_document_indexing_task.TenantIsolatedTaskQueue", autospec=True)
@ -851,7 +852,7 @@ class TestDuplicateDocumentIndexingTasks:
# Assert
for doc_id in document_ids:
updated_document = db_session_with_containers.query(Document).where(Document.id == doc_id).first()
updated_document = db_session_with_containers.scalar(select(Document).where(Document.id == doc_id).limit(1))
assert updated_document.is_paused is True
assert updated_document.indexing_status == IndexingStatus.PARSING
assert updated_document.display_status == "paused"

View File

@ -33,12 +33,17 @@ def test_completion_conversation_list_returns_paginated_result(app, monkeypatch:
monkeypatch.setattr(conversation_module, "parse_time_range", lambda *_args, **_kwargs: (None, None))
paginate_result = MagicMock()
paginate_result.page = 1
paginate_result.per_page = 20
paginate_result.total = 0
paginate_result.has_next = False
paginate_result.items = []
monkeypatch.setattr(conversation_module.db, "paginate", lambda *_args, **_kwargs: paginate_result)
with app.test_request_context("/console/api/apps/app-1/completion-conversations", method="GET"):
response = method(app_model=SimpleNamespace(id="app-1"))
assert response is paginate_result
assert response == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []}
def test_completion_conversation_list_invalid_time_range(app, monkeypatch: pytest.MonkeyPatch) -> None:
@ -71,12 +76,17 @@ def test_chat_conversation_list_advanced_chat_calls_paginate(app, monkeypatch: p
monkeypatch.setattr(conversation_module, "parse_time_range", lambda *_args, **_kwargs: (None, None))
paginate_result = MagicMock()
paginate_result.page = 1
paginate_result.per_page = 20
paginate_result.total = 0
paginate_result.has_next = False
paginate_result.items = []
monkeypatch.setattr(conversation_module.db, "paginate", lambda *_args, **_kwargs: paginate_result)
with app.test_request_context("/console/api/apps/app-1/chat-conversations", method="GET"):
response = method(app_model=SimpleNamespace(id="app-1", mode=AppMode.ADVANCED_CHAT))
assert response is paginate_result
assert response == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []}
def test_get_conversation_updates_read_at(monkeypatch: pytest.MonkeyPatch) -> None:

View File

@ -73,11 +73,6 @@ class TestAsyncWorkflowService:
mock_dispatcher = MagicMock()
mock_quota_service = MagicMock()
mock_get_workflow = MagicMock()
mock_professional_task = MagicMock()
mock_team_task = MagicMock()
mock_sandbox_task = MagicMock()
with (
patch.object(

14
api/uv.lock generated
View File

@ -1654,7 +1654,7 @@ requires-dist = [
{ name = "httpx-sse", specifier = "~=0.4.0" },
{ name = "json-repair", specifier = "~=0.59.2" },
{ name = "langfuse", specifier = ">=4.2.0,<5.0.0" },
{ name = "langsmith", specifier = ">=0.7.30,<1.0.0" },
{ name = "langsmith", specifier = ">=0.7.31,<1.0.0" },
{ name = "mlflow-skinny", specifier = ">=3.11.1,<4.0.0" },
{ name = "opentelemetry-distro", specifier = ">=0.62b0,<1.0.0" },
{ name = "opentelemetry-instrumentation-celery", specifier = ">=0.62b0,<1.0.0" },
@ -3767,7 +3767,7 @@ wheels = [
[[package]]
name = "langsmith"
version = "0.7.30"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@ -3780,9 +3780,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/e7/d27d952ce9824d684a3bb500a06541a2d55734bc4d849cdfcca2dfd4d93a/langsmith-0.7.30.tar.gz", hash = "sha256:d9df7ba5e42f818b63bda78776c8f2fc853388be3ae77b117e5d183a149321a2", size = 1106040, upload-time = "2026-04-09T21:12:01.892Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/19/96250cf58070c5563446651b03bb76c2eb5afbf08e754840ab639532d8c6/langsmith-0.7.30-py3-none-any.whl", hash = "sha256:43dd9f8d290e4d406606d6cc0bd62f5d1050963f05fe0ab6ffe50acf41f2f55a", size = 372682, upload-time = "2026-04-09T21:12:00.481Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@ -5501,11 +5501,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.10.0"
version = "6.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/9f/ca96abf18683ca12602065e4ed2bec9050b672c87d317f1079abc7b6d993/pypdf-6.10.0.tar.gz", hash = "sha256:4c5a48ba258c37024ec2505f7e8fd858525f5502784a2e1c8d415604af29f6ef", size = 5314833, upload-time = "2026-04-10T09:34:57.102Z" }
sdist = { url = "https://files.pythonhosted.org/packages/66/79/f2730c42ec7891a75a2fcea2eb4f356872bcbc671b711418060424796612/pypdf-6.10.1.tar.gz", hash = "sha256:62e6ca7f65aaa28b3d192addb44f97296e4be1748f57ed0f4efb2d4915841880", size = 5315704, upload-time = "2026-04-14T12:55:20.996Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/f2/7ebe366f633f30a6ad105f650f44f24f98cb1335c4157d21ae47138b3482/pypdf-6.10.0-py3-none-any.whl", hash = "sha256:90005e959e1596c6e6c84c8b0ad383285b3e17011751cedd17f2ce8fcdfc86de", size = 334459, upload-time = "2026-04-10T09:34:54.966Z" },
{ url = "https://files.pythonhosted.org/packages/f0/04/e3aa7f1f14dbc53429cae34666261eb935d99bd61d24756ab94d7e0309da/pypdf-6.10.1-py3-none-any.whl", hash = "sha256:6331940d3bfe75b7e6601d35db7adabab5fc1d716efaeb384e3c0c3957d033de", size = 335606, upload-time = "2026-04-14T12:55:18.941Z" },
]
[[package]]

692
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -105,7 +105,7 @@ catalog:
date-fns: 4.1.0
dayjs: 1.11.20
decimal.js: 10.6.0
dompurify: 3.3.3
dompurify: 3.4.0
echarts: 6.0.0
echarts-for-react: 3.0.6
elkjs: 0.11.1
@ -125,7 +125,7 @@ catalog:
fast-deep-equal: 3.1.3
happy-dom: 20.9.0
hast-util-to-jsx-runtime: 2.3.6
hono: 4.12.12
hono: 4.12.14
html-entities: 2.6.0
html-to-image: 1.11.13
i18next: 26.0.4
@ -190,10 +190,10 @@ catalog:
use-context-selector: 2.0.0
uuid: 13.0.0
vinext: 0.0.41
vite: npm:@voidzero-dev/vite-plus-core@0.1.16
vite: npm:@voidzero-dev/vite-plus-core@0.1.18
vite-plugin-inspect: 12.0.0-beta.1
vite-plus: 0.1.16
vitest: npm:@voidzero-dev/vite-plus-test@0.1.16
vite-plus: 0.1.18
vitest: npm:@voidzero-dev/vite-plus-test@0.1.18
vitest-canvas-mock: 1.1.4
zod: 4.3.6
zundo: 2.3.0
@ -223,8 +223,8 @@ 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.16
vitest: npm:@voidzero-dev/vite-plus-test@0.1.16
vite: npm:@voidzero-dev/vite-plus-core@0.1.18
vitest: npm:@voidzero-dev/vite-plus-test@0.1.18
yaml@>=2.0.0 <2.8.3: 2.8.3
yauzl@<3.2.1: 3.2.1
strictDepBuilds: true

View File

@ -258,6 +258,10 @@ const renderAppCard = (app?: Partial<App>) => {
return render(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />)
}
const openOperationsMenu = () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
}
describe('App Card Operations Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -313,32 +317,19 @@ describe('App Card Operations Flow', () => {
it('should show delete confirmation and call API on confirm', async () => {
renderAppCard({ id: 'app-to-delete', name: 'Deletable App' })
// Find and click the more button (popover trigger)
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
openOperationsMenu()
fireEvent.click(await screen.findByText('common.operation.delete'))
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
expect(screen.getByText('app.deleteAppConfirmTitle')).toBeInTheDocument()
})
await waitFor(() => {
const deleteBtn = screen.queryByText('common.operation.delete')
if (deleteBtn)
fireEvent.click(deleteBtn)
})
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Deletable App' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(screen.getByText('app.deleteAppConfirmTitle')).toBeInTheDocument()
})
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Deletable App' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockDeleteAppMutation).toHaveBeenCalledWith('app-to-delete')
})
}
await waitFor(() => {
expect(mockDeleteAppMutation).toHaveBeenCalledWith('app-to-delete')
})
})
})
@ -347,34 +338,18 @@ describe('App Card Operations Flow', () => {
it('should open edit modal and call updateAppInfo on confirm', async () => {
renderAppCard({ id: 'app-edit', name: 'Editable App' })
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
openOperationsMenu()
fireEvent.click(await screen.findByText('app.editApp'))
fireEvent.click(await screen.findByTestId('confirm-edit'))
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
const editBtn = screen.queryByText('app.editApp')
if (editBtn)
fireEvent.click(editBtn)
})
const confirmEdit = screen.queryByTestId('confirm-edit')
if (confirmEdit) {
fireEvent.click(confirmEdit)
await waitFor(() => {
expect(updateAppInfo).toHaveBeenCalledWith(
expect.objectContaining({
appID: 'app-edit',
name: 'Updated App Name',
}),
)
})
}
}
await waitFor(() => {
expect(updateAppInfo).toHaveBeenCalledWith(
expect.objectContaining({
appID: 'app-edit',
name: 'Updated App Name',
}),
)
})
})
})
@ -383,26 +358,14 @@ describe('App Card Operations Flow', () => {
it('should call exportAppConfig for completion apps', async () => {
renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' })
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
openOperationsMenu()
fireEvent.click(await screen.findByText('app.export'))
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
const exportBtn = screen.queryByText('app.export')
if (exportBtn)
fireEvent.click(exportBtn)
})
await waitFor(() => {
expect(exportAppConfig).toHaveBeenCalledWith(
expect.objectContaining({ appID: 'app-export' }),
)
})
}
await waitFor(() => {
expect(exportAppConfig).toHaveBeenCalledWith(
expect.objectContaining({ appID: 'app-export' }),
)
})
})
})
@ -422,35 +385,21 @@ describe('App Card Operations Flow', () => {
it('should show switch option for chat mode apps', async () => {
renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT })
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
openOperationsMenu()
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
expect(screen.queryByText('app.switch')).toBeInTheDocument()
})
}
await waitFor(() => {
expect(screen.queryByText('app.switch')).toBeInTheDocument()
})
})
it('should not show switch option for workflow apps', async () => {
renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' })
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
openOperationsMenu()
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
})
}
await waitFor(() => {
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
})
})
})
})

View File

@ -19,17 +19,40 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal-elem" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
vi.mock('@/app/components/base/ui/dropdown-menu', () => {
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context)
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
const { isOpen, setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-trigger"
onClick={(e) => {
onClick?.(e)
setOpen(!isOpen)
}}
>
{children}
</button>
)
},
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div data-testid="dropdown-content">{children}</div>,
}
})
vi.mock('../../base/app-icon', () => ({
default: ({ size, icon }: { size: string, icon: string }) => (
@ -128,11 +151,11 @@ describe('AppSidebarDropdown', () => {
const user = userEvent.setup()
render(<AppSidebarDropdown navigation={navigation} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('dropdown-trigger')
await user.click(trigger)
const portal = screen.getByTestId('portal-elem')
expect(portal).toHaveAttribute('data-open', 'true')
const dropdown = screen.getByTestId('dropdown-menu')
expect(dropdown).toHaveAttribute('data-open', 'true')
})
it('should render divider between app info and navigation', () => {

View File

@ -21,17 +21,40 @@ vi.mock('@/hooks/use-knowledge', () => ({
}),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal-elem" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
vi.mock('@/app/components/base/ui/dropdown-menu', () => {
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context)
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
const { isOpen, setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-trigger"
onClick={(e) => {
onClick?.(e)
setOpen(!isOpen)
}}
>
{children}
</button>
)
},
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div data-testid="dropdown-content">{children}</div>,
}
})
vi.mock('../../base/app-icon', () => ({
default: ({ size, icon }: { size: string, icon: string }) => (
@ -173,10 +196,10 @@ describe('DatasetSidebarDropdown', () => {
const user = userEvent.setup()
render(<DatasetSidebarDropdown navigation={navigation} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('dropdown-trigger')
await user.click(trigger)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-open', 'true')
})
it('should render divider', () => {

View File

@ -30,17 +30,67 @@ vi.mock('../../../base/ui/button', () => ({
),
}))
vi.mock('../../../base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal-elem" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="portal-content" className={className}>{children}</div>
),
}))
vi.mock('../../../base/ui/dropdown-menu', () => {
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context)
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({
children,
onClick,
render,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLElement>
render?: React.ReactElement
}) => {
const { isOpen, setOpen } = useDropdownMenuContext()
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
onClick?.(e)
setOpen(!isOpen)
}
if (render)
return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record<string, unknown>, children)
return <button data-testid="dropdown-trigger" onClick={handleClick}>{children}</button>
},
DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => {
const { isOpen } = useDropdownMenuContext()
if (!isOpen)
return null
return <div data-testid="dropdown-content" className={popupClassName}>{children}</div>
},
DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-item"
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
DropdownMenuSeparator: () => <hr data-testid="dropdown-separator" />,
}
})
const createOperation = (id: string, title: string, type?: 'divider'): Operation => ({
id,
@ -169,7 +219,7 @@ describe('AppOperations', () => {
render(<AppOperations gap={4} primaryOperations={ops} secondaryOperations={secondary} />)
const trigger = screen.queryByTestId('portal-trigger')
const trigger = screen.queryByTestId('dropdown-trigger')
if (trigger)
await user.click(trigger)

View File

@ -1,9 +1,15 @@
import type { JSX } from 'react'
import { RiMoreLine } from '@remixicon/react'
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { cloneElement, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/app/components/base/ui/button'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../../base/ui/dropdown-menu'
export type Operation = {
id: string
@ -33,9 +39,6 @@ const AppOperations = ({
const [moreOperations, setMoreOperations] = useState<Operation[]>([])
const [showMore, setShowMore] = useState(false)
const navRef = useRef<HTMLDivElement>(null)
const handleTriggerMore = useCallback(() => {
setShowMore(true)
}, [setShowMore])
const primaryOps = useMemo(() => {
if (operations)
@ -169,43 +172,44 @@ const AppOperations = ({
</Button>
))}
{shouldShowMoreButton && (
<PortalToFollowElem
open={showMore}
onOpenChange={setShowMore}
placement="bottom-end"
offset={{ mainAxis: 4 }}
>
<PortalToFollowElemTrigger onClick={handleTriggerMore}>
<Button
size="small"
variant="secondary"
className="gap-px"
>
<DropdownMenu open={showMore} onOpenChange={setShowMore}>
<DropdownMenuTrigger
render={(
<Button
size="small"
variant="secondary"
className="gap-px"
/>
)}
>
<>
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium text-components-button-secondary-text">
{t('operation.more', { ns: 'common' })}
</span>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-30">
<div className="flex min-w-[264px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
{moreOperations.map(item => item.type === 'divider'
? (
<div key={item.id} className="my-1 h-px bg-divider-subtle" />
)
: (
<div
key={item.id}
className="flex h-8 cursor-pointer items-center gap-x-1 rounded-lg p-1.5 hover:bg-state-base-hover"
onClick={item.onClick}
>
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
<span className="system-md-regular text-text-secondary">{item.title}</span>
</div>
))}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[264px]"
>
{moreOperations.map(item => item.type === 'divider'
? (
<DropdownMenuSeparator key={item.id} />
)
: (
<DropdownMenuItem
key={item.id}
className="gap-x-1 px-1.5"
onClick={item.onClick}
>
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
<span className="system-md-regular text-text-secondary">{item.title}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</>

View File

@ -5,14 +5,14 @@ import {
RiMenuLine,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { useAppContext } from '@/context/app-context'
import AppIcon from '../base/app-icon'
import Divider from '../base/divider'
@ -34,16 +34,7 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
const { isCurrentWorkspaceEditor } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
const [detailExpand, setDetailExpand] = useState(false)
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
const [open, setOpen] = useState(false)
if (!appDetail)
return null
@ -51,27 +42,28 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
return (
<>
<div className="fixed top-2 left-2 z-20">
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: -41,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div className={cn('flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover', open && 'bg-background-default-hover')}>
<AppIcon
size="small"
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<RiMenuLine className="h-4 w-4 text-text-tertiary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
open && 'bg-background-default-hover',
)}
>
<AppIcon
size="small"
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<RiMenuLine className="h-4 w-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className={cn('w-[305px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg')}>
<div className="p-2">
<div
@ -114,8 +106,8 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
})}
</nav>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="z-20">
<AppInfo expand onlyShowDetail openState={detailExpand} onDetailExpand={setDetailExpand} />

View File

@ -137,14 +137,6 @@ vi.mock('@/app/components/datasets/rename-modal', () => ({
},
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
describe('Dropdown callback coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -159,7 +151,7 @@ describe('Dropdown callback coverage', () => {
const user = userEvent.setup()
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('common.operation.edit'))
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
@ -175,7 +167,7 @@ describe('Dropdown callback coverage', () => {
const user = userEvent.setup()
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('common.operation.edit'))
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
@ -190,7 +182,7 @@ describe('Dropdown callback coverage', () => {
const user = userEvent.setup()
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('common.operation.delete'))
await waitFor(() => {
@ -210,7 +202,7 @@ describe('Dropdown callback coverage', () => {
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('common.operation.delete'))
await waitFor(() => {
@ -224,7 +216,7 @@ describe('Dropdown callback coverage', () => {
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
await waitFor(() => {
@ -232,6 +224,27 @@ describe('Dropdown callback coverage', () => {
})
})
it('should not attempt export when the dataset has no pipeline id', async () => {
const user = userEvent.setup()
mockDataset = createDataset({ pipeline_id: '' })
render(<Dropdown expand={false} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
expect(mockExportPipeline).not.toHaveBeenCalled()
})
it('should render and open correctly when collapsed', async () => {
const user = userEvent.setup()
render(<Dropdown expand={false} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
})
it('should surface the backend message when checking app usage fails', async () => {
const user = userEvent.setup()
mockCheckIsUsedInApp.mockRejectedValueOnce({
@ -240,7 +253,7 @@ describe('Dropdown callback coverage', () => {
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('common.operation.delete'))
await waitFor(() => {

View File

@ -1,5 +1,4 @@
import type { DataSet } from '@/models/datasets'
import { RiEditLine } from '@remixicon/react'
import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
@ -22,6 +21,7 @@ const mockInvalidDatasetDetail = vi.fn()
const mockExportPipeline = vi.fn()
const mockCheckIsUsedInApp = vi.fn()
const mockDeleteDataset = vi.fn()
const TestEditIcon = () => <span aria-hidden className="i-ri-edit-line" />
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
@ -210,7 +210,7 @@ describe('MenuItem', () => {
const user = userEvent.setup()
const handleClick = vi.fn()
// Arrange
render(<MenuItem name="Edit" Icon={RiEditLine} handleClick={handleClick} />)
render(<MenuItem name="Edit" Icon={TestEditIcon} handleClick={handleClick} />)
// Act
await user.click(screen.getByText('Edit'))
@ -225,7 +225,7 @@ describe('MenuItem', () => {
render(
<div onClick={parentClick}>
<MenuItem name="Edit" Icon={RiEditLine} handleClick={handleClick} />
<MenuItem name="Edit" Icon={TestEditIcon} handleClick={handleClick} />
</div>,
)
@ -236,7 +236,7 @@ describe('MenuItem', () => {
})
it('should not crash when no click handler is provided', () => {
render(<MenuItem name="Edit" Icon={RiEditLine} />)
render(<MenuItem name="Edit" Icon={TestEditIcon} />)
const event = createEvent.click(screen.getByText('Edit'))
fireEvent(screen.getByText('Edit'), event)

View File

@ -1,6 +1,5 @@
import type { DataSet } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { RiMoreFill } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -14,7 +13,6 @@ import { useInvalid } from '@/service/use-base'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import { downloadBlob } from '@/utils/download'
import ActionButton from '../../base/action-button'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
import {
AlertDialog,
AlertDialogActions,
@ -24,6 +22,11 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '../../base/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '../../base/ui/dropdown-menu'
import RenameDatasetModal from '../../datasets/rename-modal'
import Menu from './menu'
@ -44,10 +47,6 @@ const DropDown = ({
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const handleTrigger = useCallback(() => {
setOpen(prev => !prev)
}, [])
const invalidDatasetList = useInvalidDatasetList()
const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id])
@ -57,9 +56,11 @@ const DropDown = ({
}, [invalidDatasetDetail, invalidDatasetList])
const openRenameModal = useCallback(() => {
setShowRenameModal(true)
handleTrigger()
}, [handleTrigger])
setOpen(false)
queueMicrotask(() => {
setShowRenameModal(true)
})
}, [])
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
@ -67,7 +68,7 @@ const DropDown = ({
const { pipeline_id, name } = dataset
if (!pipeline_id)
return
handleTrigger()
setOpen(false)
try {
const { data } = await exportPipelineConfig({
pipelineId: pipeline_id,
@ -79,9 +80,10 @@ const DropDown = ({
catch {
toast(t('exportFailed', { ns: 'app' }), { type: 'error' })
}
}, [dataset, exportPipelineConfig, handleTrigger, t])
}, [dataset, exportPipelineConfig, t])
const detectIsUsedByApp = useCallback(async () => {
setOpen(false)
try {
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
setConfirmMessage(isUsedByApp ? t('datasetUsedByApp', { ns: 'dataset' })! : t('deleteDatasetConfirmContent', { ns: 'dataset' })!)
@ -91,10 +93,7 @@ const DropDown = ({
const res = await e.json()
toast(res?.message || 'Unknown error', { type: 'error' })
}
finally {
handleTrigger()
}
}, [dataset.id, handleTrigger, t])
}, [dataset.id, t])
const onConfirmDelete = useCallback(async () => {
try {
@ -109,32 +108,27 @@ const DropDown = ({
}, [dataset.id, replace, invalidDatasetList, t])
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement={expand ? 'bottom-end' : 'right'}
offset={expand
? {
mainAxis: 4,
crossAxis: 10,
}
: {
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<ActionButton className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md')}>
<RiMoreFill className="size-4" />
<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>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-60">
</DropdownMenuTrigger>
<DropdownMenuContent
placement={expand ? 'bottom-end' : 'right-start'}
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<Menu
showDelete={!isCurrentWorkspaceDatasetOperator}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>
</PortalToFollowElemContent>
</DropdownMenuContent>
{showRenameModal && (
<RenameDatasetModal
show={showRenameModal}
@ -163,7 +157,7 @@ const DropDown = ({
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</PortalToFollowElem>
</DropdownMenu>
)
}

View File

@ -5,13 +5,13 @@ import {
RiMenuLine,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useKnowledge } from '@/hooks/use-knowledge'
import { DOC_FORM_TEXT } from '@/models/datasets'
@ -41,15 +41,7 @@ const DatasetSidebarDropdown = ({
const { data: relatedApps } = useDatasetRelatedApps(dataset.id)
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
const [open, setOpen] = useState(false)
const iconInfo = dataset.icon_info || {
icon: '📙',
@ -66,32 +58,28 @@ const DatasetSidebarDropdown = ({
return (
<>
<div className="fixed top-2 left-2 z-20">
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: -41,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
open && 'bg-background-default-hover',
)}
>
<AppIcon
size="small"
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<RiMenuLine className="size-4 text-text-tertiary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
open && 'bg-background-default-hover',
)}
>
<AppIcon
size="small"
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<RiMenuLine className="size-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative w-[216px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg">
<Effect className="top-[-22px] -left-5 opacity-15" />
<div className="flex flex-col gap-y-2 p-4">
@ -155,8 +143,8 @@ const DatasetSidebarDropdown = ({
documentCount={dataset.document_count}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)

View File

@ -188,8 +188,7 @@ const renderComponent = (
}
const openOperationsPopover = async (user: ReturnType<typeof userEvent.setup>) => {
const trigger = document.querySelector('button.btn.btn-secondary') as HTMLButtonElement
expect(trigger).toBeTruthy()
const trigger = screen.getByRole('button', { name: 'common.operation.more' }) as HTMLButtonElement
await user.click(trigger)
}

View File

@ -3,21 +3,18 @@ import type { FC } from 'react'
import type { AnnotationItemBasic } from '../type'
import { Menu, MenuButton, MenuItems, Transition } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiAddLine,
RiDeleteBinLine,
RiMoreFill,
} from '@remixicon/react'
import * as React from 'react'
import { Fragment, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
useCSVDownloader,
} from 'react-papaparse'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import CustomPopover from '@/app/components/base/popover'
import { Button } from '@/app/components/base/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
@ -37,6 +34,120 @@ type Props = {
controlUpdateList: number
}
type OperationsMenuProps = {
list: AnnotationItemBasic[]
onClose: () => void
onBulkImport: () => void
onClearAll: () => void
onExportJsonl: () => void
}
const buildAnnotationJsonlRecords = (list: AnnotationItemBasic[]) => list.map(
(item: AnnotationItemBasic) => {
return `{"messages": [{"role": "system", "content": ""}, {"role": "user", "content": ${JSON.stringify(item.question)}}, {"role": "assistant", "content": ${JSON.stringify(item.answer)}}]}`
},
)
const downloadAnnotationJsonl = (list: AnnotationItemBasic[], locale: string) => {
const content = buildAnnotationJsonlRecords(list).join('\n')
const file = new Blob([content], { type: 'application/jsonl' })
downloadBlob({ data: file, fileName: `annotations-${locale}.jsonl` })
}
const OperationsMenu: FC<OperationsMenuProps> = ({
list,
onClose,
onBulkImport,
onClearAll,
onExportJsonl,
}) => {
const { t } = useTranslation()
const locale = useLocale()
const { CSVDownloader, Type } = useCSVDownloader()
const annotationUnavailable = list.length === 0
return (
<div className="w-full py-1">
<button
type="button"
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50"
onClick={() => {
onClose()
onBulkImport()
}}
>
<span aria-hidden className="i-custom-vender-line-files-file-plus-02 h-4 w-4 text-text-tertiary" />
<span className="grow text-left system-sm-regular text-text-secondary">{t('table.header.bulkImport', { ns: 'appAnnotation' })}</span>
</button>
<Menu as="div" className="relative h-full w-full">
<MenuButton className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50">
<span aria-hidden className="i-custom-vender-line-files-file-download-02 h-4 w-4 text-text-tertiary" />
<span className="grow text-left system-sm-regular text-text-secondary">{t('table.header.bulkExport', { ns: 'appAnnotation' })}</span>
<span aria-hidden className="i-custom-vender-line-arrows-chevron-right h-[14px] w-[14px] shrink-0 text-text-tertiary" />
</MenuButton>
<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={cn(
'absolute top-px left-1 z-10 min-w-[100px] origin-top-right -translate-x-full rounded-xl border-[0.5px] border-components-panel-on-panel-item-bg bg-components-panel-bg py-1 shadow-xs',
)}
>
<CSVDownloader
type={Type.Link}
filename={`annotations-${locale}`}
bom={true}
data={[
locale !== LanguagesSupported[1] ? CSV_HEADER_QA_EN : CSV_HEADER_QA_CN,
...list.map(item => [item.question, item.answer]),
]}
>
<button
type="button"
disabled={annotationUnavailable}
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50"
onClick={onClose}
>
<span className="grow text-left system-sm-regular text-text-secondary">CSV</span>
</button>
</CSVDownloader>
<button
type="button"
disabled={annotationUnavailable}
className={cn('mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50', 'border-0!')}
onClick={() => {
onClose()
onExportJsonl()
}}
>
<span className="grow text-left system-sm-regular text-text-secondary">JSONL</span>
</button>
</MenuItems>
</Transition>
</Menu>
<button
type="button"
onClick={() => {
onClose()
onClearAll()
}}
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 text-red-600 hover:bg-red-50 disabled:opacity-50"
>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4" />
<span className="grow text-left system-sm-regular">
{t('table.header.clearAll', { ns: 'appAnnotation' })}
</span>
</button>
</div>
)
}
const HeaderOptions: FC<Props> = ({
appId,
onAdd,
@ -45,22 +156,7 @@ const HeaderOptions: FC<Props> = ({
}) => {
const { t } = useTranslation()
const locale = useLocale()
const { CSVDownloader, Type } = useCSVDownloader()
const [list, setList] = useState<AnnotationItemBasic[]>([])
const annotationUnavailable = list.length === 0
const listTransformer = (list: AnnotationItemBasic[]) => list.map(
(item: AnnotationItemBasic) => {
const dataString = `{"messages": [{"role": "system", "content": ""}, {"role": "user", "content": ${JSON.stringify(item.question)}}, {"role": "assistant", "content": ${JSON.stringify(item.answer)}}]}`
return dataString
},
)
const JSONLOutput = () => {
const content = listTransformer(list).join('\n')
const file = new Blob([content], { type: 'application/jsonl' })
downloadBlob({ data: file, fileName: `annotations-${locale}.jsonl` })
}
const fetchList = React.useCallback(async () => {
const { data }: any = await fetchExportAnnotationList(appId)
@ -77,9 +173,16 @@ const HeaderOptions: FC<Props> = ({
const [showBulkImportModal, setShowBulkImportModal] = useState(false)
const [showClearConfirm, setShowClearConfirm] = useState(false)
const handleClearAll = () => {
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
const handleShowBulkImportModal = React.useCallback(() => {
setShowBulkImportModal(true)
}, [])
const handleClearAll = React.useCallback(() => {
setShowClearConfirm(true)
}
}, [])
const handleExportJsonl = React.useCallback(() => {
downloadAnnotationJsonl(list, locale)
}, [list, locale])
const handleConfirmed = async () => {
try {
await clearAllAnnotations(appId)
@ -92,92 +195,36 @@ const HeaderOptions: FC<Props> = ({
setShowClearConfirm(false)
}
}
const Operations = () => {
return (
<div className="w-full py-1">
<button
type="button"
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50"
onClick={() => {
setShowBulkImportModal(true)
}}
>
<FilePlus02 className="h-4 w-4 text-text-tertiary" />
<span className="grow text-left system-sm-regular text-text-secondary">{t('table.header.bulkImport', { ns: 'appAnnotation' })}</span>
</button>
<Menu as="div" className="relative h-full w-full">
<MenuButton className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50">
<FileDownload02 className="h-4 w-4 text-text-tertiary" />
<span className="grow text-left system-sm-regular text-text-secondary">{t('table.header.bulkExport', { ns: 'appAnnotation' })}</span>
<ChevronRight className="h-[14px] w-[14px] shrink-0 text-text-tertiary" />
</MenuButton>
<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={cn(
'absolute top-px left-1 z-10 min-w-[100px] origin-top-right -translate-x-full rounded-xl border-[0.5px] border-components-panel-on-panel-item-bg bg-components-panel-bg py-1 shadow-xs',
)}
>
<CSVDownloader
type={Type.Link}
filename={`annotations-${locale}`}
bom={true}
data={[
locale !== LanguagesSupported[1] ? CSV_HEADER_QA_EN : CSV_HEADER_QA_CN,
...list.map(item => [item.question, item.answer]),
]}
>
<button type="button" disabled={annotationUnavailable} className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50">
<span className="grow text-left system-sm-regular text-text-secondary">CSV</span>
</button>
</CSVDownloader>
<button type="button" disabled={annotationUnavailable} className={cn('mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50', 'border-0!')} onClick={JSONLOutput}>
<span className="grow text-left system-sm-regular text-text-secondary">JSONL</span>
</button>
</MenuItems>
</Transition>
</Menu>
<button
type="button"
onClick={handleClearAll}
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 text-red-600 hover:bg-red-50 disabled:opacity-50"
>
<RiDeleteBinLine className="h-4 w-4" />
<span className="grow text-left system-sm-regular">
{t('table.header.clearAll', { ns: 'appAnnotation' })}
</span>
</button>
</div>
)
}
const [showAddModal, setShowAddModal] = React.useState(false)
return (
<div className="flex space-x-2">
<Button variant="primary" onClick={() => setShowAddModal(true)}>
<RiAddLine className="mr-0.5 h-4 w-4" />
<span aria-hidden className="mr-0.5 i-ri-add-line h-4 w-4" />
<div>{t('table.header.addAnnotation', { ns: 'appAnnotation' })}</div>
</Button>
<CustomPopover
htmlContent={<Operations />}
position="br"
trigger="click"
btnElement={
<RiMoreFill className="h-4 w-4" />
}
btnClassName="btn btn-secondary btn-medium w-8 p-0"
className="z-20! h-fit w-[155px]!"
popupClassName="w-full! overflow-visible!"
manualClose
/>
<DropdownMenu open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className="mr-0 box-border inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-0 text-components-button-secondary-text shadow-xs backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover data-popup-open:border-components-button-secondary-border-hover data-popup-open:bg-components-button-secondary-bg-hover"
>
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[155px] overflow-visible py-0"
>
<OperationsMenu
list={list}
onClose={() => setIsOperationsMenuOpen(false)}
onBulkImport={handleShowBulkImportModal}
onClearAll={handleClearAll}
onExportJsonl={handleExportJsonl}
/>
</DropdownMenuContent>
</DropdownMenu>
{showAddModal && (
<AddAnnotationModal
isShow={showAddModal}

View File

@ -8,6 +8,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/app/components/base/ui/avatar'
import { Button } from '@/app/components/base/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
import { useSelector } from '@/context/app-context'
import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
@ -15,7 +16,6 @@ import useAccessControlStore from '../../../../context/access-control-store'
import Checkbox from '../../base/checkbox'
import Input from '../../base/input'
import Loading from '../../base/loading'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
export default function AddMemberOrGroupDialog() {
const { t } = useTranslation()
@ -45,15 +45,21 @@ export default function AddMemberOrGroupDialog() {
}, [isLoading, fetchNextPage, anchorRef, data])
return (
<PortalToFollowElem open={open} onOpenChange={setOpen} offset={{ crossAxis: 300 }} placement="bottom-end">
<PortalToFollowElemTrigger asChild>
<Button variant="ghost-accent" size="small" className="flex shrink-0 items-center gap-x-0.5" onClick={() => setOpen(!open)}>
<RiAddCircleFill className="h-4 w-4" />
<span>{t('operation.add', { ns: 'common' })}</span>
</Button>
</PortalToFollowElemTrigger>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button variant="ghost-accent" size="small" className="flex shrink-0 items-center gap-x-0.5">
<RiAddCircleFill className="h-4 w-4" />
<span>{t('operation.add', { ns: 'common' })}</span>
</Button>
)}
/>
{open && <FloatingOverlay />}
<PortalToFollowElemContent className="z-100">
<PopoverContent
placement="bottom-end"
alignOffset={300}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="relative flex max-h-[400px] w-[400px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
<Input value={keyword} onChange={handleKeywordChange} showLeftIcon placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' }) as string} />
@ -81,8 +87,8 @@ export default function AddMemberOrGroupDialog() {
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -22,24 +22,57 @@ vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({
default: ({ modelName }: { modelName: string }) => <span data-testid="model-icon">{modelName}</span>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
const ReactModule = await vi.importActual<typeof import('react')>('react')
const OpenContext = ReactModule.createContext(false)
const OpenContext = ReactModule.createContext<{ open: boolean, setOpen: (nextOpen: boolean) => void } | null>(null)
const useOpenContext = () => {
const context = ReactModule.use(OpenContext)
if (!context)
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext.Provider value={open}>
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
<OpenContext.Provider value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="portal-root">{children}</div>
</OpenContext.Provider>
),
PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => (
<div className={className} onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
const open = ReactModule.useContext(OpenContext)
return open ? <div className={className}>{children}</div> : null
DropdownMenuTrigger: ({
children,
render,
}: {
children: React.ReactNode
render?: React.ReactElement
}) => {
const { open, setOpen } = useOpenContext()
if (render) {
return ReactModule.cloneElement(render, {
onClick: () => setOpen(!open),
} as Record<string, unknown>, children)
}
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
},
DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => {
const context = useOpenContext()
return context.open ? <div className={popupClassName}>{children}</div> : null
},
DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
const { setOpen } = useOpenContext()
return (
<button
type="button"
onClick={(event) => {
onClick?.(event)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})

View File

@ -17,12 +17,8 @@ import { useTranslation } from 'react-i18next'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { Button } from '@/app/components/base/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { WorkflowContext } from '@/app/components/workflow/context'
@ -36,9 +32,9 @@ import { fetchAppDetailDirect } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { useConvertWorkflowTypeMutation } from '@/service/use-apps'
import { useEvaluationWorkflowAssociatedTargets } from '@/service/use-evaluation'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
import { fetchPublishedWorkflow } from '@/service/workflow'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import { toast } from '../../base/ui/toast'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
@ -241,20 +237,18 @@ const AppPublisher = ({
catch { }
}, [onRestore])
const handleTrigger = useCallback(() => {
const state = !open
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (disabled) {
setOpen(false)
return
}
onToggle?.(state)
setOpen(state)
onToggle?.(nextOpen)
setOpen(nextOpen)
if (state)
if (nextOpen)
setPublished(false)
}, [disabled, onToggle, open])
}, [disabled, onToggle])
const handleOpenInExplore = useCallback(async () => {
await openAsyncWindow(async () => {
@ -403,26 +397,28 @@ const AppPublisher = ({
return (
<>
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: crossAxisOffset,
}}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<Button
variant="primary"
className="py-2 pr-2 pl-3"
disabled={disabled}
>
{t('common.publish', { ns: 'workflow' })}
<span className="i-ri-arrow-down-s-line h-4 w-4 text-components-button-primary-text" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<PopoverTrigger
render={(
<Button
variant="primary"
className="py-2 pr-2 pl-3"
disabled={disabled}
>
{t('common.publish', { ns: 'workflow' })}
<span className="i-ri-arrow-down-s-line h-4 w-4 text-components-button-primary-text" />
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
alignOffset={crossAxisOffset}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
<PublisherSummarySection
debugWithMultipleModel={debugWithMultipleModel}
@ -450,7 +446,10 @@ const AppPublisher = ({
isAppAccessSet={isAppAccessSet}
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
accessMode={appDetail?.access_mode}
onClick={() => setShowAppAccessControl(true)}
onClick={() => {
handleOpenChange(false)
setShowAppAccessControl(true)
}}
/>
<PublisherActionsSection
appDetail={appDetail}
@ -459,9 +458,12 @@ const AppPublisher = ({
disabledFunctionTooltip={disabledFunctionTooltip}
handleEmbed={() => {
setEmbeddingModalOpen(true)
handleTrigger()
handleOpenChange(false)
}}
handleOpenInExplore={() => {
handleOpenChange(false)
handleOpenInExplore()
}}
handleOpenInExplore={handleOpenInExplore}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
@ -478,7 +480,7 @@ const AppPublisher = ({
</>
)}
</div>
</PortalToFollowElemContent>
</PopoverContent>
<EmbeddedModal
siteInfo={appDetail?.site}
isShow={embeddingModalOpen}
@ -487,7 +489,7 @@ const AppPublisher = ({
accessToken={accessToken}
/>
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
</PortalToFollowElem>
</Popover>
<EvaluationWorkflowSwitchConfirmDialog
open={showEvaluationWorkflowSwitchConfirm}
targets={evaluationWorkflowSwitchTargets}

View File

@ -4,12 +4,13 @@ import type { Model, ModelItem } from '@/app/components/header/account-setting/m
import { RiArrowDownSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { Button } from '@/app/components/base/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useProviderContext } from '@/context/provider-context'
import ModelIcon from '../../header/account-setting/model-provider-page/model-icon'
@ -50,61 +51,57 @@ const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({
}
})
const handleToggle = () => {
if (validModelConfigs.length)
setOpen(v => !v)
}
const handleSelect = (item: ModelAndParameter) => {
onSelect(item)
setOpen(false)
}
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-end"
>
<PortalToFollowElemTrigger className="w-full" onClick={handleToggle}>
<Button
variant="primary"
disabled={!validModelConfigs.length}
className="mt-3 w-full"
>
<DropdownMenuTrigger
disabled={!validModelConfigs.length}
render={(
<Button
variant="primary"
disabled={!validModelConfigs.length}
className="mt-3 w-full"
/>
)}
>
<>
{t('operation.applyConfig', { ns: 'appDebug' })}
<RiArrowDownSLine className="ml-0.5 h-3 w-3" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50 mt-1 w-[288px]">
<div className="rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg">
<div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary">
{t('publishAs', { ns: 'appDebug' })}
</div>
{
validModelConfigs.map((item, index) => (
<div
key={item.id}
className="flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-tertiary hover:bg-state-base-hover"
onClick={() => handleSelect(item)}
>
<span className="min-w-[18px] italic">
#
{index + 1}
</span>
<ModelIcon modelName={item.model} provider={item.providerItem} className="ml-2" />
<div
className="ml-1 truncate text-text-secondary"
title={item.modelItem.label[language]}
>
{item.modelItem.label[language]}
</div>
</div>
))
}
</>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[288px] p-1"
>
<div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary">
{t('publishAs', { ns: 'appDebug' })}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{
validModelConfigs.map((item, index) => (
<DropdownMenuItem
key={item.id}
className="gap-0 px-3"
onClick={() => onSelect(item)}
>
<span className="min-w-[18px] italic">
#
{index + 1}
</span>
<ModelIcon modelName={item.model} provider={item.providerItem} className="ml-2" />
<div
className="ml-1 truncate text-text-secondary"
title={item.modelItem.label[language]}
>
{item.modelItem.label[language]}
</div>
</DropdownMenuItem>
))
}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -44,18 +44,25 @@ vi.mock('@/app/components/base/select', () => ({
),
}))
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => (
<div>
<button type="button" onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')}>{`ui-select:${value}`}</button>
{children}
</div>
),
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <span>select-value</span>,
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/base/ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/select')>()
return {
...actual,
Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => (
<div>
<button type="button" onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')}>{`ui-select:${value}`}</button>
{children}
</div>
),
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <span>select-value</span>,
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItemText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => <span data-testid="select-item-indicator" />,
}
})
vi.mock('../field', () => ({
default: ({ children, title }: { children: ReactNode, title: string }) => (

View File

@ -12,6 +12,8 @@ import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@/app/components/base/ui/select'
@ -138,8 +140,14 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
<SelectValue placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })} />
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
<SelectItem value={CHECKBOX_DEFAULT_TRUE_VALUE}>{t('variableConfig.startChecked', { ns: 'appDebug' })}</SelectItem>
<SelectItem value={CHECKBOX_DEFAULT_FALSE_VALUE}>{t('variableConfig.noDefaultSelected', { ns: 'appDebug' })}</SelectItem>
<SelectItem value={CHECKBOX_DEFAULT_TRUE_VALUE}>
<SelectItemText>{t('variableConfig.startChecked', { ns: 'appDebug' })}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value={CHECKBOX_DEFAULT_FALSE_VALUE}>
<SelectItemText>{t('variableConfig.noDefaultSelected', { ns: 'appDebug' })}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</Field>
@ -161,9 +169,15 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
<SelectValue placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })} />
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
<SelectItem value={EMPTY_SELECT_VALUE}>{t('variableConfig.noDefaultValue', { ns: 'appDebug' })}</SelectItem>
<SelectItem value={EMPTY_SELECT_VALUE}>
<SelectItemText>{t('variableConfig.noDefaultValue', { ns: 'appDebug' })}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
{options.filter(option => option.trim() !== '').map(option => (
<SelectItem key={option} value={option}>{option}</SelectItem>
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>

View File

@ -4,12 +4,8 @@ import { cn } from '@langgenius/dify-ui/cn'
import { RiSettings2Line } from '@remixicon/react'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { Button } from '@/app/components/base/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
import ParamConfigContent from './param-config-content'
const ParamsConfig: FC = () => {
@ -17,26 +13,28 @@ const ParamsConfig: FC = () => {
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Button variant="ghost" size="small" className={cn('')}>
<RiSettings2Line className="h-3.5 w-3.5" />
<div className="ml-1">{t('voice.settings', { ns: 'appDebug' })}</div>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 50 }}>
<PopoverTrigger
render={(
<Button variant="ghost" size="small" className={cn('')}>
<RiSettings2Line className="h-3.5 w-3.5" />
<div className="ml-1">{t('voice.settings', { ns: 'appDebug' })}</div>
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="w-80 space-y-3 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-lg sm:w-[412px]">
<ParamConfigContent />
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
export default memo(ParamsConfig)

View File

@ -1,7 +1,7 @@
import type { CSSProperties } from 'react'
import type { ModelAndParameter } from '../../types'
import type { Item } from '@/app/components/base/dropdown'
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { AppModeEnum } from '@/types/app'
import DebugItem from '../debug-item'
@ -10,12 +10,6 @@ const mockUseDebugConfigurationContext = vi.fn()
const mockUseDebugWithMultipleModelContext = vi.fn()
const mockUseProviderContext = vi.fn()
let capturedDropdownProps: {
onSelect: (item: Item) => void
items: Item[]
secondItems?: Item[]
} | null = null
let capturedModelParameterTriggerProps: {
modelAndParameter: ModelAndParameter
} | null = null
@ -51,34 +45,6 @@ vi.mock('../model-parameter-trigger', () => ({
},
}))
vi.mock('@/app/components/base/dropdown', () => ({
default: (props: { onSelect: (item: Item) => void, items: Item[], secondItems?: Item[] }) => {
capturedDropdownProps = props
return (
<div data-testid="dropdown">
{props.items.map(item => (
<button
key={item.value}
data-testid={`dropdown-item-${item.value}`}
onClick={() => props.onSelect(item)}
>
{item.text}
</button>
))}
{props.secondItems?.map(item => (
<button
key={item.value}
data-testid={`dropdown-second-item-${item.value}`}
onClick={() => props.onSelect(item)}
>
{item.text}
</button>
))}
</div>
)
},
}))
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
id: 'model-1',
model: 'gpt-3.5-turbo',
@ -117,7 +83,6 @@ const renderComponent = (props: Partial<DebugItemProps> = {}) => {
describe('DebugItem', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedDropdownProps = null
capturedModelParameterTriggerProps = null
mockUseDebugConfigurationContext.mockReturnValue({
@ -137,12 +102,18 @@ describe('DebugItem', () => {
})
})
const openMenu = async () => {
const user = userEvent.setup()
await user.click(screen.getByRole('button'))
return user
}
describe('rendering', () => {
it('should render with basic props', () => {
renderComponent()
expect(screen.getByTestId('model-parameter-trigger')).toBeInTheDocument()
expect(screen.getByTestId('dropdown')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should display correct index number', () => {
@ -280,7 +251,7 @@ describe('DebugItem', () => {
})
describe('dropdown menu', () => {
it('should show duplicate option when less than 4 models', () => {
it('should show duplicate option when less than 4 models', async () => {
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [createModelAndParameter()],
onMultipleModelConfigsChange: vi.fn(),
@ -288,13 +259,12 @@ describe('DebugItem', () => {
})
renderComponent()
await openMenu()
expect(capturedDropdownProps?.items).toContainEqual(
expect.objectContaining({ value: 'duplicate' }),
)
expect(screen.getByText('appDebug.duplicateModel')).toBeInTheDocument()
})
it('should hide duplicate option when 4 or more models', () => {
it('should hide duplicate option when 4 or more models', async () => {
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [
createModelAndParameter({ id: '1' }),
@ -307,52 +277,48 @@ describe('DebugItem', () => {
})
renderComponent()
await openMenu()
expect(capturedDropdownProps?.items).not.toContainEqual(
expect.objectContaining({ value: 'duplicate' }),
)
expect(screen.queryByText('appDebug.duplicateModel')).not.toBeInTheDocument()
})
it('should show debug-as-single-model option when provider and model are set', () => {
it('should show debug-as-single-model option when provider and model are set', async () => {
renderComponent({
modelAndParameter: createModelAndParameter({
provider: 'openai',
model: 'gpt-3.5-turbo',
}),
})
await openMenu()
expect(capturedDropdownProps?.items).toContainEqual(
expect.objectContaining({ value: 'debug-as-single-model' }),
)
expect(screen.getByText('appDebug.debugAsSingleModel')).toBeInTheDocument()
})
it('should hide debug-as-single-model option when provider is missing', () => {
it('should hide debug-as-single-model option when provider is missing', async () => {
renderComponent({
modelAndParameter: createModelAndParameter({
provider: '',
model: 'gpt-3.5-turbo',
}),
})
await openMenu()
expect(capturedDropdownProps?.items).not.toContainEqual(
expect.objectContaining({ value: 'debug-as-single-model' }),
)
expect(screen.queryByText('appDebug.debugAsSingleModel')).not.toBeInTheDocument()
})
it('should hide debug-as-single-model option when model is missing', () => {
it('should hide debug-as-single-model option when model is missing', async () => {
renderComponent({
modelAndParameter: createModelAndParameter({
provider: 'openai',
model: '',
}),
})
await openMenu()
expect(capturedDropdownProps?.items).not.toContainEqual(
expect.objectContaining({ value: 'debug-as-single-model' }),
)
expect(screen.queryByText('appDebug.debugAsSingleModel')).not.toBeInTheDocument()
})
it('should show remove option in secondItems when more than 2 models', () => {
it('should show remove option in secondItems when more than 2 models', async () => {
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [
createModelAndParameter({ id: '1' }),
@ -364,13 +330,12 @@ describe('DebugItem', () => {
})
renderComponent()
await openMenu()
expect(capturedDropdownProps?.secondItems).toContainEqual(
expect.objectContaining({ value: 'remove' }),
)
expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
})
it('should not show remove option when 2 or fewer models', () => {
it('should not show remove option when 2 or fewer models', async () => {
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [
createModelAndParameter({ id: '1' }),
@ -381,13 +346,14 @@ describe('DebugItem', () => {
})
renderComponent()
await openMenu()
expect(capturedDropdownProps?.secondItems).toBeUndefined()
expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument()
})
})
describe('dropdown actions', () => {
it('should duplicate model when duplicate is selected', () => {
it('should duplicate model when duplicate is selected', async () => {
const onMultipleModelConfigsChange = vi.fn()
const originalModel = createModelAndParameter({ id: 'original' })
@ -399,7 +365,8 @@ describe('DebugItem', () => {
renderComponent({ modelAndParameter: originalModel })
fireEvent.click(screen.getByTestId('dropdown-item-duplicate'))
const user = await openMenu()
await user.click(screen.getByText('appDebug.duplicateModel'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
@ -414,7 +381,7 @@ describe('DebugItem', () => {
)
})
it('should not duplicate when already at 4 models', () => {
it('should not duplicate when already at 4 models', async () => {
const onMultipleModelConfigsChange = vi.fn()
const models = [
createModelAndParameter({ id: '1' }),
@ -430,14 +397,13 @@ describe('DebugItem', () => {
})
renderComponent({ modelAndParameter: models[0] })
// Since duplicate is not shown when >= 4 models, we need to manually call handleSelect
capturedDropdownProps?.onSelect({ value: 'duplicate', text: 'Duplicate' })
await openMenu()
expect(onMultipleModelConfigsChange).not.toHaveBeenCalled()
expect(screen.queryByText('appDebug.duplicateModel')).not.toBeInTheDocument()
})
it('should call onDebugWithMultipleModelChange when debug-as-single-model is selected', () => {
it('should call onDebugWithMultipleModelChange when debug-as-single-model is selected', async () => {
const onDebugWithMultipleModelChange = vi.fn()
const modelAndParameter = createModelAndParameter()
@ -449,12 +415,13 @@ describe('DebugItem', () => {
renderComponent({ modelAndParameter })
fireEvent.click(screen.getByTestId('dropdown-item-debug-as-single-model'))
const user = await openMenu()
await user.click(screen.getByText('appDebug.debugAsSingleModel'))
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter)
})
it('should remove model when remove is selected', () => {
it('should remove model when remove is selected', async () => {
const onMultipleModelConfigsChange = vi.fn()
const models = [
createModelAndParameter({ id: '1' }),
@ -470,7 +437,8 @@ describe('DebugItem', () => {
renderComponent({ modelAndParameter: models[1] })
fireEvent.click(screen.getByTestId('dropdown-second-item-remove'))
const user = await openMenu()
await user.click(screen.getByText('common.operation.remove'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
@ -478,7 +446,7 @@ describe('DebugItem', () => {
)
})
it('should insert duplicated model at correct position', () => {
it('should insert duplicated model at correct position', async () => {
const onMultipleModelConfigsChange = vi.fn()
const models = [
createModelAndParameter({ id: '1' }),
@ -495,7 +463,8 @@ describe('DebugItem', () => {
// Duplicate the second model
renderComponent({ modelAndParameter: models[1] })
fireEvent.click(screen.getByTestId('dropdown-item-duplicate'))
const user = await openMenu()
await user.click(screen.getByText('appDebug.duplicateModel'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,

View File

@ -1,9 +1,15 @@
import type { CSSProperties, FC } from 'react'
import type { ModelAndParameter } from '../types'
import type { Item } from '@/app/components/base/dropdown'
import { memo } from 'react'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Dropdown from '@/app/components/base/dropdown'
import ActionButton from '@/app/components/base/action-button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useProviderContext } from '@/context/provider-context'
@ -35,34 +41,43 @@ const DebugItem: FC<DebugItemProps> = ({
const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id)
const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider)
const currentModel = currentProvider?.models.find(item => item.model === modelAndParameter.model)
const [open, setOpen] = useState(false)
const handleSelect = (item: Item) => {
if (item.value === 'duplicate') {
if (multipleModelConfigs.length >= 4)
return
const handleDuplicate = () => {
setOpen(false)
if (multipleModelConfigs.length >= 4)
return
onMultipleModelConfigsChange(
true,
[
...multipleModelConfigs.slice(0, index + 1),
{
...modelAndParameter,
id: `${Date.now()}`,
},
...multipleModelConfigs.slice(index + 1),
],
)
}
if (item.value === 'debug-as-single-model')
onDebugWithMultipleModelChange(modelAndParameter)
if (item.value === 'remove') {
onMultipleModelConfigsChange(
true,
multipleModelConfigs.filter(item => item.id !== modelAndParameter.id),
)
}
onMultipleModelConfigsChange(
true,
[
...multipleModelConfigs.slice(0, index + 1),
{
...modelAndParameter,
id: `${Date.now()}`,
},
...multipleModelConfigs.slice(index + 1),
],
)
}
const handleDebugAsSingleModel = () => {
setOpen(false)
onDebugWithMultipleModelChange(modelAndParameter)
}
const handleRemove = () => {
setOpen(false)
onMultipleModelConfigsChange(
true,
multipleModelConfigs.filter(item => item.id !== modelAndParameter.id),
)
}
const showDuplicate = multipleModelConfigs.length <= 3
const showDebugAsSingleModel = !!(modelAndParameter.provider && modelAndParameter.model)
const showRemove = multipleModelConfigs.length > 2
return (
<div
className={`flex min-w-[320px] flex-col rounded-xl bg-background-section-burn ${className}`}
@ -76,41 +91,37 @@ const DebugItem: FC<DebugItemProps> = ({
<ModelParameterTrigger
modelAndParameter={modelAndParameter}
/>
<Dropdown
onSelect={handleSelect}
items={[
...(
multipleModelConfigs.length <= 3
? [
{
value: 'duplicate',
text: t('duplicateModel', { ns: 'appDebug' }),
},
]
: []
),
...(
(modelAndParameter.provider && modelAndParameter.model)
? [
{
value: 'debug-as-single-model',
text: t('debugAsSingleModel', { ns: 'appDebug' }),
},
]
: []
),
]}
secondItems={
multipleModelConfigs.length > 2
? [
{
value: 'remove',
text: t('operation.remove', { ns: 'common' }) as string,
},
]
: undefined
}
/>
<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>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[160px]"
>
{showDuplicate && (
<DropdownMenuItem className="system-md-regular" onClick={handleDuplicate}>
{t('duplicateModel', { ns: 'appDebug' })}
</DropdownMenuItem>
)}
{showDebugAsSingleModel && (
<DropdownMenuItem className="system-md-regular" onClick={handleDebugAsSingleModel}>
{t('debugAsSingleModel', { ns: 'appDebug' })}
</DropdownMenuItem>
)}
{showRemove && (
<>
{(showDuplicate || showDebugAsSingleModel) && <DropdownMenuSeparator />}
<DropdownMenuItem destructive className="system-md-regular" onClick={handleRemove}>
{t('operation.remove', { ns: 'common' })}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div style={{ height: 'calc(100% - 40px)' }}>
{

View File

@ -11,7 +11,7 @@ import FormGeneration from '@/app/components/base/features/new-feature-panel/mod
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import { Button } from '@/app/components/base/ui/button'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { toast } from '@/app/components/base/ui/toast'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { useDocLink, useLocale } from '@/context/i18n'
@ -129,7 +129,8 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
<SelectContent popupClassName="w-[354px]">
{providers.map(option => (
<SelectItem key={option.key} value={option.key}>
{option.name}
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>

View File

@ -34,17 +34,41 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-name'
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal-elem" data-open={open ? 'true' : 'false'}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
vi.mock('@/app/components/base/ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext<{ open: boolean, onOpenChange?: (open: boolean) => void } | null>(null)
return {
Popover: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
<PopoverContext.Provider value={{ open, onOpenChange }}>
<div data-testid="popover-root" data-open={open ? 'true' : 'false'}>
{children}
</div>
</PopoverContext.Provider>
),
PopoverTrigger: ({ children, render }: { children?: React.ReactNode, render?: React.ReactNode }) => {
const context = React.useContext(PopoverContext)
const content = render ?? children
const handleClick = () => {
context?.onOpenChange?.(!context.open)
}
if (React.isValidElement(content)) {
const element = content as React.ReactElement<{ onClick?: () => void }>
return React.cloneElement(element, { onClick: handleClick })
}
return <button type="button" data-testid="popover-trigger" onClick={handleClick}>{content}</button>
},
PopoverContent: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(PopoverContext)
if (!context?.open)
return null
return <div data-testid="popover-content">{children}</div>
},
}
})
describe('ModelInfo', () => {
const defaultModel = {
@ -92,42 +116,46 @@ describe('ModelInfo', () => {
it('should be closed by default', () => {
render(<ModelInfo model={defaultModel} />)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'false')
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
it('should open when info button is clicked', () => {
render(<ModelInfo model={defaultModel} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByRole('button')
fireEvent.click(trigger)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
})
it('should close when info button is clicked again', () => {
render(<ModelInfo model={defaultModel} />)
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByRole('button')
// Open
fireEvent.click(trigger)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true')
// Close
fireEvent.click(trigger)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'false')
})
})
describe('Model Parameters Display', () => {
it('should render model params header', () => {
render(<ModelInfo model={defaultModel} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('detail.modelParams')).toBeInTheDocument()
})
it('should render temperature parameter', () => {
render(<ModelInfo model={defaultModel} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('Temperature')).toBeInTheDocument()
expect(screen.getByText('0.7')).toBeInTheDocument()
@ -135,6 +163,7 @@ describe('ModelInfo', () => {
it('should render top_p parameter', () => {
render(<ModelInfo model={defaultModel} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('Top P')).toBeInTheDocument()
expect(screen.getByText('0.9')).toBeInTheDocument()
@ -142,6 +171,7 @@ describe('ModelInfo', () => {
it('should render presence_penalty parameter', () => {
render(<ModelInfo model={defaultModel} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('Presence Penalty')).toBeInTheDocument()
expect(screen.getByText('0.1')).toBeInTheDocument()
@ -149,6 +179,7 @@ describe('ModelInfo', () => {
it('should render max_tokens parameter', () => {
render(<ModelInfo model={defaultModel} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('Max Token')).toBeInTheDocument()
expect(screen.getByText('2048')).toBeInTheDocument()
@ -156,6 +187,7 @@ describe('ModelInfo', () => {
it('should render stop parameter as comma-separated values', () => {
render(<ModelInfo model={defaultModel} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('Stop')).toBeInTheDocument()
expect(screen.getByText('END')).toBeInTheDocument()
@ -171,6 +203,7 @@ describe('ModelInfo', () => {
}
render(<ModelInfo model={modelWithNoParams} />)
fireEvent.click(screen.getByRole('button'))
const dashes = screen.getAllByText('-')
expect(dashes.length).toBeGreaterThan(0)
@ -186,6 +219,7 @@ describe('ModelInfo', () => {
}
render(<ModelInfo model={modelWithInvalidStop} />)
fireEvent.click(screen.getByRole('button'))
const stopValues = screen.getAllByText('-')
expect(stopValues.length).toBeGreaterThan(0)
@ -201,6 +235,7 @@ describe('ModelInfo', () => {
}
render(<ModelInfo model={modelWithMultipleStops} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('END,STOP,DONE')).toBeInTheDocument()
})

View File

@ -6,11 +6,7 @@ import {
} from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name'
@ -68,26 +64,29 @@ const ModelInfo: FC<Props> = ({
showMode
/>
</div>
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<div className={cn(
'cursor-pointer rounded-r-lg bg-components-button-tertiary-bg p-2 hover:bg-components-button-tertiary-bg-hover',
open && 'bg-components-button-tertiary-bg-hover',
<PopoverTrigger
render={(
<button type="button" className="block border-none bg-transparent p-0">
<div className={cn(
'cursor-pointer rounded-r-lg bg-components-button-tertiary-bg p-2 hover:bg-components-button-tertiary-bg-hover',
open && 'bg-components-button-tertiary-bg-hover',
)}
>
<RiInformation2Line className="h-4 w-4 text-text-tertiary" />
</div>
</button>
)}
>
<RiInformation2Line className="h-4 w-4 text-text-tertiary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="relative w-[280px] overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-4 pt-3 pb-2 shadow-xl">
<div className="mb-1 h-6 system-sm-semibold-uppercase text-text-secondary">{t('detail.modelParams', { ns: 'appLog' })}</div>
<div className="py-1">
@ -101,9 +100,9 @@ const ModelInfo: FC<Props> = ({
})}
</div>
</div>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
</div>
)
}

View File

@ -133,6 +133,7 @@ vi.mock('@/utils/time', () => ({
// Mock dynamic imports
vi.mock('@/next/dynamic', () => ({
default: (importFn: () => Promise<unknown>) => {
void importFn().catch(() => {})
const fnString = importFn.toString()
if (fnString.includes('create-app-modal') || fnString.includes('explore/create-app-modal')) {
@ -189,22 +190,109 @@ vi.mock('@/next/dynamic', () => ({
},
}))
// Popover uses @headlessui/react portals - mock for controlled interaction testing
vi.mock('@/app/components/base/popover', () => {
type PopoverHtmlContent = React.ReactNode | ((state: { open: boolean, onClose: () => void, onClick: () => void }) => React.ReactNode)
type MockPopoverProps = { htmlContent: PopoverHtmlContent, btnElement: React.ReactNode, btnClassName?: string | ((open: boolean) => string) }
const MockPopover = ({ htmlContent, btnElement, btnClassName }: MockPopoverProps) => {
const [isOpen, setIsOpen] = React.useState(false)
const computedClassName = typeof btnClassName === 'function' ? btnClassName(isOpen) : ''
return React.createElement('div', { 'data-testid': 'custom-popover', 'className': computedClassName }, React.createElement('div', {
'onClick': () => setIsOpen(!isOpen),
'data-testid': 'popover-trigger',
}, btnElement), isOpen && React.createElement('div', {
'data-testid': 'popover-content',
'onMouseLeave': () => setIsOpen(false),
}, typeof htmlContent === 'function' ? htmlContent({ open: isOpen, onClose: () => setIsOpen(false), onClick: () => setIsOpen(false) }) : htmlContent))
vi.mock('@/app/components/base/ui/dropdown-menu', () => {
type DropdownMenuContextValue = {
isOpen: boolean
setOpen: (open: boolean) => void
}
const DropdownMenuContext = React.createContext<DropdownMenuContextValue | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context)
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({
children,
open = false,
modal,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
modal?: boolean
onOpenChange?: (open: boolean) => void
}) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open} data-modal={modal}>
{children}
</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({
children,
className,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isOpen, setOpen } = useDropdownMenuContext()
return (
<button
data-testid="dropdown-menu-trigger"
type="button"
className={className}
onClick={(e) => {
onClick?.(e)
setOpen(!isOpen)
}}
{...props}
>
{children}
</button>
)
},
DropdownMenuContent: ({
children,
className,
popupClassName,
}: {
children: React.ReactNode
className?: string
popupClassName?: string
}) => {
const { isOpen } = useDropdownMenuContext()
if (!isOpen)
return null
return (
<div data-testid="dropdown-menu-content" role="menu" className={[className, popupClassName].filter(Boolean).join(' ')}>
{children}
</div>
)
},
DropdownMenuItem: ({
children,
className,
onClick,
destructive,
}: {
children: React.ReactNode
className?: string
onClick?: React.MouseEventHandler<HTMLButtonElement>
destructive?: boolean
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
data-testid="dropdown-menu-item"
role="menuitem"
type="button"
className={className}
data-destructive={destructive}
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
DropdownMenuSeparator: () => <hr data-testid="dropdown-menu-separator" />,
}
return { __esModule: true, default: MockPopover }
})
// Tooltip uses portals - minimal mock preserving popup content as title attribute
@ -285,9 +373,9 @@ describe('AppCard', () => {
it('should render app icon', () => {
// AppIcon component renders the emoji icon from app data
const { container } = render(<AppCard app={mockApp} />)
// Check that the icon container is rendered (AppIcon renders within the card)
const iconElement = container.querySelector('[class*="icon"]') || container.querySelector('img')
expect(iconElement || screen.getByText(mockApp.icon)).toBeTruthy()
const emojiIcon = container.querySelector(`em-emoji[id="${mockApp.icon}"]`)
const imageIcon = container.querySelector('img')
expect(emojiIcon || imageIcon).toBeTruthy()
})
it('should render app type icon', () => {
@ -370,45 +458,50 @@ describe('AppCard', () => {
})
describe('Operations Menu', () => {
it('should render operations popover', () => {
it('should render operations dropdown menu', () => {
render(<AppCard app={mockApp} />)
expect(screen.getByTestId('custom-popover')).toBeInTheDocument()
expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument()
})
it('should show edit option when popover is opened', async () => {
it('should render dropdown menu as non-modal', () => {
render(<AppCard app={mockApp} />)
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-modal', 'false')
})
it('should show edit option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('app.editApp')).toBeInTheDocument()
})
})
it('should show duplicate option when popover is opened', async () => {
it('should show duplicate option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('app.duplicate')).toBeInTheDocument()
})
})
it('should show export option when popover is opened', async () => {
it('should show export option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('app.export')).toBeInTheDocument()
})
})
it('should show delete option when popover is opened', async () => {
it('should show delete option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
@ -419,7 +512,7 @@ describe('AppCard', () => {
const chatApp = { ...mockApp, mode: AppModeEnum.CHAT }
render(<AppCard app={chatApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText(/switch/i)).toBeInTheDocument()
@ -430,7 +523,7 @@ describe('AppCard', () => {
const completionApp = { ...mockApp, mode: AppModeEnum.COMPLETION }
render(<AppCard app={completionApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText(/switch/i)).toBeInTheDocument()
@ -441,7 +534,7 @@ describe('AppCard', () => {
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.queryByText(/switch/i)).not.toBeInTheDocument()
@ -453,7 +546,7 @@ describe('AppCard', () => {
it('should open edit modal when edit button is clicked', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
const editButton = screen.getByText('app.editApp')
@ -468,7 +561,7 @@ describe('AppCard', () => {
it('should open duplicate modal when duplicate button is clicked', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
const duplicateButton = screen.getByText('app.duplicate')
@ -483,16 +576,16 @@ describe('AppCard', () => {
it('should open confirm dialog when delete button is clicked', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
})
it('should close confirm dialog when cancel is clicked', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
@ -500,10 +593,23 @@ describe('AppCard', () => {
})
})
it('should not submit delete when confirmation text does not match', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
const form = (await screen.findByRole('alertdialog')).querySelector('form')
expect(form).toBeTruthy()
fireEvent.submit(form!)
expect(mockDeleteAppMutation).not.toHaveBeenCalled()
})
it('should close edit modal when onHide is called', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.editApp'))
})
@ -523,7 +629,7 @@ describe('AppCard', () => {
it('should close duplicate modal when onHide is called', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.duplicate'))
})
@ -539,6 +645,28 @@ describe('AppCard', () => {
expect(screen.queryByTestId('duplicate-modal')).not.toBeInTheDocument()
})
})
it('should clear delete confirmation input after closing the dialog', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
const deleteInput = await screen.findByRole('textbox')
fireEvent.change(deleteInput, { target: { value: 'partial name' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveValue('')
})
})
})
describe('Styling', () => {
@ -559,9 +687,9 @@ describe('AppCard', () => {
it('should call deleteApp API when confirming delete', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
// Open popover and click delete
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
// Open dropdown menu and click delete
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
// Fill in the confirmation input with app name
@ -578,8 +706,8 @@ describe('AppCard', () => {
it('should not call onRefresh after successful delete', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
// Fill in the confirmation input with app name
@ -599,8 +727,8 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
// Fill in the confirmation input with app name
@ -615,10 +743,28 @@ describe('AppCard', () => {
})
})
it('should handle delete failure without an error message', async () => {
;(mockDeleteAppMutation as Mock).mockRejectedValueOnce({})
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
fireEvent.change(screen.getByRole('textbox'), { target: { value: mockApp.name } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockDeleteAppMutation).toHaveBeenCalled()
expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.appDeleteFailed' })
})
})
it('should call updateAppInfo API when editing app', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.editApp'))
})
@ -634,10 +780,30 @@ describe('AppCard', () => {
})
})
it('should edit successfully without onRefresh callback', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.editApp'))
})
await waitFor(() => {
expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-edit-modal'))
await waitFor(() => {
expect(appsService.updateAppInfo).toHaveBeenCalled()
expect(screen.queryByTestId('edit-app-modal')).not.toBeInTheDocument()
})
})
it('should call copyApp API when duplicating app', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.duplicate'))
})
@ -656,7 +822,7 @@ describe('AppCard', () => {
it('should call onPlanInfoChanged after successful duplication', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.duplicate'))
})
@ -672,12 +838,33 @@ describe('AppCard', () => {
})
})
it('should duplicate successfully without onRefresh callback', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.duplicate'))
})
await waitFor(() => {
expect(screen.getByTestId('duplicate-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-duplicate-modal'))
await waitFor(() => {
expect(appsService.copyApp).toHaveBeenCalled()
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(screen.queryByTestId('duplicate-modal')).not.toBeInTheDocument()
})
})
it('should handle copy failure', async () => {
(appsService.copyApp as Mock).mockRejectedValueOnce(new Error('Copy failed'))
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.duplicate'))
})
@ -697,7 +884,7 @@ describe('AppCard', () => {
it('should call exportAppConfig API when exporting', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -712,7 +899,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -729,7 +916,7 @@ describe('AppCard', () => {
const chatApp = { ...mockApp, mode: AppModeEnum.CHAT }
render(<AppCard app={chatApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.switch'))
})
@ -743,7 +930,7 @@ describe('AppCard', () => {
const chatApp = { ...mockApp, mode: AppModeEnum.CHAT }
render(<AppCard app={chatApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.switch'))
})
@ -763,7 +950,7 @@ describe('AppCard', () => {
const chatApp = { ...mockApp, mode: AppModeEnum.CHAT }
render(<AppCard app={chatApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.switch'))
})
@ -779,11 +966,31 @@ describe('AppCard', () => {
})
})
it('should close switch modal after success without onRefresh callback', async () => {
const chatApp = { ...mockApp, mode: AppModeEnum.CHAT }
render(<AppCard app={chatApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.switch'))
})
await waitFor(() => {
expect(screen.getByTestId('switch-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-switch-modal'))
await waitFor(() => {
expect(screen.queryByTestId('switch-modal')).not.toBeInTheDocument()
})
})
it('should open switch modal for completion mode apps', async () => {
const completionApp = { ...mockApp, mode: AppModeEnum.COMPLETION }
render(<AppCard app={completionApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.switch'))
})
@ -795,10 +1002,10 @@ describe('AppCard', () => {
})
describe('Open in Explore', () => {
it('should show open in explore option when popover is opened', async () => {
it('should show open in explore option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('app.openInExplore')).toBeInTheDocument()
@ -811,7 +1018,7 @@ describe('AppCard', () => {
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -829,7 +1036,7 @@ describe('AppCard', () => {
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -839,11 +1046,33 @@ describe('AppCard', () => {
})
})
it('should export workflow directly when environment_variables is undefined', async () => {
(workflowService.fetchWorkflowDraft as Mock).mockResolvedValueOnce({})
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
await waitFor(() => {
expect(workflowService.fetchWorkflowDraft).toHaveBeenCalledWith(`/apps/${workflowApp.id}/workflows/draft`)
expect(appsService.exportAppConfig).toHaveBeenCalledWith({
appID: workflowApp.id,
include: false,
})
})
expect(screen.queryByTestId('dsl-export-modal')).not.toBeInTheDocument()
})
it('should check for secret environment variables in advanced chat apps', async () => {
const advancedChatApp = { ...mockApp, mode: AppModeEnum.ADVANCED_CHAT }
render(<AppCard app={advancedChatApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -861,7 +1090,7 @@ describe('AppCard', () => {
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -952,7 +1181,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.editApp'))
})
@ -969,10 +1198,32 @@ describe('AppCard', () => {
})
})
it('should fall back to the default edit failure message', async () => {
(appsService.updateAppInfo as Mock).mockRejectedValueOnce({ message: '' })
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.editApp'))
})
await waitFor(() => {
expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-edit-modal'))
await waitFor(() => {
expect(appsService.updateAppInfo).toHaveBeenCalled()
expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.editFailed' })
})
})
it('should close edit modal after successful edit', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.editApp'))
})
@ -1011,7 +1262,7 @@ describe('AppCard', () => {
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -1031,7 +1282,7 @@ describe('AppCard', () => {
const chatApp = createMockApp({ mode: AppModeEnum.CHAT })
render(<AppCard app={chatApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.switch'))
})
@ -1048,12 +1299,12 @@ describe('AppCard', () => {
})
})
it('should render popover menu with correct styling for different app modes', async () => {
it('should render dropdown menu with correct styling for different app modes', async () => {
// Test completion mode styling
const completionApp = createMockApp({ mode: AppModeEnum.COMPLETION })
const { unmount } = render(<AppCard app={completionApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('app.editApp')).toBeInTheDocument()
})
@ -1064,7 +1315,7 @@ describe('AppCard', () => {
const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW })
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('app.editApp')).toBeInTheDocument()
})
@ -1086,45 +1337,26 @@ describe('AppCard', () => {
fireEvent.click(tagSelectorWrapper)
})
it('should handle popover mouse leave', async () => {
it('should close operations menu after selecting an item', async () => {
render(<AppCard app={mockApp} />)
// Open popover
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
expect(screen.getByTestId('dropdown-menu-content')).toBeInTheDocument()
})
// Trigger mouse leave on the outer popover-content
fireEvent.mouseLeave(screen.getByTestId('popover-content'))
fireEvent.click(screen.getByText('app.editApp'))
await waitFor(() => {
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
expect(screen.queryByTestId('dropdown-menu-content')).not.toBeInTheDocument()
expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument()
})
})
it('should handle operations menu mouse leave', async () => {
render(<AppCard app={mockApp} />)
// Open popover
fireEvent.click(screen.getByTestId('popover-trigger'))
await waitFor(() => {
expect(screen.getByText('app.editApp')).toBeInTheDocument()
})
// Find the Operations wrapper div (contains the menu items)
const editButton = screen.getByText('app.editApp')
const operationsWrapper = editButton.closest('div.relative')
// Trigger mouse leave on the Operations wrapper to call onMouseLeave
if (operationsWrapper)
fireEvent.mouseLeave(operationsWrapper)
})
it('should click open in explore button', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
const openInExploreBtn = screen.getByText('app.openInExplore')
fireEvent.click(openInExploreBtn)
@ -1147,7 +1379,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
const openInExploreBtn = screen.getByText('app.openInExplore')
fireEvent.click(openInExploreBtn)
@ -1173,7 +1405,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
const openInExploreBtn = screen.getByText('app.openInExplore')
fireEvent.click(openInExploreBtn)
@ -1183,13 +1415,49 @@ describe('AppCard', () => {
expect(exploreService.fetchInstalledAppList).toHaveBeenCalled()
})
})
it('should show string errors from open in explore onError callback', async () => {
mockOpenAsyncWindow.mockImplementationOnce(async (_callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => {
options?.onError?.('Window failed')
})
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.openInExplore'))
})
await waitFor(() => {
expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'Window failed' })
})
})
it('should handle non-Error rejections from open in explore', async () => {
const nonErrorRejection = { toString: () => 'Window rejected' }
mockOpenAsyncWindow.mockImplementationOnce(async () => {
return Promise.reject(nonErrorRejection)
})
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.openInExplore'))
})
await waitFor(() => {
expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'Window rejected' })
})
})
})
describe('Access Control', () => {
it('should render operations menu correctly', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('app.editApp')).toBeInTheDocument()
expect(screen.getByText('app.duplicate')).toBeInTheDocument()
@ -1215,7 +1483,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
const openInExploreBtn = screen.getByText('app.openInExplore')
fireEvent.click(openInExploreBtn)
@ -1236,7 +1504,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
const openInExploreBtn = screen.getByText('app.openInExplore')
fireEvent.click(openInExploreBtn)
@ -1253,7 +1521,7 @@ describe('AppCard', () => {
const draftTriggerApp = createMockApp({ has_draft_trigger: true })
render(<AppCard app={draftTriggerApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('app.editApp')).toBeInTheDocument()
// openInExplore should not be shown for draft trigger apps
@ -1278,7 +1546,7 @@ describe('AppCard', () => {
it('should show access control option when webapp_auth is enabled', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('app.accessControl')).toBeInTheDocument()
})
@ -1287,7 +1555,7 @@ describe('AppCard', () => {
it('should click access control button', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
const accessControlBtn = screen.getByText('app.accessControl')
fireEvent.click(accessControlBtn)
@ -1301,7 +1569,7 @@ describe('AppCard', () => {
it('should close access control modal and call onRefresh', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.accessControl'))
})
@ -1318,10 +1586,29 @@ describe('AppCard', () => {
})
})
it('should close access control modal after confirm without onRefresh callback', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.accessControl'))
})
await waitFor(() => {
expect(screen.getByTestId('access-control-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-access-control'))
await waitFor(() => {
expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument()
})
})
it('should show open in explore when userCanAccessApp is true', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('app.openInExplore')).toBeInTheDocument()
})
@ -1330,7 +1617,7 @@ describe('AppCard', () => {
it('should close access control modal when onClose is called', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
fireEvent.click(screen.getByText('app.accessControl'))
})
@ -1347,4 +1634,87 @@ describe('AppCard', () => {
})
})
})
describe('Delete dialog guards', () => {
const createMockAlertDialogModule = () => ({
AlertDialog: ({ open, onOpenChange, children }: { open: boolean, onOpenChange?: (open: boolean) => void, children: React.ReactNode }) => (
open
? (
<div role="alertdialog">
<button type="button" data-testid="keep-open-dialog" onClick={() => onOpenChange?.(true)}>Keep open</button>
<button type="button" data-testid="force-close-dialog" onClick={() => onOpenChange?.(false)}>Force close</button>
{children}
</div>
)
: null
),
AlertDialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type="button" {...props}>{children}</button>,
AlertDialogConfirmButton: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean }) => <button type="button" {...props}>{children}</button>,
})
it('should reset delete input when dialog closes', async () => {
vi.resetModules()
vi.doMock('@/app/components/base/ui/alert-dialog', createMockAlertDialogModule)
const { default: IsolatedAppCard } = await import('../app-card')
render(<IsolatedAppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
fireEvent.change(await screen.findByRole('textbox'), { target: { value: 'partial name' } })
fireEvent.click(screen.getByTestId('force-close-dialog'))
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('textbox')).toHaveValue('')
vi.doUnmock('@/app/components/base/ui/alert-dialog')
})
it('should keep delete input when dialog remains open', async () => {
vi.resetModules()
vi.doMock('@/app/components/base/ui/alert-dialog', createMockAlertDialogModule)
const { default: IsolatedAppCard } = await import('../app-card')
render(<IsolatedAppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
fireEvent.change(await screen.findByRole('textbox'), { target: { value: 'partial name' } })
fireEvent.click(screen.getByTestId('keep-open-dialog'))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
expect(await screen.findByRole('textbox')).toHaveValue('partial name')
vi.doUnmock('@/app/components/base/ui/alert-dialog')
})
it('should keep delete dialog open when close is requested during deletion', async () => {
vi.resetModules()
mockDeleteMutationPending = true
vi.doMock('@/app/components/base/ui/alert-dialog', createMockAlertDialogModule)
const { default: IsolatedAppCard } = await import('../app-card')
render(<IsolatedAppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('force-close-dialog'))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
vi.doUnmock('@/app/components/base/ui/alert-dialog')
mockDeleteMutationPending = false
})
})
})

View File

@ -1,22 +1,18 @@
'use client'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { HtmlContentProps } from '@/app/components/base/popover'
import type { Tag } from '@/app/components/base/tag-management/constant'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import type { WorkflowOnlineUser } from '@/models/app'
import type { App } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useId, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import CustomPopover from '@/app/components/base/popover'
import TagSelector from '@/app/components/base/tag-management/selector'
import Tooltip from '@/app/components/base/tooltip'
import {
@ -28,6 +24,13 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { toast } from '@/app/components/base/ui/toast'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@ -71,6 +74,134 @@ type AppCardProps = {
onRefresh?: () => void
}
type AppCardOperationsMenuProps = {
app: App
shouldShowSwitchOption: boolean
shouldShowOpenInExploreOption: boolean
shouldShowAccessControlOption: boolean
onEdit: () => void
onDuplicate: () => void
onExport: () => void
onSwitch: () => void
onDelete: () => void
onAccessControl: () => void
}
const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
app,
shouldShowSwitchOption,
shouldShowOpenInExploreOption,
shouldShowAccessControlOption,
onEdit,
onDuplicate,
onExport,
onSwitch,
onDelete,
onAccessControl,
}) => {
const { t } = useTranslation()
const openAsyncWindow = useAsyncWindowOpen()
const handleMenuAction = useCallback((e: React.MouseEvent<HTMLElement>, action: () => void) => {
e.stopPropagation()
e.preventDefault()
action()
}, [])
const handleOpenInstalledApp = useCallback(async (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation()
e.preventDefault()
try {
await openAsyncWindow(async () => {
const { installed_apps } = await fetchInstalledAppList(app.id)
if (installed_apps?.length > 0)
return `${basePath}/explore/installed/${installed_apps[0].id}`
throw new Error('No app found in Explore')
}, {
onError: (err) => {
toast.error(`${err.message || err}`)
},
})
}
catch (e: unknown) {
const message = e instanceof Error ? e.message : `${e}`
toast.error(message)
}
}, [app.id, openAsyncWindow])
return (
<>
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onEdit)}>
<span className="system-sm-regular text-text-secondary">{t('editApp', { ns: 'app' })}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onDuplicate)}>
<span className="system-sm-regular text-text-secondary">{t('duplicate', { ns: 'app' })}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onExport)}>
<span className="system-sm-regular text-text-secondary">{t('export', { ns: 'app' })}</span>
</DropdownMenuItem>
{shouldShowSwitchOption && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onSwitch)}>
<span className="text-sm leading-5 text-text-secondary">{t('switch', { ns: 'app' })}</span>
</DropdownMenuItem>
</>
)}
{shouldShowOpenInExploreOption && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenInstalledApp}>
<span className="system-sm-regular text-text-secondary">{t('openInExplore', { ns: 'app' })}</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
{shouldShowAccessControlOption && (
<>
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onAccessControl)}>
<span className="text-sm leading-5 text-text-secondary">{t('accessControl', { ns: 'app' })}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
destructive
className="gap-2 px-3"
onClick={e => handleMenuAction(e, onDelete)}
>
<span className="system-sm-regular">
{t('operation.delete', { ns: 'common' })}
</span>
</DropdownMenuItem>
</>
)
}
type AppCardOperationsMenuContentProps = Omit<AppCardOperationsMenuProps, 'shouldShowOpenInExploreOption'>
const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps> = (props) => {
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({
appId: props.app.id,
enabled: systemFeatures.webapp_auth.enabled,
})
const shouldShowOpenInExploreOption = !props.app.has_draft_trigger
&& (
!systemFeatures.webapp_auth.enabled
|| (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result))
)
return (
<AppCardOperationsMenu
{...props}
shouldShowOpenInExploreOption={shouldShowOpenInExploreOption}
/>
)
}
const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
const { t } = useTranslation()
const deleteAppNameInputId = useId()
@ -78,7 +209,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
const { isCurrentWorkspaceEditor } = useAppContext()
const { onPlanInfoChanged } = useProviderContext()
const { push } = useRouter()
const openAsyncWindow = useAsyncWindowOpen()
const [showEditModal, setShowEditModal] = useState(false)
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
@ -86,6 +216,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
const [showAccessControl, setShowAccessControl] = useState(false)
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation()
@ -121,6 +252,41 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
void onConfirmDelete()
}, [isDeleteConfirmDisabled, onConfirmDelete])
const handleShowEditModal = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowEditModal(true)
})
}, [])
const handleShowDuplicateModal = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowDuplicateModal(true)
})
}, [])
const handleShowSwitchModal = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowSwitchModal(true)
})
}, [])
const handleShowDeleteConfirm = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowConfirmDelete(true)
})
}, [])
const handleShowAccessControl = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowAccessControl(true)
})
}, [])
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
@ -189,6 +355,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
}
const exportCheck = async () => {
setIsOperationsMenuOpen(false)
if (app.mode !== AppModeEnum.WORKFLOW && app.mode !== AppModeEnum.ADVANCED_CHAT) {
onExport()
return
@ -219,136 +386,9 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
setShowAccessControl(false)
}, [onRefresh, setShowAccessControl])
const Operations = (props: HtmlContentProps) => {
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ appId: app?.id, enabled: (!!props?.open && systemFeatures.webapp_auth.enabled) })
const onMouseLeave = async () => {
props.onClose?.()
}
const onClickSettings = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowEditModal(true)
}
const onClickDuplicate = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowDuplicateModal(true)
}
const onClickExport = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
exportCheck()
}
const onClickSwitch = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowSwitchModal(true)
}
const onClickDelete = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowConfirmDelete(true)
}
const onClickAccessControl = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowAccessControl(true)
}
const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
try {
await openAsyncWindow(async () => {
const { installed_apps } = await fetchInstalledAppList(app.id)
if (installed_apps?.length > 0)
return `${basePath}/explore/installed/${installed_apps[0].id}`
throw new Error('No app found in Explore')
}, {
onError: (err) => {
toast.error(`${err.message || err}`)
},
})
}
catch (e: unknown) {
const message = e instanceof Error ? e.message : `${e}`
toast.error(message)
}
}
return (
<div className="relative flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickSettings}>
<span className="system-sm-regular text-text-secondary">{t('editApp', { ns: 'app' })}</span>
</button>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickDuplicate}>
<span className="system-sm-regular text-text-secondary">{t('duplicate', { ns: 'app' })}</span>
</button>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickExport}>
<span className="system-sm-regular text-text-secondary">{t('export', { ns: 'app' })}</span>
</button>
{(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && (
<>
<Divider className="my-1" />
<button
type="button"
className="mx-1 flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={onClickSwitch}
>
<span className="text-sm leading-5 text-text-secondary">{t('switch', { ns: 'app' })}</span>
</button>
</>
)}
{
!app.has_draft_trigger && (
(!systemFeatures.webapp_auth.enabled)
? (
<>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickInstalledApp}>
<span className="system-sm-regular text-text-secondary">{t('openInExplore', { ns: 'app' })}</span>
</button>
</>
)
: !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && (
<>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickInstalledApp}>
<span className="system-sm-regular text-text-secondary">{t('openInExplore', { ns: 'app' })}</span>
</button>
</>
)
)
}
<Divider className="my-1" />
{
systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor && (
<>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickAccessControl}>
<span className="text-sm leading-5 text-text-secondary">{t('accessControl', { ns: 'app' })}</span>
</button>
<Divider className="my-1" />
</>
)
}
<button
type="button"
className="group mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-destructive-hover"
onClick={onClickDelete}
>
<span className="system-sm-regular text-text-secondary group-hover:text-text-destructive">
{t('operation.delete', { ns: 'common' })}
</span>
</button>
</div>
)
}
const shouldShowSwitchOption = app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT
const shouldShowAccessControlOption = systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor
const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]'
const [tags, setTags] = useState<Tag[]>(app.tags)
useEffect(() => {
@ -414,28 +454,28 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
<div className="flex h-5 w-5 items-center justify-center">
{app.access_mode === AccessMode.PUBLIC && (
<Tooltip asChild={false} popupContent={t('accessItemsDescription.anyone', { ns: 'app' })}>
<RiGlobalLine className="h-4 w-4 text-text-quaternary" />
<span aria-hidden className="i-ri-global-line h-4 w-4 text-text-quaternary" />
</Tooltip>
)}
{app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && (
<Tooltip asChild={false} popupContent={t('accessItemsDescription.specific', { ns: 'app' })}>
<RiLockLine className="h-4 w-4 text-text-quaternary" />
<span aria-hidden className="i-ri-lock-line h-4 w-4 text-text-quaternary" />
</Tooltip>
)}
{app.access_mode === AccessMode.ORGANIZATION && (
<Tooltip asChild={false} popupContent={t('accessItemsDescription.organization', { ns: 'app' })}>
<RiBuildingLine className="h-4 w-4 text-text-quaternary" />
<span aria-hidden className="i-ri-building-line h-4 w-4 text-text-quaternary" />
</Tooltip>
)}
{app.access_mode === AccessMode.EXTERNAL_MEMBERS && (
<Tooltip asChild={false} popupContent={t('accessItemsDescription.external', { ns: 'app' })}>
<RiVerifiedBadgeLine className="h-4 w-4 text-text-quaternary" />
<span aria-hidden className="i-ri-verified-badge-line h-4 w-4 text-text-quaternary" />
</Tooltip>
)}
</div>
</div>
</div>
<div className="title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary">
<div className="h-[90px] px-[14px] text-xs leading-normal text-text-tertiary">
<div
className="line-clamp-2"
title={app.description}
@ -453,7 +493,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
e.preventDefault()
}}
>
<div className="mr-[41px] w-full grow group-hover:mr-0!">
<div className="mr-[41px] w-full grow">
<TagSelector
position="bl"
type="app"
@ -465,32 +505,69 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
/>
</div>
</div>
<div className="mx-1 hidden! h-[14px] w-px shrink-0 bg-divider-regular group-hover:flex!" />
<div className="hidden! shrink-0 group-hover:flex!">
<CustomPopover
htmlContent={<Operations />}
position="br"
trigger="click"
btnElement={(
<div
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md"
>
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<RiMoreFill aria-hidden className="h-4 w-4 text-text-tertiary" />
</div>
)}
btnClassName={open =>
cn(
open ? 'bg-state-base-hover! shadow-none!' : 'bg-transparent!',
'h-8 w-8 rounded-md border-none p-2! hover:bg-state-base-hover!',
<div
className={cn(
'absolute top-1/2 right-[6px] flex -translate-y-1/2 items-center transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<div className="mx-1 h-[14px] w-px shrink-0 bg-divider-regular" />
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isOperationsMenuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover',
)}
popupClassName={
(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT)
? 'w-[256px]! translate-x-[-224px]'
: 'w-[216px]! translate-x-[-128px]'
}
className="z-20! h-fit"
/>
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md">
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</div>
</DropdownMenuTrigger>
{isOperationsMenuOpen && (
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)}
</DropdownMenuContent>
)}
</DropdownMenu>
</div>
</>
)}

View File

@ -30,7 +30,7 @@ const actionButtonVariants = cva(
},
)
export type ActionButtonProps = {
type ActionButtonProps = {
size?: 'xs' | 's' | 'm' | 'l' | 'xl'
state?: ActionButtonState
styleCss?: CSSProperties
@ -73,4 +73,4 @@ const ActionButton = ({ className, size, state = ActionButtonState.Default, styl
ActionButton.displayName = 'ActionButton'
export default ActionButton
export { ActionButton, ActionButtonState, actionButtonVariants }
export { ActionButton, ActionButtonState }

View File

@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -53,11 +53,16 @@ describe('MobileOperationDropdown Component', () => {
// Reset Chat
await user.click(screen.getByText('share.chat.resetChat'))
expect(defaultProps.handleResetChat).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(defaultProps.handleResetChat).toHaveBeenCalledTimes(1)
})
await user.click(screen.getByRole('button'))
// View Chat Settings
await user.click(screen.getByText('share.chat.viewChatSettings'))
expect(defaultProps.handleViewChatSettings).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(defaultProps.handleViewChatSettings).toHaveBeenCalledTimes(1)
})
})
it('applies hover state to ActionButton when open', async () => {
@ -72,4 +77,16 @@ describe('MobileOperationDropdown Component', () => {
await user.click(trigger)
expect(trigger).toHaveClass('action-btn-hover')
})
it('closes the menu after clicking an action', async () => {
const user = userEvent.setup()
render(<MobileOperationDropdown {...defaultProps} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('share.chat.resetChat'))
await waitFor(() => {
expect(screen.queryByText('share.chat.resetChat')).not.toBeInTheDocument()
})
})
})

View File

@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -74,12 +74,18 @@ describe('Operation Component', () => {
expect(defaultProps.togglePin).toHaveBeenCalledTimes(1)
// Rename
await user.click(screen.getByText('Chat Title'))
await user.click(screen.getByText('explore.sidebar.action.rename'))
expect(defaultProps.onRenameConversation).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(defaultProps.onRenameConversation).toHaveBeenCalledTimes(1)
})
// Delete
await user.click(screen.getByText('Chat Title'))
await user.click(screen.getByText('explore.sidebar.action.delete'))
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1)
})
})
it('applies hover background when open', async () => {

View File

@ -1,7 +1,12 @@
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
type Props = {
handleResetChat: () => void
@ -16,40 +21,45 @@ const MobileOperationDropdown = ({
}: Props) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleMenuAction = useCallback((callback: () => void) => {
setOpen(false)
queueMicrotask(callback)
}, [])
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
<DropdownMenuTrigger
render={<div />}
data-testid="mobile-more-btn"
>
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
<div className="i-ri-more-fill h-[18px] w-[18px]" />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-40">
<div
className="min-w-[160px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[160px]"
>
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleMenuAction(handleResetChat)}
>
<div className="flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover" onClick={handleResetChat}>
<span className="grow">{t('chat.resetChat', { ns: 'share' })}</span>
</div>
{!hideViewChatSettings && (
<div className="flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover" onClick={handleViewChatSettings}>
<span className="grow">{t('chat.viewChatSettings', { ns: 'share' })}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<span className="grow">{t('chat.resetChat', { ns: 'share' })}</span>
</DropdownMenuItem>
{!hideViewChatSettings && (
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleMenuAction(handleViewChatSettings)}
>
<span className="grow">{t('chat.viewChatSettings', { ns: 'share' })}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,14 +1,16 @@
'use client'
import type { Placement } from '@floating-ui/react'
import type { FC } from 'react'
import type { Placement } from '@/app/components/base/ui/placement'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import * as React from 'react'
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
type Props = {
title: string
@ -33,42 +35,51 @@ const Operation: FC<Props> = ({
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleDeferredAction = useCallback((action: () => void) => {
setOpen(false)
queueMicrotask(action)
}, [])
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement={placement}
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
<DropdownMenuTrigger
render={<div />}
>
<div className={cn('flex cursor-pointer items-center rounded-lg p-1.5 pl-2 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
<div className="system-md-semibold">{title}</div>
<RiArrowDownSLine className="h-4 w-4" />
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div
className="min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
>
<div className={cn('flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover')} onClick={togglePin}>
<span className="grow">{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
</div>
{isShowRenameConversation && (
<div className={cn('flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover')} onClick={onRenameConversation}>
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
</div>
)}
{isShowDelete && (
<div className={cn('group flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive')} onClick={onDelete}>
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DropdownMenuTrigger>
<DropdownMenuContent
placement={placement}
sideOffset={4}
popupClassName="min-w-[120px]"
>
<DropdownMenuItem className="system-md-regular" onClick={togglePin}>
<span className="grow">{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
</DropdownMenuItem>
{isShowRenameConversation && (
<DropdownMenuItem
className="system-md-regular"
onClick={() => onRenameConversation && handleDeferredAction(onRenameConversation)}
>
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
</DropdownMenuItem>
)}
{isShowDelete && (
<DropdownMenuItem
destructive
className="system-md-regular"
onClick={() => handleDeferredAction(onDelete)}
>
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default React.memo(Operation)

View File

@ -1,16 +1,9 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Operation from '../operation'
// Mock PortalToFollowElem components to render children in place
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => <div data-open={open}>{children}</div>,
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => <div onClick={onClick}>{children}</div>,
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div data-testid="portal-content">{children}</div>,
}))
describe('Operation', () => {
const defaultProps = {
isActive: false,
@ -72,7 +65,9 @@ describe('Operation', () => {
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('explore.sidebar.action.rename'))
expect(defaultProps.onRenameConversation).toHaveBeenCalled()
await waitFor(() => {
expect(defaultProps.onRenameConversation).toHaveBeenCalled()
})
})
it('should call onDelete when delete is clicked', async () => {
@ -82,7 +77,9 @@ describe('Operation', () => {
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('explore.sidebar.action.delete'))
expect(defaultProps.onDelete).toHaveBeenCalled()
await waitFor(() => {
expect(defaultProps.onDelete).toHaveBeenCalled()
})
})
it('should respect visibility props', async () => {
@ -108,8 +105,7 @@ describe('Operation', () => {
await user.click(screen.getByRole('button'))
const portalContent = screen.getByTestId('portal-content')
expect(portalContent).toBeInTheDocument()
expect(screen.getByText('explore.sidebar.action.pin')).toBeInTheDocument()
})
it('should close dropdown when item hovering stops', async () => {
@ -120,5 +116,60 @@ describe('Operation', () => {
expect(screen.getByText('explore.sidebar.action.pin')).toBeInTheDocument()
rerender(<Operation {...defaultProps} isItemHovering={false} />)
await waitFor(() => {
expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument()
})
})
it('should keep the trigger mounted while visually hidden', () => {
render(<Operation {...defaultProps} isItemHovering={false} />)
const trigger = screen.getByRole('button')
expect(trigger).toHaveClass('pointer-events-none')
expect(trigger).toHaveClass('opacity-0')
})
it('should safely ignore rename clicks when callback is missing', async () => {
const user = userEvent.setup()
render(<Operation {...defaultProps} onRenameConversation={undefined} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('explore.sidebar.action.rename'))
await waitFor(() => {
expect(screen.queryByText('explore.sidebar.action.rename')).not.toBeInTheDocument()
})
})
it('should not bubble trigger clicks to the parent container', async () => {
const user = userEvent.setup()
const parentClick = vi.fn()
render(
<div onClick={parentClick}>
<Operation {...defaultProps} />
</div>,
)
await user.click(screen.getByRole('button'))
expect(parentClick).not.toHaveBeenCalled()
})
it('should not bubble popup clicks to the parent container', async () => {
const user = userEvent.setup()
const parentClick = vi.fn()
render(
<div onClick={parentClick}>
<Operation {...defaultProps} isItemHovering={true} />
</div>,
)
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('menu'))
expect(parentClick).not.toHaveBeenCalled()
})
})

View File

@ -1,19 +1,17 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiDeleteBinLine,
RiEditLine,
RiMoreFill,
RiPushpinLine,
RiUnpinLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
type Props = {
isActive?: boolean
@ -38,24 +36,29 @@ const Operation: FC<Props> = ({
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef(null)
const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false)
useEffect(() => {
if (!isItemHovering && !isHovering)
setOpen(false)
}, [isItemHovering, isHovering])
const handleDeferredAction = useCallback((action?: () => void) => {
if (!action)
return
setOpen(false)
queueMicrotask(action)
}, [])
return (
<PortalToFollowElem
<DropdownMenu
modal={false}
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
<DropdownMenuTrigger
render={<div />}
onClick={e => e.stopPropagation()}
>
<ActionButton
className={cn((isItemHovering || open) ? 'opacity-100' : 'opacity-0')}
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
state={
isActive
? ActionButtonState.Active
@ -64,39 +67,57 @@ const Operation: FC<Props> = ({
: ActionButtonState.Default
}
>
<RiMoreFill className="h-4 w-4" />
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div
ref={ref}
className="min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
onMouseEnter={setIsHovering}
onMouseLeave={setNotHovering}
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[120px]"
popupProps={{
onMouseEnter: setIsHovering,
onMouseLeave: setNotHovering,
onClick: e => e.stopPropagation(),
}}
>
<DropdownMenuItem
className="gap-2 px-2 system-md-regular"
onClick={(e) => {
e.stopPropagation()
togglePin()
}}
>
<div className={cn('flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover')} onClick={togglePin}>
{isPinned && <RiUnpinLine className="h-4 w-4 shrink-0 text-text-tertiary" />}
{!isPinned && <RiPushpinLine className="h-4 w-4 shrink-0 text-text-tertiary" />}
<span className="grow">{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
</div>
{isShowRenameConversation && (
<div className={cn('flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover')} onClick={onRenameConversation}>
<RiEditLine className="h-4 w-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
</div>
)}
{isShowDelete && (
<div className={cn('group flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive')} onClick={onDelete}>
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary group-hover:text-text-destructive')} />
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{isPinned && <span aria-hidden className="i-ri-unpin-line h-4 w-4 shrink-0 text-text-tertiary" />}
{!isPinned && <span aria-hidden className="i-ri-pushpin-line h-4 w-4 shrink-0 text-text-tertiary" />}
<span className="grow">{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
</DropdownMenuItem>
{isShowRenameConversation && (
<DropdownMenuItem
className="gap-2 px-2 system-md-regular"
onClick={(e) => {
e.stopPropagation()
handleDeferredAction(onRenameConversation)
}}
>
<span aria-hidden className="i-ri-edit-line h-4 w-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
</DropdownMenuItem>
)}
{isShowDelete && (
<DropdownMenuItem
destructive
className="gap-2 px-2 system-md-regular"
onClick={(e) => {
e.stopPropagation()
handleDeferredAction(onDelete)
}}
>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4 shrink-0" />
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default React.memo(Operation)

View File

@ -1,225 +0,0 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import Dropdown from '../index'
describe('Dropdown Component', () => {
const mockItems = [
{ value: 'option1', text: 'Option 1' },
{ value: 'option2', text: 'Option 2' },
]
const mockSecondItems = [
{ value: 'option3', text: 'Option 3' },
]
const onSelect = vi.fn()
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('renders default trigger properly', () => {
const { container } = render(
<Dropdown items={mockItems} onSelect={onSelect} />,
)
const trigger = container.querySelector('button')
expect(trigger).toBeInTheDocument()
})
it('renders custom trigger when provided', () => {
render(
<Dropdown
items={mockItems}
onSelect={onSelect}
renderTrigger={open => <button data-testid="custom-trigger">{open ? 'Open' : 'Closed'}</button>}
/>,
)
const trigger = screen.getByTestId('custom-trigger')
expect(trigger).toBeInTheDocument()
expect(trigger).toHaveTextContent('Closed')
})
it('opens dropdown menu on trigger click and shows items', async () => {
render(
<Dropdown items={mockItems} onSelect={onSelect} />,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.click(trigger)
})
// Dropdown items are rendered in a portal (document.body)
expect(screen.getByText('Option 1')).toBeInTheDocument()
expect(screen.getByText('Option 2')).toBeInTheDocument()
})
it('calls onSelect and closes dropdown when an item is clicked', async () => {
render(
<Dropdown items={mockItems} onSelect={onSelect} />,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.click(trigger)
})
const option1 = screen.getByText('Option 1')
await act(async () => {
fireEvent.click(option1)
})
expect(onSelect).toHaveBeenCalledWith(mockItems[0])
expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
})
it('calls onSelect and closes dropdown when a second item is clicked', async () => {
render(
<Dropdown items={mockItems} secondItems={mockSecondItems} onSelect={onSelect} />,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
const option3 = screen.getByText('Option 3')
await act(async () => {
fireEvent.click(option3)
})
expect(onSelect).toHaveBeenCalledWith(mockSecondItems[0])
expect(screen.queryByText('Option 3')).not.toBeInTheDocument()
})
it('renders second items and divider when provided', async () => {
render(
<Dropdown
items={mockItems}
secondItems={mockSecondItems}
onSelect={onSelect}
/>,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.click(trigger)
})
expect(screen.getByText('Option 1')).toBeInTheDocument()
expect(screen.getByText('Option 3')).toBeInTheDocument()
// Check for divider (h-px bg-divider-regular)
const divider = document.body.querySelector('.bg-divider-regular.h-px')
expect(divider).toBeInTheDocument()
})
it('applies custom classNames', async () => {
const popupClass = 'custom-popup'
const itemClass = 'custom-item'
const secondItemClass = 'custom-second-item'
render(
<Dropdown
items={mockItems}
secondItems={mockSecondItems}
onSelect={onSelect}
popupClassName={popupClass}
itemClassName={itemClass}
secondItemClassName={secondItemClass}
/>,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
const popup = document.body.querySelector(`.${popupClass}`)
expect(popup).toBeInTheDocument()
const items = screen.getAllByText('Option 1')
expect(items[0]).toHaveClass(itemClass)
const secondItems = screen.getAllByText('Option 3')
expect(secondItems[0]).toHaveClass(secondItemClass)
})
it('applies open class to trigger when menu is open', async () => {
render(<Dropdown items={mockItems} onSelect={onSelect} />)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.click(trigger)
})
expect(trigger).toHaveClass('bg-divider-regular')
})
it('handles JSX elements as item text', async () => {
const itemsWithJSX = [
{ value: 'jsx', text: <span data-testid="jsx-item">JSX Content</span> },
]
render(
<Dropdown items={itemsWithJSX} onSelect={onSelect} />,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
expect(screen.getByTestId('jsx-item')).toBeInTheDocument()
expect(screen.getByText('JSX Content')).toBeInTheDocument()
})
it('does not render items section if items list is empty', async () => {
render(
<Dropdown items={[]} secondItems={mockSecondItems} onSelect={onSelect} />,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
const p1Divs = document.body.querySelectorAll('.p-1')
expect(p1Divs.length).toBe(1)
expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
expect(screen.getByText('Option 3')).toBeInTheDocument()
})
it('does not render divider if only one section is provided', async () => {
const { rerender } = render(
<Dropdown items={mockItems} onSelect={onSelect} />,
)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument()
await act(async () => {
rerender(
<Dropdown items={[]} secondItems={mockSecondItems} onSelect={onSelect} />,
)
})
expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument()
})
it('renders nothing if both item lists are empty', async () => {
render(<Dropdown items={[]} secondItems={[]} onSelect={onSelect} />)
await act(async () => {
fireEvent.click(screen.getByRole('button'))
})
const popup = document.body.querySelector('.bg-components-panel-bg')
expect(popup?.children.length).toBe(0)
})
it('passes triggerProps to ActionButton and applies custom className', () => {
render(
<Dropdown
items={mockItems}
onSelect={onSelect}
triggerProps={{
'disabled': true,
'aria-label': 'dropdown-trigger',
'className': 'custom-trigger-class',
}}
/>,
)
const trigger = screen.getByLabelText('dropdown-trigger')
expect(trigger).toBeDisabled()
expect(trigger).toHaveClass('custom-trigger-class')
})
})

View File

@ -1,88 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { Item } from '.'
import { useState } from 'react'
import { fn } from 'storybook/test'
import Dropdown from '.'
const PRIMARY_ITEMS: Item[] = [
{ value: 'rename', text: 'Rename' },
{ value: 'duplicate', text: 'Duplicate' },
]
const SECONDARY_ITEMS: Item[] = [
{ value: 'archive', text: <span className="text-text-destructive">Archive</span> },
{ value: 'delete', text: <span className="text-text-destructive">Delete</span> },
]
const meta = {
title: 'Base/Navigation/Dropdown',
component: Dropdown,
parameters: {
docs: {
description: {
component: 'Small contextual menu with optional destructive section. Uses portal positioning utilities for precise placement.',
},
},
},
tags: ['autodocs'],
args: {
items: PRIMARY_ITEMS,
secondItems: SECONDARY_ITEMS,
},
} satisfies Meta<typeof Dropdown>
export default meta
type Story = StoryObj<typeof meta>
const DropdownDemo = (props: React.ComponentProps<typeof Dropdown>) => {
const [lastAction, setLastAction] = useState<string>('None')
return (
<div className="flex h-[200px] flex-col items-center justify-center gap-4">
<Dropdown
{...props}
onSelect={(item) => {
setLastAction(String(item.value))
props.onSelect?.(item)
}}
/>
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-secondary">
Last action:
{' '}
<span className="font-mono text-text-primary">{lastAction}</span>
</div>
</div>
)
}
export const Playground: Story = {
render: args => <DropdownDemo {...args} />,
args: {
items: PRIMARY_ITEMS,
secondItems: SECONDARY_ITEMS,
onSelect: fn(),
},
}
export const CustomTrigger: Story = {
render: args => (
<DropdownDemo
{...args}
renderTrigger={open => (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary hover:bg-state-base-hover-alt"
>
Actions
<span className={`transition-transform ${open ? 'rotate-180' : ''}`}>
</span>
</button>
)}
/>
),
args: {
items: PRIMARY_ITEMS,
onSelect: fn(),
},
}

View File

@ -1,122 +0,0 @@
import type { FC } from 'react'
import type { ActionButtonProps } from '@/app/components/base/action-button'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiMoreFill,
} from '@remixicon/react'
import { useState } from 'react'
import ActionButton from '@/app/components/base/action-button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
export type Item = {
value: string | number
text: string | React.JSX.Element
}
type DropdownProps = {
items: Item[]
secondItems?: Item[]
onSelect: (item: Item) => void
renderTrigger?: (open: boolean) => React.ReactNode
triggerProps?: ActionButtonProps
popupClassName?: string
itemClassName?: string
secondItemClassName?: string
}
const Dropdown: FC<DropdownProps> = ({
items,
onSelect,
secondItems,
renderTrigger,
triggerProps,
popupClassName,
itemClassName,
secondItemClassName,
}) => {
const [open, setOpen] = useState(false)
const handleSelect = (item: Item) => {
setOpen(false)
onSelect(item)
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
{
renderTrigger
? renderTrigger(open)
: (
<ActionButton
{...triggerProps}
className={cn(
open && 'bg-divider-regular',
triggerProps?.className,
)}
>
<RiMoreFill className="h-4 w-4 text-text-tertiary" />
</ActionButton>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={popupClassName}>
<div className="rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg text-sm text-text-secondary shadow-lg">
{
!!items.length && (
<div className="p-1">
{
items.map(item => (
<div
key={item.value}
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover',
itemClassName,
)}
onClick={() => handleSelect(item)}
>
{item.text}
</div>
))
}
</div>
)
}
{
(!!items.length && !!secondItems?.length) && (
<div className="h-px bg-divider-regular" />
)
}
{
!!secondItems?.length && (
<div className="p-1">
{
secondItems.map(item => (
<div
key={item.value}
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover',
secondItemClassName,
)}
onClick={() => handleSelect(item)}
>
{item.text}
</div>
))
}
</div>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default Dropdown

View File

@ -3,7 +3,6 @@ export { default as CopyCheck } from './CopyCheck'
export { default as FileArrow01 } from './FileArrow01'
export { default as FileDownload02 } from './FileDownload02'
export { default as FilePlus01 } from './FilePlus01'
export { default as FilePlus02 } from './FilePlus02'

View File

@ -10,7 +10,7 @@ import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-tim
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { Button } from '@/app/components/base/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
enum DATA_FORMAT {
TEXT = 'text',
@ -316,7 +316,10 @@ const MarkdownForm = ({ node }: { node: HastElement }) => {
</SelectTrigger>
<SelectContent>
{options.map(option => (
<SelectItem key={option} value={option}>{option}</SelectItem>
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>

View File

@ -1,272 +0,0 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CustomPopover from '..'
const CloseButtonContent = ({ onClick }: { onClick?: () => void }) => (
<button data-testid="content" onClick={onClick}>Close Me</button>
)
describe('CustomPopover', () => {
const defaultProps = {
btnElement: <span data-testid="trigger">Trigger</span>,
htmlContent: <div data-testid="content">Popover Content</div>,
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
if (vi.isFakeTimers?.())
vi.clearAllTimers()
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render the trigger element', () => {
render(<CustomPopover {...defaultProps} />)
expect(screen.getByTestId('trigger')).toBeInTheDocument()
})
it('should render string as htmlContent', async () => {
render(<CustomPopover {...defaultProps} htmlContent="String Content" trigger="click" />)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
expect(screen.getByText('String Content')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should toggle when clicking the button', async () => {
vi.useRealTimers()
const user = userEvent.setup()
render(<CustomPopover {...defaultProps} trigger="click" />)
const trigger = screen.getByTestId('trigger')
await user.click(trigger)
expect(screen.getByTestId('content')).toBeInTheDocument()
await user.click(trigger)
await waitFor(() => {
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
})
})
it('should open on hover when trigger is "hover" (default)', async () => {
render(<CustomPopover {...defaultProps} />)
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
const triggerContainer = screen.getByTestId('trigger').closest('div')
if (!triggerContainer)
throw new Error('Trigger container not found')
await act(async () => {
fireEvent.mouseEnter(triggerContainer)
})
expect(screen.getByTestId('content')).toBeInTheDocument()
})
it('should close after delay on mouse leave when trigger is "hover"', async () => {
vi.useRealTimers()
const user = userEvent.setup()
render(<CustomPopover {...defaultProps} />)
const trigger = screen.getByTestId('trigger')
await user.hover(trigger)
expect(screen.getByTestId('content')).toBeInTheDocument()
await user.unhover(trigger)
await waitFor(() => {
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
}, { timeout: 2000 })
})
it('should stay open when hovering over the popover content', async () => {
vi.useRealTimers()
const user = userEvent.setup()
render(<CustomPopover {...defaultProps} />)
const trigger = screen.getByTestId('trigger')
await user.hover(trigger)
expect(screen.getByTestId('content')).toBeInTheDocument()
// Leave trigger but enter content
await user.unhover(trigger)
const content = screen.getByTestId('content')
await user.hover(content)
// Wait for the timeout duration
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 200))
})
// Should still be open because we are hovering the content
expect(screen.getByTestId('content')).toBeInTheDocument()
// Now leave content
await user.unhover(content)
await waitFor(() => {
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
}, { timeout: 2000 })
})
it('should cancel close timeout when re-entering during hover delay', async () => {
render(<CustomPopover {...defaultProps} />)
const triggerContainer = screen.getByTestId('trigger').closest('div')
if (!triggerContainer)
throw new Error('Trigger container not found')
await act(async () => {
fireEvent.mouseEnter(triggerContainer)
})
await act(async () => {
fireEvent.mouseLeave(triggerContainer!)
})
await act(async () => {
vi.advanceTimersByTime(50) // Halfway through timeout
fireEvent.mouseEnter(triggerContainer!)
})
await act(async () => {
vi.advanceTimersByTime(1000) // Much longer than the original timeout
})
expect(screen.getByTestId('content')).toBeInTheDocument()
})
it('should not open when disabled', async () => {
render(<CustomPopover {...defaultProps} disabled={true} trigger="click" />)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
})
it('should pass close function to htmlContent when manualClose is true', async () => {
vi.useRealTimers()
render(
<CustomPopover
{...defaultProps}
htmlContent={<CloseButtonContent />}
trigger="click"
manualClose={true}
/>,
)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
expect(screen.getByTestId('content')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('content'))
})
await waitFor(() => {
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
})
})
it('should not close when mouse leaves while already closed', async () => {
render(<CustomPopover {...defaultProps} />)
const triggerContainer = screen.getByTestId('trigger').closest('div')
if (!triggerContainer)
throw new Error('Trigger container not found')
await act(async () => {
fireEvent.mouseLeave(triggerContainer)
})
await act(async () => {
vi.runAllTimers()
})
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom class names', async () => {
render(
<CustomPopover
{...defaultProps}
trigger="click"
className="wrapper-class"
popupClassName="popup-inner-class"
btnClassName="btn-class"
/>,
)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
expect(document.querySelector('.wrapper-class')).toBeInTheDocument()
expect(document.querySelector('.popup-inner-class')).toBeInTheDocument()
const button = screen.getByTestId('trigger').parentElement
expect(button).toHaveClass('btn-class')
})
it('should handle btnClassName as a function', () => {
render(
<CustomPopover
{...defaultProps}
btnClassName={(open: boolean) => open ? 'btn-open' : 'btn-closed'}
/>,
)
const button = screen.getByTestId('trigger').parentElement
expect(button).toHaveClass('btn-closed')
})
it('should align popover panel to left when position is bl', async () => {
render(
<CustomPopover
{...defaultProps}
trigger="click"
position="bl"
/>,
)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
const panel = screen.getByTestId('content').closest('.absolute')
expect(panel).toHaveClass('left-0')
})
it('should align popover panel to right when position is br', async () => {
render(
<CustomPopover
{...defaultProps}
trigger="click"
position="br"
/>,
)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
const panel = screen.getByTestId('content').closest('.absolute')
expect(panel).toHaveClass('right-0')
})
})
})

View File

@ -1,120 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import CustomPopover from '.'
type PopoverContentProps = {
open?: boolean
onClose?: () => void
onClick?: () => void
title: string
description: string
}
const PopoverContent = ({ title, description, onClose }: PopoverContentProps) => {
return (
<div className="flex min-w-[220px] flex-col gap-2 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-text-tertiary">
{title}
</div>
<p className="text-sm leading-5 text-text-secondary">{description}</p>
<button
type="button"
className="self-start rounded-md border border-divider-subtle px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover"
onClick={onClose}
>
Dismiss
</button>
</div>
)
}
const Template = ({
trigger = 'hover',
position = 'bottom',
manualClose,
disabled,
}: {
trigger?: 'click' | 'hover'
position?: 'bottom' | 'bl' | 'br'
manualClose?: boolean
disabled?: boolean
}) => {
const [hoverHint] = useState(
trigger === 'hover'
? 'Hover over the badge to reveal quick tips.'
: 'Click the badge to open the contextual menu.',
)
return (
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<p className="text-sm text-text-secondary">{hoverHint}</p>
<div className="flex flex-wrap items-center gap-6">
<CustomPopover
trigger={trigger}
position={position}
manualClose={manualClose}
disabled={disabled}
btnElement={<span className="text-xs font-medium text-text-secondary">Popover trigger</span>}
htmlContent={(
<PopoverContent
title={trigger === 'hover' ? 'Quick help' : 'More actions'}
description={trigger === 'hover'
? 'Use hover-triggered popovers for light contextual hints and inline docs.'
: 'Click-triggered popovers are ideal for menus that require user decisions.'}
/>
)}
/>
</div>
</div>
)
}
const meta = {
title: 'Base/Feedback/Popover',
component: Template,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Headless UI popover wrapper supporting hover and click triggers. These examples highlight alignment controls and manual closing.',
},
},
},
argTypes: {
trigger: {
control: 'radio',
options: ['hover', 'click'],
},
position: {
control: 'radio',
options: ['bottom', 'bl', 'br'],
},
manualClose: { control: 'boolean' },
disabled: { control: 'boolean' },
},
args: {
trigger: 'hover',
position: 'bottom',
manualClose: false,
disabled: false,
},
tags: ['autodocs'],
} satisfies Meta<typeof Template>
export default meta
type Story = StoryObj<typeof meta>
export const HoverPopover: Story = {}
export const ClickPopover: Story = {
args: {
trigger: 'click',
position: 'br',
},
}
export const DisabledState: Story = {
args: {
disabled: true,
},
}

View File

@ -1,127 +0,0 @@
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import { cloneElement, Fragment, isValidElement, useRef } from 'react'
export type HtmlContentProps = {
open?: boolean
onClose?: () => void
onClick?: () => void
}
type IPopover = {
className?: string
htmlContent: React.ReactNode
popupClassName?: string
trigger?: 'click' | 'hover'
position?: 'bottom' | 'br' | 'bl'
btnElement?: string | React.ReactNode
btnClassName?: string | ((open: boolean) => string)
manualClose?: boolean
disabled?: boolean
}
const timeoutDuration = 100
export default function CustomPopover({
trigger = 'hover',
position = 'bottom',
htmlContent,
popupClassName,
btnElement,
className,
btnClassName,
manualClose,
disabled = false,
}: IPopover) {
const buttonRef = useRef<HTMLButtonElement>(null)
const timeOutRef = useRef<number | null>(null)
const onMouseEnter = (isOpen: boolean) => {
if (timeOutRef.current != null)
window.clearTimeout(timeOutRef.current)
if (!isOpen)
buttonRef.current?.click()
}
const onMouseLeave = (isOpen: boolean) => {
timeOutRef.current = window.setTimeout(() => {
if (isOpen)
buttonRef.current?.click()
}, timeoutDuration)
}
return (
<Popover className="relative">
{({ open }: { open: boolean }) => {
return (
<>
<div
{...(trigger !== 'hover'
? {}
: {
onMouseLeave: () => onMouseLeave(open),
onMouseEnter: () => onMouseEnter(open),
})}
>
<PopoverButton
ref={buttonRef}
disabled={disabled}
className={cn(
'group inline-flex items-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-base font-medium hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus:outline-hidden',
open && 'border-components-button-secondary-border bg-components-button-secondary-bg-hover',
(btnClassName && typeof btnClassName === 'string') && btnClassName,
(btnClassName && typeof btnClassName !== 'string') && btnClassName?.(open),
)}
>
{btnElement}
</PopoverButton>
<Transition as={Fragment}>
<PopoverPanel
className={cn(
'absolute z-10 mt-1 w-full max-w-sm px-4 sm:px-0 lg:max-w-3xl',
position === 'bottom' && 'left-1/2 -translate-x-1/2',
position === 'bl' && 'left-0',
position === 'br' && 'right-0',
className,
)}
{...(trigger !== 'hover'
? {}
: {
onMouseLeave: () => onMouseLeave(open),
onMouseEnter: () => onMouseEnter(open),
})
}
>
{({ close }) => (
<div
className={cn('w-fit min-w-[130px] overflow-hidden rounded-lg bg-components-panel-bg shadow-lg ring-1 ring-black/5', popupClassName)}
{...(trigger !== 'hover'
? {}
: {
onMouseLeave: () => onMouseLeave(open),
onMouseEnter: () => onMouseEnter(open),
})
}
>
{isValidElement(htmlContent)
? cloneElement(htmlContent as React.ReactElement<HtmlContentProps>, {
open,
onClose: close,
...(manualClose
? {
onClick: close,
}
: {}),
})
: htmlContent}
</div>
)}
</PopoverPanel>
</Transition>
</div>
</>
)
}}
</Popover>
)
}

View File

@ -1,7 +1,6 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { act } from 'react'
import TagSelector from '../selector'
import { useStore as useTagStore } from '../store'
@ -38,54 +37,6 @@ vi.mock('@/service/tag', () => ({
unBindTag,
}))
// Mock popover for deterministic open/close behavior in unit tests.
vi.mock('@/app/components/base/popover', () => {
type PopoverContentProps = {
open?: boolean
onClose?: () => void
}
type MockPopoverProps = {
htmlContent: React.ReactNode
btnElement?: React.ReactNode
btnClassName?: string | ((open: boolean) => string)
}
const MockPopover = ({ htmlContent, btnElement, btnClassName }: MockPopoverProps) => {
const [isOpen, setIsOpen] = React.useState(false)
const computedClassName = typeof btnClassName === 'function'
? btnClassName(isOpen)
: btnClassName
const content = React.isValidElement(htmlContent)
// eslint-disable-next-line react/no-clone-element
? React.cloneElement(htmlContent as React.ReactElement<PopoverContentProps>, {
open: isOpen,
onClose: () => setIsOpen(false),
})
: htmlContent
return (
<div data-testid="custom-popover">
<button
type="button"
aria-expanded={isOpen}
className={computedClassName}
onClick={() => setIsOpen(prev => !prev)}
>
{btnElement}
</button>
{isOpen && (
<div data-testid="popover-content">
{content}
</div>
)}
</div>
)
}
return { __esModule: true, default: MockPopover }
})
// i18n keys rendered in "ns.key" format
const i18n = {
addTag: 'common.tag.addTag',
@ -109,6 +60,12 @@ const defaultProps = {
}
describe('TagSelector', () => {
const getPanelTagRow = (tagName: string) => {
const row = screen.getAllByTestId('tag-row').find(tagRow => within(tagRow).queryByText(tagName))
expect(row).toBeDefined()
return row as HTMLElement
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(fetchTagList).mockResolvedValue(appTags)
@ -223,8 +180,8 @@ describe('TagSelector', () => {
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
await user.click(triggerButton)
const popoverContent = await screen.findByTestId('popover-content')
await user.click(within(popoverContent).getByText('Backend'))
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
await user.click(getPanelTagRow('Backend'))
// Close panel to trigger unmount side effects.
await user.click(triggerButton)
@ -244,8 +201,8 @@ describe('TagSelector', () => {
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
await user.click(triggerButton)
const popoverContent = await screen.findByTestId('popover-content')
await user.click(within(popoverContent).getByText('Frontend'))
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
await user.click(getPanelTagRow('Frontend'))
// Close panel to trigger unmount side effects.
await user.click(triggerButton)

View File

@ -1,5 +1,4 @@
import type { TagSelectorProps } from './selector'
import type { HtmlContentProps } from '@/app/components/base/popover'
import type { Tag } from '@/app/components/base/tag-management/constant'
import { useUnmount } from 'ahooks'
import { noop } from 'es-toolkit/function'
@ -15,7 +14,7 @@ import { useStore as useTagStore } from './store'
type PanelProps = {
onCreate: () => void
} & HtmlContentProps & TagSelectorProps
} & TagSelectorProps
const Panel = (props: PanelProps) => {
const { t } = useTranslation()
const { targetID, type, value, selectedTags, onCacheUpdate, onChange, onCreate } = props

View File

@ -1,8 +1,13 @@
import type { FC } from 'react'
import type { Tag } from '@/app/components/base/tag-management/constant'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback, useMemo } from 'react'
import CustomPopover from '@/app/components/base/popover'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { fetchTagList } from '@/service/tag'
import Panel from './panel'
import { useStore as useTagStore } from './store'
@ -17,7 +22,7 @@ export type TagSelectorProps = {
selectedTags: Tag[]
onCacheUpdate: (tags: Tag[]) => void
onChange?: () => void
minWidth?: string
minWidth?: number | string
}
const TagSelector: FC<TagSelectorProps> = ({
@ -31,8 +36,10 @@ const TagSelector: FC<TagSelectorProps> = ({
onChange,
minWidth,
}) => {
const { t } = useTranslation()
const tagList = useTagStore(s => s.tagList)
const setTagList = useTagStore(s => s.setTagList)
const [open, setOpen] = useState(false)
const getTagList = useCallback(async () => {
const res = await fetchTagList(type)
@ -45,35 +52,64 @@ const TagSelector: FC<TagSelectorProps> = ({
return []
}, [selectedTags, tagList])
return (
<>
{isPopover && (
<CustomPopover
htmlContent={(
<Panel
type={type}
targetID={targetID}
value={value}
selectedTags={selectedTags}
onCacheUpdate={onCacheUpdate}
onChange={onChange}
onCreate={getTagList}
/>
)}
position={position}
trigger="click"
btnElement={<Trigger tags={tags} />}
btnClassName={open =>
cn(
open ? 'bg-state-base-hover! text-text-secondary!' : 'bg-transparent!',
'w-full! border-0! p-0! text-text-tertiary! hover:bg-state-base-hover! hover:text-text-secondary!',
)}
popupClassName={cn('w-full! ring-0!', minWidth && 'min-w-80!')}
className="z-20! h-fit w-full!"
/>
)}
</>
const placement = useMemo(() => {
if (position === 'bl')
return 'bottom-start' as const
if (position === 'br')
return 'bottom-end' as const
return 'bottom' as const
}, [position])
const resolvedMinWidth = useMemo(() => {
if (minWidth == null)
return undefined
return typeof minWidth === 'number' ? `${minWidth}px` : minWidth
}, [minWidth])
const triggerLabel = useMemo(() => {
if (tags.length)
return tags.join(', ')
return t('tag.addTag', { ns: 'common' })
}, [tags, t])
if (!isPopover)
return null
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
aria-label={triggerLabel}
className={cn(
open ? 'bg-state-base-hover' : 'bg-transparent',
'block w-full rounded-lg border-0 p-0 text-left focus:outline-hidden',
)}
>
<Trigger tags={tags} />
</PopoverTrigger>
<PopoverContent
placement={placement}
sideOffset={4}
popupClassName="overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
popupProps={{
style: {
width: 'var(--anchor-width, auto)',
minWidth: resolvedMinWidth,
},
}}
>
<Panel
type={type}
targetID={targetID}
value={value}
selectedTags={selectedTags}
onCacheUpdate={onCacheUpdate}
onChange={onChange}
onCreate={getTagList}
/>
</PopoverContent>
</Popover>
)
}

View File

@ -5,14 +5,14 @@ import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import {
menuBackdropClassName,
menuGroupLabelClassName,
menuIndicatorClassName,
menuPopupAnimationClassName,
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
overlayBackdropClassName,
overlayGroupLabelClassName,
overlayIndicatorClassName,
overlayPopupAnimationClassName,
overlayPopupBaseClassName,
overlayRowClassName,
overlaySeparatorClassName,
} from '@/app/components/base/ui/overlay-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
export const ContextMenu = BaseContextMenu.Root
@ -65,7 +65,7 @@ function renderContextMenuPopup({
return (
<BaseContextMenu.Portal>
{withBackdrop && (
<BaseContextMenu.Backdrop className={menuBackdropClassName} />
<BaseContextMenu.Backdrop className={overlayBackdropClassName} />
)}
<BaseContextMenu.Positioner
side={side}
@ -77,8 +77,8 @@ function renderContextMenuPopup({
>
<BaseContextMenu.Popup
className={cn(
menuPopupBaseClassName,
menuPopupAnimationClassName,
overlayPopupBaseClassName,
overlayPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
@ -124,7 +124,7 @@ export function ContextMenuItem({
}: ContextMenuItemProps) {
return (
<BaseContextMenu.Item
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(overlayRowClassName, destructive && 'text-text-destructive', className)}
{...props}
/>
)
@ -142,7 +142,7 @@ export function ContextMenuLinkItem({
}: ContextMenuLinkItemProps) {
return (
<BaseContextMenu.LinkItem
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(overlayRowClassName, destructive && 'text-text-destructive', className)}
closeOnClick={closeOnClick}
{...props}
/>
@ -155,7 +155,7 @@ export function ContextMenuRadioItem({
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItem>) {
return (
<BaseContextMenu.RadioItem
className={cn(menuRowClassName, className)}
className={cn(overlayRowClassName, className)}
{...props}
/>
)
@ -167,7 +167,7 @@ export function ContextMenuCheckboxItem({
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItem>) {
return (
<BaseContextMenu.CheckboxItem
className={cn(menuRowClassName, className)}
className={cn(overlayRowClassName, className)}
{...props}
/>
)
@ -179,7 +179,7 @@ export function ContextMenuCheckboxItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItemIndicator>, 'children'>) {
return (
<BaseContextMenu.CheckboxItemIndicator
className={cn(menuIndicatorClassName, className)}
className={cn(overlayIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -193,7 +193,7 @@ export function ContextMenuRadioItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItemIndicator>, 'children'>) {
return (
<BaseContextMenu.RadioItemIndicator
className={cn(menuIndicatorClassName, className)}
className={cn(overlayIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -213,7 +213,7 @@ export function ContextMenuSubTrigger({
}: ContextMenuSubTriggerProps) {
return (
<BaseContextMenu.SubmenuTrigger
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(overlayRowClassName, destructive && 'text-text-destructive', className)}
{...props}
>
{children}
@ -261,7 +261,7 @@ export function ContextMenuGroupLabel({
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.GroupLabel>) {
return (
<BaseContextMenu.GroupLabel
className={cn(menuGroupLabelClassName, className)}
className={cn(overlayGroupLabelClassName, className)}
{...props}
/>
)
@ -273,7 +273,7 @@ export function ContextMenuSeparator({
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.Separator>) {
return (
<BaseContextMenu.Separator
className={cn(menuSeparatorClassName, className)}
className={cn(overlaySeparatorClassName, className)}
{...props}
/>
)

View File

@ -5,13 +5,13 @@ import { Menu } from '@base-ui/react/menu'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import {
menuGroupLabelClassName,
menuIndicatorClassName,
menuPopupAnimationClassName,
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
overlayGroupLabelClassName,
overlayIndicatorClassName,
overlayPopupAnimationClassName,
overlayPopupBaseClassName,
overlayRowClassName,
overlaySeparatorClassName,
} from '@/app/components/base/ui/overlay-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
export const DropdownMenu = Menu.Root
@ -26,7 +26,7 @@ export function DropdownMenuRadioItem({
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
return (
<Menu.RadioItem
className={cn(menuRowClassName, className)}
className={cn(overlayRowClassName, className)}
{...props}
/>
)
@ -38,7 +38,7 @@ export function DropdownMenuRadioItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
return (
<Menu.RadioItemIndicator
className={cn(menuIndicatorClassName, className)}
className={cn(overlayIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -52,7 +52,7 @@ export function DropdownMenuCheckboxItem({
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
return (
<Menu.CheckboxItem
className={cn(menuRowClassName, className)}
className={cn(overlayRowClassName, className)}
{...props}
/>
)
@ -64,7 +64,7 @@ export function DropdownMenuCheckboxItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
return (
<Menu.CheckboxItemIndicator
className={cn(menuIndicatorClassName, className)}
className={cn(overlayIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -78,7 +78,7 @@ export function DropdownMenuGroupLabel({
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
return (
<Menu.GroupLabel
className={cn(menuGroupLabelClassName, className)}
className={cn(overlayGroupLabelClassName, className)}
{...props}
/>
)
@ -135,8 +135,8 @@ function renderDropdownMenuPopup({
>
<Menu.Popup
className={cn(
menuPopupBaseClassName,
menuPopupAnimationClassName,
overlayPopupBaseClassName,
overlayPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
@ -182,7 +182,7 @@ export function DropdownMenuSubTrigger({
}: DropdownMenuSubTriggerProps) {
return (
<Menu.SubmenuTrigger
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(overlayRowClassName, destructive && 'text-text-destructive', className)}
{...props}
>
{children}
@ -235,7 +235,7 @@ export function DropdownMenuItem({
}: DropdownMenuItemProps) {
return (
<Menu.Item
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(overlayRowClassName, destructive && 'text-text-destructive', className)}
{...props}
/>
)
@ -253,7 +253,7 @@ export function DropdownMenuLinkItem({
}: DropdownMenuLinkItemProps) {
return (
<Menu.LinkItem
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(overlayRowClassName, destructive && 'text-text-destructive', className)}
closeOnClick={closeOnClick}
{...props}
/>
@ -266,7 +266,7 @@ export function DropdownMenuSeparator({
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
return (
<Menu.Separator
className={cn(menuSeparatorClassName, className)}
className={cn(overlaySeparatorClassName, className)}
{...props}
/>
)

View File

@ -1,7 +0,0 @@
export const menuRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-hidden data-highlighted:bg-state-base-hover data-disabled:cursor-not-allowed data-disabled:opacity-30'
export const menuIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent'
export const menuGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase'
export const menuSeparatorClassName = 'my-1 h-px bg-divider-subtle'
export const menuPopupBaseClassName = 'max-h-(--available-height) overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-hidden focus:outline-hidden focus-visible:outline-hidden backdrop-blur-[5px]'
export const menuPopupAnimationClassName = 'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none'
export const menuBackdropClassName = 'fixed inset-0 z-1002 bg-transparent transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none'

View File

@ -0,0 +1,7 @@
export const overlayRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-hidden data-highlighted:bg-state-base-hover data-disabled:cursor-not-allowed data-disabled:opacity-30'
export const overlayIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent'
export const overlayGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase'
export const overlaySeparatorClassName = 'my-1 h-px bg-divider-subtle'
export const overlayPopupBaseClassName = 'max-h-(--available-height) overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-hidden focus:outline-hidden focus-visible:outline-hidden backdrop-blur-[5px]'
export const overlayPopupAnimationClassName = 'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none'
export const overlayBackdropClassName = 'fixed inset-0 z-1002 bg-transparent transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none'

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../index'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '../index'
const renderOpenSelect = ({
rootProps = {},
@ -33,8 +33,14 @@ const renderOpenSelect = ({
}}
{...contentProps}
>
<SelectItem value="seattle">Seattle</SelectItem>
<SelectItem value="new-york">New York</SelectItem>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="new-york">
<SelectItemText>New York</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>,
)
@ -50,8 +56,14 @@ describe('Select wrappers', () => {
<SelectValue />
</SelectTrigger>
<SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}>
<SelectItem value="seattle">Seattle</SelectItem>
<SelectItem value="new-york">New York</SelectItem>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="new-york">
<SelectItemText>New York</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</form>,
@ -66,22 +78,6 @@ describe('Select wrappers', () => {
})
describe('SelectTrigger', () => {
it('should render clear button when clearable is true and loading is false', () => {
renderOpenSelect({
triggerProps: { clearable: true },
})
expect(screen.getByRole('button', { name: /clear selection/i })).toBeInTheDocument()
})
it('should hide clear button when loading is true', () => {
renderOpenSelect({
triggerProps: { clearable: true, loading: true },
})
expect(screen.queryByRole('button', { name: /clear selection/i })).not.toBeInTheDocument()
})
it('should forward native trigger props when trigger props are provided', () => {
renderOpenSelect({
triggerProps: {
@ -94,48 +90,6 @@ describe('Select wrappers', () => {
expect(trigger).toBeDisabled()
})
it('should call onClear and stop click propagation when clear button is clicked', () => {
const onClear = vi.fn()
const onTriggerClick = vi.fn()
renderOpenSelect({
triggerProps: {
clearable: true,
onClear,
onClick: onTriggerClick,
},
})
fireEvent.click(screen.getByRole('button', { name: /clear selection/i }))
expect(onClear).toHaveBeenCalledTimes(1)
expect(onTriggerClick).not.toHaveBeenCalled()
})
it('should stop mouse down propagation when clear button receives mouse down', () => {
const onTriggerMouseDown = vi.fn()
renderOpenSelect({
triggerProps: {
clearable: true,
onMouseDown: onTriggerMouseDown,
},
})
fireEvent.mouseDown(screen.getByRole('button', { name: /clear selection/i }))
expect(onTriggerMouseDown).not.toHaveBeenCalled()
})
it('should not throw when clear button is clicked without onClear handler', () => {
renderOpenSelect({
triggerProps: { clearable: true },
})
const clearButton = screen.getByRole('button', { name: /clear selection/i })
expect(() => fireEvent.click(clearButton)).not.toThrow()
})
it('should apply regular size variant classes by default', () => {
renderOpenSelect()
@ -182,26 +136,6 @@ describe('Select wrappers', () => {
expect(trigger.className).toContain('data-disabled:data-placeholder:text-components-input-text-disabled')
})
it('should show error icon and apply destructive styling when variant is destructive', () => {
renderOpenSelect({
triggerProps: { variant: 'destructive' },
})
const trigger = screen.getByRole('combobox', { name: 'city select' })
expect(trigger.className).toContain('border-components-input-border-destructive')
expect(trigger.className).toContain('bg-components-input-bg-destructive')
const errorIcon = trigger.querySelector('.i-ri-error-warning-line')
expect(errorIcon).toBeInTheDocument()
})
it('should hide clear button when variant is destructive even if clearable', () => {
renderOpenSelect({
triggerProps: { clearable: true, variant: 'destructive' },
})
expect(screen.queryByRole('button', { name: /clear selection/i })).not.toBeInTheDocument()
})
it('should apply readonly styling via data attributes when Root is readOnly', () => {
renderOpenSelect({
rootProps: { readOnly: true },
@ -236,6 +170,14 @@ describe('Select wrappers', () => {
const trigger = screen.getByRole('combobox', { name: 'city select' })
expect(trigger.className).toContain('data-placeholder:text-components-input-text-placeholder')
})
it('should render built-in chevron icon', () => {
renderOpenSelect()
const trigger = screen.getByRole('combobox', { name: 'city select' })
const chevron = trigger.querySelector('.i-ri-arrow-down-s-line')
expect(chevron).toBeInTheDocument()
})
})
describe('SelectContent', () => {
@ -291,7 +233,10 @@ describe('Select wrappers', () => {
'onFocus': onListFocus,
}}
>
<SelectItem value="seattle">Seattle</SelectItem>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>,
)
@ -330,9 +275,13 @@ describe('Select wrappers', () => {
<SelectValue />
</SelectTrigger>
<SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}>
<SelectItem value="seattle">Seattle</SelectItem>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="new-york" disabled aria-label="Disabled New York">
New York
<SelectItemText>New York</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>,
@ -342,5 +291,22 @@ describe('Select wrappers', () => {
expect(onValueChange).not.toHaveBeenCalled()
})
it('should support custom composition with SelectItemText without indicator', () => {
render(
<Select open defaultValue="a">
<SelectTrigger aria-label="custom select">
<SelectValue />
</SelectTrigger>
<SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}>
<SelectItem value="a" className="gap-2">
<SelectItemText>Custom Item</SelectItemText>
</SelectItem>
</SelectContent>
</Select>,
)
expect(screen.getByRole('option', { name: 'Custom Item' })).toBeInTheDocument()
})
})
})

View File

@ -1,115 +1,43 @@
'use client'
import type { VariantProps } from 'class-variance-authority'
import type { Placement } from '@/app/components/base/ui/placement'
import { Select as BaseSelect } from '@base-ui/react/select'
import { cn } from '@langgenius/dify-ui/cn'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import {
overlayGroupLabelClassName,
overlaySeparatorClassName,
} from '@/app/components/base/ui/overlay-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
export const Select = BaseSelect.Root
export const SelectValue = BaseSelect.Value
/** @public */
export const SelectGroup = BaseSelect.Group
/** @public */
export const SelectGroupLabel = BaseSelect.GroupLabel
/** @public */
export const SelectSeparator = BaseSelect.Separator
const selectTriggerVariants = cva(
'',
{
variants: {
size: {
small: 'h-6 gap-px rounded-md px-[5px] py-0 system-xs-regular',
regular: 'h-8 gap-0.5 rounded-lg px-2 py-1 system-sm-regular',
large: 'h-9 gap-0.5 rounded-[10px] px-2.5 py-1 system-md-regular',
},
variant: {
default: '',
destructive: 'border border-components-input-border-destructive bg-components-input-bg-destructive shadow-xs hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive',
},
},
defaultVariants: {
size: 'regular',
variant: 'default',
},
},
)
const contentPadding: Record<string, string> = {
small: 'px-[3px] py-1',
regular: 'p-1',
large: 'px-1.5 py-1',
const selectSizeClassName: Record<string, string> = {
small: 'h-6 gap-px rounded-md px-2 py-1 system-xs-regular',
regular: 'h-8 gap-0.5 rounded-lg px-3 py-2 system-sm-regular',
large: 'h-9 gap-0.5 rounded-[10px] px-4 py-2 system-md-regular',
}
type SelectTriggerProps = React.ComponentPropsWithoutRef<typeof BaseSelect.Trigger> & {
clearable?: boolean
onClear?: () => void
loading?: boolean
} & VariantProps<typeof selectTriggerVariants>
size?: 'small' | 'regular' | 'large'
}
export function SelectTrigger({
className,
children,
size = 'regular',
variant = 'default',
clearable = false,
onClear,
loading = false,
...props
}: SelectTriggerProps) {
const paddingClass = contentPadding[size ?? 'regular']
const isDestructive = variant === 'destructive'
let trailingIcon: React.ReactNode = null
if (loading) {
trailingIcon = (
<span className="shrink-0 text-text-quaternary" aria-hidden="true">
<span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
</span>
)
}
else if (isDestructive) {
trailingIcon = (
<span className="shrink-0 text-text-destructive-secondary" aria-hidden="true">
<span className="i-ri-error-warning-line h-4 w-4" />
</span>
)
}
else if (clearable) {
trailingIcon = (
<span
role="button"
aria-label="Clear selection"
tabIndex={-1}
className="shrink-0 cursor-pointer text-text-quaternary group-data-disabled:hidden group-data-readonly:hidden hover:text-text-secondary"
onClick={(e) => {
e.stopPropagation()
onClear?.()
}}
onMouseDown={e => e.stopPropagation()}
>
<span className="i-ri-close-circle-fill h-3.5 w-3.5" aria-hidden="true" />
</span>
)
}
else {
trailingIcon = (
<BaseSelect.Icon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary group-data-readonly:hidden data-open:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />
</BaseSelect.Icon>
)
}
return (
<BaseSelect.Trigger
className={cn(
'group relative flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
'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',
'data-placeholder:text-components-input-text-placeholder',
selectTriggerVariants({ size, variant }),
selectSizeClassName[size],
'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',
'data-disabled:data-placeholder:text-components-input-text-disabled',
@ -117,14 +45,41 @@ export function SelectTrigger({
)}
{...props}
>
<span className={cn('min-w-0 grow truncate', paddingClass)}>
<span className="min-w-0 grow truncate">
{children}
</span>
{trailingIcon}
<BaseSelect.Icon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary group-data-readonly:hidden data-open:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />
</BaseSelect.Icon>
</BaseSelect.Trigger>
)
}
export function SelectGroupLabel({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.GroupLabel>) {
return (
<BaseSelect.GroupLabel
className={cn(overlayGroupLabelClassName, className)}
{...props}
/>
)
}
/** @public */
export function SelectSeparator({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.Separator>) {
return (
<BaseSelect.Separator
className={cn(overlaySeparatorClassName, className)}
{...props}
/>
)
}
type SelectContentProps = {
children: React.ReactNode
placement?: Placement
@ -174,14 +129,14 @@ export function SelectContent({
>
<BaseSelect.Popup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'min-w-(--anchor-width) rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
popupClassName,
)}
{...popupProps}
>
<BaseSelect.List
className={cn('max-h-80 min-w-40 overflow-auto p-1 outline-hidden', listClassName)}
className={cn('max-h-80 overflow-auto p-1 outline-hidden', listClassName)}
{...listProps}
>
{children}
@ -194,7 +149,6 @@ export function SelectContent({
export function SelectItem({
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.Item>) {
return (
@ -205,13 +159,32 @@ export function SelectItem({
className,
)}
{...props}
>
<BaseSelect.ItemText className="mr-1 min-w-0 grow truncate px-1">
{children}
</BaseSelect.ItemText>
<BaseSelect.ItemIndicator className="flex shrink-0 items-center text-text-accent">
<span className="i-ri-check-line h-4 w-4" aria-hidden="true" />
</BaseSelect.ItemIndicator>
</BaseSelect.Item>
/>
)
}
export function SelectItemText({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.ItemText>) {
return (
<BaseSelect.ItemText
className={cn('mr-1 min-w-0 grow truncate px-1', className)}
{...props}
/>
)
}
export function SelectItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.ItemIndicator>, 'children'>) {
return (
<BaseSelect.ItemIndicator
className={cn('ml-auto flex shrink-0 items-center text-text-accent', className)}
{...props}
>
<span className="i-ri-check-line h-4 w-4" aria-hidden />
</BaseSelect.ItemIndicator>
)
}

View File

@ -37,14 +37,12 @@ describe('Actions', () => {
it('should render add icon', () => {
const { container } = render(<Actions {...defaultProps} />)
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(0)
expect(container.querySelector('.i-ri-add-line')).toBeInTheDocument()
})
it('should render arrow icon for details', () => {
const { container } = render(<Actions {...defaultProps} />)
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(1)
expect(container.querySelector('.i-ri-arrow-right-up-line')).toBeInTheDocument()
})
})
@ -83,6 +81,16 @@ describe('Actions', () => {
expect(defaultProps.handleShowTemplateDetails).toHaveBeenCalledTimes(1)
})
it('should open more operations menu and close it after selecting edit', async () => {
render(<Actions {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
const editButton = await screen.findByText(/operations\.editInfo/i)
fireEvent.click(editButton)
expect(defaultProps.openEditModal).toHaveBeenCalledTimes(1)
})
})
describe('Layout', () => {

View File

@ -1,8 +1,12 @@
import { RiAddLine, RiArrowRightUpLine, RiMoreFill } from '@remixicon/react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import CustomPopover from '@/app/components/base/popover'
import { Button } from '@/app/components/base/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import Operations from './operations'
type ActionsProps = {
@ -23,15 +27,21 @@ const Actions = ({
handleDelete,
}: ActionsProps) => {
const { t } = useTranslation()
const [isMoreOperationsOpen, setIsMoreOperationsOpen] = React.useState(false)
return (
<div className="absolute bottom-0 left-0 z-10 hidden w-full items-center gap-x-1 bg-pipeline-template-card-hover-bg p-4 pt-8 group-hover:flex">
<div
className={cn(
'absolute bottom-0 left-0 z-10 w-full items-center gap-x-1 bg-pipeline-template-card-hover-bg p-4 pt-8',
isMoreOperationsOpen ? 'flex' : 'hidden group-hover:flex',
)}
>
<Button
variant="primary"
onClick={onApplyTemplate}
className="grow gap-x-0.5"
>
<RiAddLine className="size-4" />
<span aria-hidden className="i-ri-add-line size-4" />
<span className="px-0.5">{t('operations.choose', { ns: 'datasetPipeline' })}</span>
</Button>
<Button
@ -39,28 +49,35 @@ const Actions = ({
onClick={handleShowTemplateDetails}
className="grow gap-x-0.5"
>
<RiArrowRightUpLine className="size-4" />
<span aria-hidden className="i-ri-arrow-right-up-line size-4" />
<span className="px-0.5">{t('operations.details', { ns: 'datasetPipeline' })}</span>
</Button>
{
showMoreOperations && (
<CustomPopover
htmlContent={(
<DropdownMenu open={isMoreOperationsOpen} onOpenChange={setIsMoreOperationsOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex size-8 cursor-pointer items-center justify-center rounded-lg p-0 shadow-xs shadow-shadow-shadow-3',
isMoreOperationsOpen && 'bg-state-base-hover',
)}
onClick={e => e.stopPropagation()}
>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[160px] border-0 bg-transparent py-0 shadow-none backdrop-blur-none"
>
<Operations
openEditModal={openEditModal}
onExport={handleExportDSL}
onDelete={handleDelete}
onClose={() => setIsMoreOperationsOpen(false)}
/>
)}
className="z-20 min-w-[160px]"
popupClassName="rounded-xl bg-none shadow-none ring-0 min-w-[160px]"
position="br"
trigger="click"
btnElement={
<RiMoreFill className="size-4 text-text-tertiary" />
}
btnClassName="size-8 cursor-pointer justify-center rounded-lg p-0 shadow-xs shadow-shadow-shadow-3"
/>
</DropdownMenuContent>
</DropdownMenu>
)
}
</div>

View File

@ -6,30 +6,35 @@ type OperationsProps = {
openEditModal: () => void
onDelete: () => void
onExport: () => void
onClose?: () => void
}
const Operations = ({
openEditModal,
onDelete,
onExport,
onClose,
}: OperationsProps) => {
const { t } = useTranslation()
const onClickEdit = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
onClose?.()
openEditModal()
}
const onClickExport = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
onClose?.()
onExport()
}
const onClickDelete = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.preventDefault()
onClose?.()
onDelete()
}
@ -40,7 +45,7 @@ const Operations = ({
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
onClick={onClickEdit}
>
<span className="system-md-regular px-1 text-text-secondary">
<span className="px-1 system-md-regular text-text-secondary">
{t('operations.editInfo', { ns: 'datasetPipeline' })}
</span>
</div>
@ -48,7 +53,7 @@ const Operations = ({
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
onClick={onClickExport}
>
<span className="system-md-regular px-1 text-text-secondary">
<span className="px-1 system-md-regular text-text-secondary">
{t('operations.exportPipeline', { ns: 'datasetPipeline' })}
</span>
</div>
@ -59,7 +64,7 @@ const Operations = ({
className="group flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-destructive-hover"
onClick={onClickDelete}
>
<span className="system-md-regular px-1 text-text-secondary group-hover:text-text-destructive">
<span className="px-1 system-md-regular text-text-secondary group-hover:text-text-destructive">
{t('operation.delete', { ns: 'common' })}
</span>
</div>

View File

@ -1,13 +1,12 @@
import type { ILanguageSelectProps } from '../index'
import { fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { languages } from '@/i18n-config/language'
import LanguageSelect from '../index'
// Get supported languages for test assertions
const supportedLanguages = languages.filter(lang => lang.supported)
const supportedLanguages = languages.filter(language => language.supported)
// Test data builder for props
const createDefaultProps = (overrides?: Partial<ILanguageSelectProps>): ILanguageSelectProps => ({
currentLanguage: 'English',
onSelect: vi.fn(),
@ -15,264 +14,163 @@ const createDefaultProps = (overrides?: Partial<ILanguageSelectProps>): ILanguag
...overrides,
})
const openSelect = async () => {
await act(async () => {
fireEvent.click(screen.getByRole('combobox', { name: 'language' }))
})
return screen.findByRole('listbox')
}
describe('LanguageSelect', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering Tests - Verify component renders correctly
// Rendering
describe('Rendering', () => {
it('should render without crashing', () => {
const props = createDefaultProps()
it('should render the current language in the trigger', () => {
render(<LanguageSelect {...createDefaultProps()} />)
render(<LanguageSelect {...props} />)
expect(screen.getByText('English')).toBeInTheDocument()
const trigger = screen.getByRole('combobox', { name: 'language' })
expect(trigger).toBeInTheDocument()
expect(trigger).toHaveTextContent('English')
})
it('should render current language text', () => {
const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' })
it('should render non-listed current language values', () => {
render(<LanguageSelect {...createDefaultProps({ currentLanguage: 'NonExistentLanguage' })} />)
render(<LanguageSelect {...props} />)
expect(screen.getByText('Chinese Simplified')).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'language' })).toHaveTextContent('NonExistentLanguage')
})
it('should render dropdown arrow icon', () => {
const props = createDefaultProps()
it('should render a placeholder when current language is empty', () => {
render(<LanguageSelect {...createDefaultProps({ currentLanguage: '' })} />)
const { container } = render(<LanguageSelect {...props} />)
// Assert - RiArrowDownSLine renders as SVG
const svgIcon = container.querySelector('svg')
expect(svgIcon).toBeInTheDocument()
})
it('should render all supported languages in dropdown when opened', () => {
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
// Act - Click button to open dropdown
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - All supported languages should be visible
// Use getAllByText because current language appears both in button and dropdown
supportedLanguages.forEach((lang) => {
expect(screen.getAllByText(lang.prompt_name).length).toBeGreaterThanOrEqual(1)
})
})
it('should render check icon for selected language', () => {
const selectedLanguage = 'Japanese'
const props = createDefaultProps({ currentLanguage: selectedLanguage })
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - The selected language option should have a check icon
const languageOptions = screen.getAllByText(selectedLanguage)
// One in the button, one in the dropdown list
expect(languageOptions.length).toBeGreaterThanOrEqual(1)
expect(screen.getByRole('combobox', { name: 'language' }).textContent).toBe('\u00A0')
})
})
// Props Testing - Verify all prop variations work correctly
describe('Props', () => {
describe('currentLanguage prop', () => {
it('should display English when currentLanguage is English', () => {
const props = createDefaultProps({ currentLanguage: 'English' })
render(<LanguageSelect {...props} />)
expect(screen.getByText('English')).toBeInTheDocument()
})
// Dropdown behavior
describe('Dropdown behavior', () => {
it('should render all supported languages when the select is opened', async () => {
render(<LanguageSelect {...createDefaultProps()} />)
it('should display Chinese Simplified when currentLanguage is Chinese Simplified', () => {
const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' })
render(<LanguageSelect {...props} />)
expect(screen.getByText('Chinese Simplified')).toBeInTheDocument()
expect(await openSelect()).toBeInTheDocument()
supportedLanguages.forEach((language) => {
expect(screen.getByRole('option', { name: language.prompt_name })).toBeInTheDocument()
})
})
it('should display Japanese when currentLanguage is Japanese', () => {
const props = createDefaultProps({ currentLanguage: 'Japanese' })
render(<LanguageSelect {...props} />)
expect(screen.getByText('Japanese')).toBeInTheDocument()
it('should only render supported languages in the dropdown', async () => {
render(<LanguageSelect {...createDefaultProps()} />)
await openSelect()
const unsupportedLanguages = languages.filter(language => !language.supported)
unsupportedLanguages.forEach((language) => {
expect(screen.queryByRole('option', { name: language.prompt_name })).not.toBeInTheDocument()
})
})
it.each(supportedLanguages.map(l => l.prompt_name))(
'should display %s as current language',
(language) => {
const props = createDefaultProps({ currentLanguage: language })
render(<LanguageSelect {...props} />)
expect(screen.getByText(language)).toBeInTheDocument()
it('should mark the selected language inside the opened list', async () => {
render(<LanguageSelect {...createDefaultProps({ currentLanguage: 'Japanese' })} />)
await openSelect()
const selectedOption = await screen.findByRole('option', { name: 'Japanese' })
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
})
})
// Interaction
describe('Interaction', () => {
it('should call onSelect when a different language is chosen', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(<LanguageSelect {...createDefaultProps({ onSelect })} />)
await user.click(screen.getByRole('combobox', { name: 'language' }))
const listbox = await screen.findByRole('listbox')
await user.click(within(listbox).getByRole('option', { name: 'French' }))
await waitFor(() => {
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith('French')
})
})
it('should re-render with the new language value', () => {
const { rerender } = render(<LanguageSelect {...createDefaultProps()} />)
rerender(<LanguageSelect {...createDefaultProps({ currentLanguage: 'French' })} />)
expect(screen.getByRole('combobox', { name: 'language' })).toHaveTextContent('French')
})
it('should ignore null values emitted by the select control', async () => {
vi.resetModules()
vi.doMock('@/app/components/base/ui/select', () => ({
Select: ({ onValueChange, children }: { onValueChange?: (value: string | null) => void, children: React.ReactNode }) => {
React.useEffect(() => {
onValueChange?.(null)
}, [onValueChange])
return <div>{children}</div>
},
)
})
SelectTrigger: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type="button" {...props}>{children}</button>,
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItemText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => null,
}))
describe('disabled prop', () => {
it('should have disabled button when disabled is true', () => {
const props = createDefaultProps({ disabled: true })
const { default: IsolatedLanguageSelect } = await import('../index')
const onSelect = vi.fn()
render(<LanguageSelect {...props} />)
render(<IsolatedLanguageSelect currentLanguage="English" onSelect={onSelect} />)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
await waitFor(() => {
expect(onSelect).not.toHaveBeenCalled()
})
it('should have enabled button when disabled is false', () => {
const props = createDefaultProps({ disabled: false })
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
expect(button).not.toBeDisabled()
})
it('should have enabled button when disabled is undefined', () => {
const props = createDefaultProps()
delete (props as Partial<ILanguageSelectProps>).disabled
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
expect(button).not.toBeDisabled()
})
it('should apply disabled styling when disabled is true', () => {
const props = createDefaultProps({ disabled: true })
const { container } = render(<LanguageSelect {...props} />)
// Assert - Check for disabled class on text elements
const disabledTextElement = container.querySelector('.text-components-button-tertiary-text-disabled')
expect(disabledTextElement).toBeInTheDocument()
})
it('should apply cursor-not-allowed styling when disabled', () => {
const props = createDefaultProps({ disabled: true })
const { container } = render(<LanguageSelect {...props} />)
const elementWithCursor = container.querySelector('.cursor-not-allowed')
expect(elementWithCursor).toBeInTheDocument()
})
})
describe('onSelect prop', () => {
it('should be callable as a function', () => {
const mockOnSelect = vi.fn()
const props = createDefaultProps({ onSelect: mockOnSelect })
render(<LanguageSelect {...props} />)
// Open dropdown and click a language
const button = screen.getByRole('button')
fireEvent.click(button)
const germanOption = screen.getByText('German')
fireEvent.click(germanOption)
expect(mockOnSelect).toHaveBeenCalledWith('German')
})
vi.doUnmock('@/app/components/base/ui/select')
})
})
// User Interactions - Test event handlers
describe('User Interactions', () => {
it('should open dropdown when button is clicked', () => {
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
// Disabled state
describe('Disabled state', () => {
it('should disable the trigger when disabled is true', () => {
render(<LanguageSelect {...createDefaultProps({ disabled: true })} />)
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Check if dropdown content is visible
expect(screen.getAllByText('English').length).toBeGreaterThanOrEqual(1)
const trigger = screen.getByRole('combobox', { name: 'language' })
expect(trigger).toBeDisabled()
expect(trigger).toHaveClass('cursor-not-allowed')
})
it('should call onSelect when a language option is clicked', () => {
const mockOnSelect = vi.fn()
const props = createDefaultProps({ onSelect: mockOnSelect })
render(<LanguageSelect {...props} />)
it('should not open the listbox when disabled', () => {
render(<LanguageSelect {...createDefaultProps({ disabled: true })} />)
const button = screen.getByRole('button')
fireEvent.click(button)
const frenchOption = screen.getByText('French')
fireEvent.click(frenchOption)
fireEvent.click(screen.getByRole('combobox', { name: 'language' }))
expect(mockOnSelect).toHaveBeenCalledTimes(1)
expect(mockOnSelect).toHaveBeenCalledWith('French')
})
it('should call onSelect with correct language when selecting different languages', () => {
const mockOnSelect = vi.fn()
const props = createDefaultProps({ onSelect: mockOnSelect })
render(<LanguageSelect {...props} />)
// Act & Assert - Test multiple language selections
const testLanguages = ['Korean', 'Spanish', 'Italian']
testLanguages.forEach((lang) => {
mockOnSelect.mockClear()
const button = screen.getByRole('button')
fireEvent.click(button)
const languageOption = screen.getByText(lang)
fireEvent.click(languageOption)
expect(mockOnSelect).toHaveBeenCalledWith(lang)
})
})
it('should not open dropdown when disabled', () => {
const props = createDefaultProps({ disabled: true })
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Dropdown should not open, only one instance of the current language should exist
const englishElements = screen.getAllByText('English')
expect(englishElements.length).toBe(1) // Only the button text, not dropdown
})
it('should not call onSelect when component is disabled', () => {
const mockOnSelect = vi.fn()
const props = createDefaultProps({ onSelect: mockOnSelect, disabled: true })
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(mockOnSelect).not.toHaveBeenCalled()
})
it('should handle rapid consecutive clicks', () => {
const mockOnSelect = vi.fn()
const props = createDefaultProps({ onSelect: mockOnSelect })
render(<LanguageSelect {...props} />)
// Act - Rapid clicks
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
// Assert - Component should not crash
expect(button).toBeInTheDocument()
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
})
// Component Memoization - Test React.memo behavior
describe('Memoization', () => {
// Styling and memoization
describe('Styling and memoization', () => {
it('should apply the compact tertiary trigger styles', () => {
render(<LanguageSelect {...createDefaultProps()} />)
const trigger = screen.getByRole('combobox', { name: 'language' })
expect(trigger).toHaveClass('mx-1', 'bg-components-button-tertiary-bg', 'text-components-button-tertiary-text')
})
it('should be wrapped with React.memo', () => {
// Assert - Check component has memo wrapper
expect(LanguageSelect.$$typeof).toBe(Symbol.for('react.memo'))
})
it('should not re-render when props remain the same', () => {
const mockOnSelect = vi.fn()
const props = createDefaultProps({ onSelect: mockOnSelect })
it('should avoid re-rendering when props stay the same', () => {
const renderSpy = vi.fn()
const props = createDefaultProps()
// Create a wrapper component to track renders
const TrackedLanguageSelect: React.FC<ILanguageSelectProps> = (trackedProps) => {
renderSpy()
return <LanguageSelect {...trackedProps} />
@ -282,224 +180,7 @@ describe('LanguageSelect', () => {
const { rerender } = render(<MemoizedTracked {...props} />)
rerender(<MemoizedTracked {...props} />)
// Assert - Should only render once due to same props
expect(renderSpy).toHaveBeenCalledTimes(1)
})
it('should re-render when currentLanguage changes', () => {
const props = createDefaultProps({ currentLanguage: 'English' })
const { rerender } = render(<LanguageSelect {...props} />)
expect(screen.getByText('English')).toBeInTheDocument()
rerender(<LanguageSelect {...props} currentLanguage="French" />)
expect(screen.getByText('French')).toBeInTheDocument()
})
it('should re-render when disabled changes', () => {
const props = createDefaultProps({ disabled: false })
const { rerender } = render(<LanguageSelect {...props} />)
expect(screen.getByRole('button')).not.toBeDisabled()
rerender(<LanguageSelect {...props} disabled={true} />)
expect(screen.getByRole('button')).toBeDisabled()
})
})
// Edge Cases - Test boundary conditions and error handling
describe('Edge Cases', () => {
it('should handle empty string as currentLanguage', () => {
const props = createDefaultProps({ currentLanguage: '' })
render(<LanguageSelect {...props} />)
// Assert - Component should still render
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should handle non-existent language as currentLanguage', () => {
const props = createDefaultProps({ currentLanguage: 'NonExistentLanguage' })
render(<LanguageSelect {...props} />)
// Assert - Should display the value even if not in list
expect(screen.getByText('NonExistentLanguage')).toBeInTheDocument()
})
it('should handle special characters in language names', () => {
// Arrange - Turkish has special character in prompt_name
const props = createDefaultProps({ currentLanguage: 'Türkçe' })
render(<LanguageSelect {...props} />)
expect(screen.getByText('Türkçe')).toBeInTheDocument()
})
it('should handle very long language names', () => {
const longLanguageName = 'A'.repeat(100)
const props = createDefaultProps({ currentLanguage: longLanguageName })
render(<LanguageSelect {...props} />)
// Assert - Should not crash and should display the text
expect(screen.getByText(longLanguageName)).toBeInTheDocument()
})
it('should render correct number of language options', () => {
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Should show all supported languages
const expectedCount = supportedLanguages.length
// Each language appears in the dropdown (use getAllByText because current language appears twice)
supportedLanguages.forEach((lang) => {
expect(screen.getAllByText(lang.prompt_name).length).toBeGreaterThanOrEqual(1)
})
expect(supportedLanguages.length).toBe(expectedCount)
})
it('should only show supported languages in dropdown', () => {
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - All displayed languages should be supported
const allLanguages = languages
const unsupportedLanguages = allLanguages.filter(lang => !lang.supported)
unsupportedLanguages.forEach((lang) => {
expect(screen.queryByText(lang.prompt_name)).not.toBeInTheDocument()
})
})
it('should handle undefined onSelect gracefully when clicking', () => {
// Arrange - This tests TypeScript boundary, but runtime should not crash
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
const option = screen.getByText('German')
// Assert - Should not throw
expect(() => fireEvent.click(option)).not.toThrow()
})
it('should maintain selection state visually with check icon', () => {
const props = createDefaultProps({ currentLanguage: 'Russian' })
const { container } = render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Find the check icon (RiCheckLine) in the dropdown
// The selected option should have a check icon next to it
const checkIcons = container.querySelectorAll('svg.text-text-accent')
expect(checkIcons.length).toBeGreaterThanOrEqual(1)
})
})
// Accessibility - Basic accessibility checks
describe('Accessibility', () => {
it('should have accessible button element', () => {
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should have clickable language options', () => {
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Options should be clickable (have cursor-pointer class)
const options = screen.getAllByText(/English|French|German|Japanese/i)
expect(options.length).toBeGreaterThan(0)
})
})
// Integration with Popover - Test Popover behavior
describe('Popover Integration', () => {
it('should use manualClose prop on Popover', () => {
const mockOnSelect = vi.fn()
const props = createDefaultProps({ onSelect: mockOnSelect })
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Popover should be open
expect(screen.getAllByText('English').length).toBeGreaterThanOrEqual(1)
})
it('should have correct popup z-index class', () => {
const props = createDefaultProps()
const { container } = render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Check for z-20 class (popupClassName='z-20')
// This is applied to the Popover
expect(container.querySelector('.z-20')).toBeTruthy()
})
})
// Styling Tests - Verify correct CSS classes applied
describe('Styling', () => {
it('should apply tertiary button styling', () => {
const props = createDefaultProps()
const { container } = render(<LanguageSelect {...props} />)
// Assert - Check for tertiary button classes (Tailwind v4 uses ! suffix)
expect(container.querySelector('.bg-components-button-tertiary-bg\\!')).toBeInTheDocument()
})
it('should apply hover styling class to options', () => {
const props = createDefaultProps()
const { container } = render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Options should have hover class
const optionWithHover = container.querySelector('.hover\\:bg-state-base-hover')
expect(optionWithHover).toBeInTheDocument()
})
it('should apply correct text styling to language options', () => {
const props = createDefaultProps()
const { container } = render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Check for system-sm-medium class on options
const styledOption = container.querySelector('.system-sm-medium')
expect(styledOption).toBeInTheDocument()
})
it('should apply disabled styling to icon when disabled', () => {
const props = createDefaultProps({ disabled: true })
const { container } = render(<LanguageSelect {...props} />)
// Assert - Check for disabled text color on icon
const disabledIcon = container.querySelector('.text-components-button-tertiary-text-disabled')
expect(disabledIcon).toBeInTheDocument()
})
})
})

View File

@ -1,9 +1,8 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import * as React from 'react'
import Popover from '@/app/components/base/popover'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@/app/components/base/ui/select'
import { languages } from '@/i18n-config/language'
export type ILanguageSelectProps = {
@ -17,48 +16,42 @@ const LanguageSelect: FC<ILanguageSelectProps> = ({
onSelect,
disabled,
}) => {
const supportedLanguages = languages.filter(language => language.supported)
return (
<Popover
manualClose
trigger="click"
<Select
value={currentLanguage}
onValueChange={(value) => {
if (value == null)
return
onSelect(value)
}}
disabled={disabled}
popupClassName="z-20"
htmlContent={(
<div className="w-full p-1">
{languages.filter(language => language.supported).map(({ prompt_name }) => (
<div
key={prompt_name}
className="inline-flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2 hover:bg-state-base-hover"
onClick={() => onSelect(prompt_name)}
>
<span className="system-sm-medium text-text-secondary">{prompt_name}</span>
{(currentLanguage === prompt_name) && <RiCheckLine className="size-4 text-text-accent" />}
</div>
))}
</div>
)}
btnElement={(
<div className={cn('inline-flex items-center gap-x-px', disabled && 'cursor-not-allowed')}>
<span className={cn(
'px-[3px] system-xs-semibold text-components-button-tertiary-text',
disabled ? 'text-components-button-tertiary-text-disabled' : '',
)}
>
{currentLanguage}
</span>
<RiArrowDownSLine className={cn(
'size-3.5 text-components-button-tertiary-text',
disabled ? 'text-components-button-tertiary-text-disabled' : '',
)}
/>
</div>
)}
btnClassName={() => cn(
'!hover:bg-components-button-tertiary-bg mx-1! rounded-md border-0! bg-components-button-tertiary-bg! px-1.5! py-1!',
disabled ? 'bg-components-button-tertiary-bg-disabled' : '',
)}
className="left-1! z-20! h-fit w-[140px]! translate-x-0!"
/>
>
<SelectTrigger
size="small"
aria-label="language"
className={cn(
'mx-1 w-auto shrink-0 bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-tertiary-bg',
disabled && 'cursor-not-allowed bg-components-button-tertiary-bg-disabled text-components-button-tertiary-text-disabled hover:bg-components-button-tertiary-bg-disabled',
)}
>
{currentLanguage || <span>&nbsp;</span>}
</SelectTrigger>
<SelectContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-max"
listClassName="no-scrollbar"
>
{supportedLanguages.map(({ prompt_name }) => (
<SelectItem key={prompt_name} value={prompt_name}>
<SelectItemText>{prompt_name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export default React.memo(LanguageSelect)

View File

@ -380,8 +380,7 @@ describe('DocumentList', () => {
})
}
// After clicking rename, the modal should potentially be visible
expect(screen.getByRole('table')).toBeInTheDocument()
expect(screen.getByRole('dialog', { name: 'datasetDocuments.list.table.rename' })).toBeInTheDocument()
})
it('should call onUpdate when document is renamed', () => {

View File

@ -2,15 +2,12 @@ import type { OperationName } from '../types'
import type { CommonResponse } from '@/models/common'
import type { DocumentDownloadResponse } from '@/service/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArchive2Line, RiDeleteBinLine, RiDownload2Line, RiEditLine, RiEqualizer2Line, RiLoopLeftLine, RiMoreFill, RiPauseCircleLine, RiPlayCircleLine } from '@remixicon/react'
import { useBoolean, useDebounceFn } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { SearchLinesSparkle } from '@/app/components/base/icons/src/vender/knowledge'
import CustomPopover from '@/app/components/base/popover'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import {
@ -22,6 +19,11 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { toast } from '@/app/components/base/ui/toast'
import { IS_CE_EDITION } from '@/config'
import { DataSourceType, DocumentActionType } from '@/models/datasets'
@ -53,6 +55,7 @@ type OperationsProps = {
const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSelectedIdChange, onUpdate, scene = 'list', className = '' }: OperationsProps) => {
const { id, name, enabled = false, archived = false, data_source_type, display_status } = detail || {}
const [showModal, setShowModal] = useState(false)
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
const [deleting, setDeleting] = useState(false)
const { t } = useTranslation()
const router = useRouter()
@ -68,7 +71,7 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
const { mutateAsync: pauseDocument } = useDocumentPause()
const { mutateAsync: resumeDocument } = useDocumentResume()
const isListScene = scene === 'list'
const onOperate = async (operationName: OperationName) => {
const onOperate = useCallback(async (operationName: OperationName) => {
let opApi
switch (operationName) {
case 'archive':
@ -116,7 +119,25 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
}
if (operationName === DocumentActionType.delete)
setDeleting(false)
}
}, [
archiveDocument,
data_source_type,
datasetId,
deleteDocument,
disableDocument,
enableDocument,
generateSummary,
id,
onSelectedIdChange,
onUpdate,
pauseDocument,
resumeDocument,
selectedIds,
syncDocument,
syncWebsite,
t,
unArchiveDocument,
])
const { run: handleSwitch } = useDebounceFn((operationName: OperationName) => {
if (operationName === DocumentActionType.enable && enabled)
return
@ -139,6 +160,9 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
const handleRenamed = useCallback(() => {
onUpdate()
}, [onUpdate])
const closeOperationsMenu = useCallback(() => {
setIsOperationsMenuOpen(false)
}, [])
const handleDownload = useCallback(async () => {
// Avoid repeated clicks while the signed URL request is in-flight.
if (isDownloading)
@ -152,6 +176,28 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
// Trigger download without navigating away (helps avoid duplicate downloads in some browsers).
downloadUrl({ url: res.url, fileName: name })
}, [datasetId, downloadDocument, id, isDownloading, name, t])
const handleShowRename = useCallback(() => {
closeOperationsMenu()
handleShowRenameModal({
id: detail.id,
name: detail.name,
})
}, [closeOperationsMenu, detail.id, detail.name, handleShowRenameModal])
const handleMenuOperation = useCallback((operationName: OperationName) => {
closeOperationsMenu()
void onOperate(operationName)
}, [closeOperationsMenu, onOperate])
const handleDeleteClick = useCallback(() => {
closeOperationsMenu()
setShowModal(true)
}, [closeOperationsMenu])
const handleDownloadClick = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
closeOperationsMenu()
void handleDownload()
}, [closeOperationsMenu, handleDownload])
return (
<div className="flex items-center" onClick={e => e.stopPropagation()}>
{isListScene && !embeddingAvailable && (<Switch checked={false} onCheckedChange={noop} disabled={true} size="md" />)}
@ -179,49 +225,56 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
: 'p-0.5 hover:bg-state-base-hover')}
onClick={() => router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`)}
>
<RiEqualizer2Line className="h-4 w-4 text-components-button-secondary-text" />
<span aria-hidden className="i-ri-equalizer-2-line h-4 w-4 text-components-button-secondary-text" />
</button>
</Tooltip>
<CustomPopover
htmlContent={(
<DropdownMenu open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isListScene ? s.actionIconWrapperList : s.actionIconWrapperDetail,
'inline-flex items-center justify-center',
!isListScene && '!h-8 !w-8 rounded-lg backdrop-blur-[5px]',
isOperationsMenuOpen
? '!shadow-none hover:!bg-state-base-hover'
: isListScene && '!bg-transparent',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className={cn(s.commonIcon)}>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-components-button-secondary-text" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={cn('w-[200px] py-0', className)}
>
<div className="w-full py-1">
{!archived && (
<>
<div
className={s.actionItem}
onClick={() => {
handleShowRenameModal({
id: detail.id,
name: detail.name,
})
}}
>
<RiEditLine className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={handleShowRename}>
<span aria-hidden className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span>
</div>
{data_source_type === DataSourceType.FILE && (
<div
className={s.actionItem}
onClick={(evt) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
handleDownload()
}}
>
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={handleDownloadClick}>
<span aria-hidden className="i-ri-download-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</div>
)}
{['notion_import', DataSourceType.WEB].includes(data_source_type) && (
<div className={s.actionItem} onClick={() => onOperate('sync')}>
<RiLoopLeftLine className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={() => handleMenuOperation('sync')}>
<span aria-hidden className="i-ri-loop-left-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.sync', { ns: 'datasetDocuments' })}</span>
</div>
)}
{IS_CE_EDITION && (
<div className={s.actionItem} onClick={() => onOperate('summary')}>
<SearchLinesSparkle className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={() => handleMenuOperation('summary')}>
<span aria-hidden className="i-custom-vender-knowledge-search-lines-sparkle h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.summary', { ns: 'datasetDocuments' })}</span>
</div>
)}
@ -230,62 +283,44 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
)}
{archived && data_source_type === DataSourceType.FILE && (
<>
<div
className={s.actionItem}
onClick={(evt) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
handleDownload()
}}
>
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={handleDownloadClick}>
<span aria-hidden className="i-ri-download-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</div>
<Divider className="my-1" />
</>
)}
{!archived && display_status?.toLowerCase() === 'indexing' && (
<div className={s.actionItem} onClick={() => onOperate('pause')}>
<RiPauseCircleLine className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={() => handleMenuOperation('pause')}>
<span aria-hidden className="i-ri-pause-circle-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.pause', { ns: 'datasetDocuments' })}</span>
</div>
)}
{!archived && display_status?.toLowerCase() === 'paused' && (
<div className={s.actionItem} onClick={() => onOperate('resume')}>
<RiPlayCircleLine className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={() => handleMenuOperation('resume')}>
<span aria-hidden className="i-ri-play-circle-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.resume', { ns: 'datasetDocuments' })}</span>
</div>
)}
{!archived && (
<div className={s.actionItem} onClick={() => onOperate('archive')}>
<RiArchive2Line className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={() => handleMenuOperation('archive')}>
<span aria-hidden className="i-ri-archive-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.archive', { ns: 'datasetDocuments' })}</span>
</div>
)}
{archived && (
<div className={s.actionItem} onClick={() => onOperate('un_archive')}>
<RiArchive2Line className="h-4 w-4 text-text-tertiary" />
<div className={s.actionItem} onClick={() => handleMenuOperation('un_archive')}>
<span aria-hidden className="i-ri-archive-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.unarchive', { ns: 'datasetDocuments' })}</span>
</div>
)}
<div className={cn(s.actionItem, s.deleteActionItem, 'group')} onClick={() => setShowModal(true)}>
<RiDeleteBinLine className="h-4 w-4 text-text-tertiary group-hover:text-text-destructive" />
<div className={cn(s.actionItem, s.deleteActionItem, 'group')} onClick={handleDeleteClick}>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4 text-text-tertiary group-hover:text-text-destructive" />
<span className={cn(s.actionName, 'group-hover:text-text-destructive')}>{t('list.action.delete', { ns: 'datasetDocuments' })}</span>
</div>
</div>
)}
trigger="click"
position="br"
btnElement={(
<div className={cn(s.commonIcon)}>
<RiMoreFill className="h-4 w-4 text-components-button-secondary-text" />
</div>
)}
btnClassName={open => cn(isListScene ? s.actionIconWrapperList : s.actionIconWrapperDetail, open ? '!hover:bg-state-base-hover !shadow-none' : '!bg-transparent')}
popupClassName="!w-full"
className={`!z-20 flex h-fit !w-[200px] justify-end ${className}`}
/>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
<AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}>

View File

@ -31,10 +31,10 @@ describe('Dropdown', () => {
const { container } = render(<Dropdown {...props} />)
// Assert - Button should have RiMoreFill icon (rendered as svg)
// Assert - Button should have the more icon
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
expect(container.querySelector('.i-ri-more-fill')).toBeInTheDocument()
})
it('should render separator after dropdown', () => {

View File

@ -1,12 +1,11 @@
import { cn } from '@langgenius/dify-ui/cn'
import { RiMoreFill } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import Menu from './menu'
type DropdownProps = {
@ -22,26 +21,17 @@ const Dropdown = ({
}: DropdownProps) => {
const [open, setOpen] = useState(false)
const handleTrigger = useCallback(() => {
setOpen(prev => !prev)
}, [])
const handleBreadCrumbClick = useCallback((index: number) => {
onBreadcrumbClick(index)
setOpen(false)
}, [onBreadcrumbClick])
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: -13,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<DropdownMenuTrigger render={<div />}>
<button
type="button"
className={cn(
@ -49,18 +39,22 @@ const Dropdown = ({
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<RiMoreFill className="size-4 text-text-tertiary" />
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<Menu
breadcrumbs={breadcrumbs}
startIndex={startIndex}
onBreadcrumbClick={handleBreadCrumbClick}
/>
</PortalToFollowElemContent>
</DropdownMenuContent>
<span className="system-xs-regular text-divider-deep">/</span>
</PortalToFollowElem>
</DropdownMenu>
)
}

View File

@ -1,4 +1,3 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Plan } from '@/app/components/billing/type'
@ -30,18 +29,6 @@ vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
),
}))
// Mock Popover
vi.mock('@/app/components/base/popover', () => ({
default: ({ htmlContent, btnElement, disabled }: { htmlContent: ReactNode, btnElement: ReactNode, disabled?: boolean }) => (
<div data-testid="popover">
<button data-testid="popover-btn" disabled={disabled}>
{btnElement}
</button>
<div data-testid="popover-content">{htmlContent}</div>
</div>
),
}))
describe('SegmentAdd', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -70,10 +57,10 @@ describe('SegmentAdd', () => {
expect(screen.getByText(/list\.action\.addButton/i)).toBeInTheDocument()
})
it('should render popover for batch add', () => {
it('should render dropdown trigger for batch add', () => {
render(<SegmentAdd {...defaultProps} />)
expect(screen.getByTestId('popover')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /list\.action\.batchAdd/i })).toBeInTheDocument()
})
})
@ -152,17 +139,20 @@ describe('SegmentAdd', () => {
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
})
it('should render batch add option in popover', () => {
it('should render batch add option in dropdown', async () => {
render(<SegmentAdd {...defaultProps} />)
expect(screen.getByText(/list\.action\.batchAdd/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /list\.action\.batchAdd/i }))
expect(await screen.findByRole('menuitem', { name: /list\.action\.batchAdd/i })).toBeInTheDocument()
})
it('should call showBatchModal when batch add is clicked', () => {
it('should call showBatchModal when batch add is clicked', async () => {
const mockShowBatchModal = vi.fn()
render(<SegmentAdd {...defaultProps} showBatchModal={mockShowBatchModal} />)
fireEvent.click(screen.getByText(/list\.action\.batchAdd/i))
fireEvent.click(screen.getByRole('button', { name: /list\.action\.batchAdd/i }))
fireEvent.click(await screen.findByRole('menuitem', { name: /list\.action\.batchAdd/i }))
expect(mockShowBatchModal).toHaveBeenCalledTimes(1)
})
@ -177,10 +167,10 @@ describe('SegmentAdd', () => {
expect(addButton).toBeDisabled()
})
it('should disable popover button when embedding is true', () => {
it('should disable batch menu trigger when embedding is true', () => {
render(<SegmentAdd {...defaultProps} embedding={true} />)
expect(screen.getByTestId('popover-btn')).toBeDisabled()
expect(screen.getByRole('button', { name: /list\.action\.batchAdd/i })).toBeDisabled()
})
it('should apply disabled styling when embedding is true', () => {

View File

@ -1,18 +1,16 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiAddLine,
RiArrowDownSLine,
RiErrorWarningFill,
RiLoader2Line,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
import Popover from '@/app/components/base/popover'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
import { useProviderContext } from '@/context/provider-context'
@ -47,6 +45,8 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
const { plan, enableBilling } = useProviderContext()
const { type } = plan
const canAdd = enableBilling ? type !== Plan.sandbox : true
const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false)
const batchMenuAnchorRef = useRef<HTMLDivElement>(null)
const withNeedUpgradeCheck = useCallback((fn: () => void) => {
return () => {
@ -72,14 +72,14 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"
>
<div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === ProcessStatus.WAITING ? 'w-3/12' : 'w-2/3')} />
<RiLoader2Line className="mr-1 h-4 w-4 animate-spin" />
<span aria-hidden className="mr-1 i-ri-loader-2-line h-4 w-4 animate-spin" />
<span className="z-10 pr-0.5 system-sm-medium">{t('list.batchModal.processing', { ns: 'datasetDocuments' })}</span>
</div>
)}
{importStatus === ProcessStatus.COMPLETED && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-success">
<CheckCircle className="mr-1 h-4 w-4" />
<span aria-hidden className="mr-1 i-custom-vender-solid-general-check-circle h-4 w-4" />
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.completed', { ns: 'datasetDocuments' })}</span>
</div>
<div className="m-1 inline-flex items-center">
@ -91,7 +91,7 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
{importStatus === ProcessStatus.ERROR && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-destructive">
<RiErrorWarningFill className="mr-1 h-4 w-4" />
<span aria-hidden className="mr-1 i-ri-error-warning-fill h-4 w-4" />
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.error', { ns: 'datasetDocuments' })}</span>
</div>
<div className="m-1 inline-flex items-center">
@ -105,10 +105,12 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
}
return (
<div className={cn(
'relative z-20 flex items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]',
embedding && 'border-components-button-secondary-border-disabled bg-components-button-secondary-bg-disabled',
)}
<div
ref={batchMenuAnchorRef}
className={cn(
'relative z-20 flex items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]',
embedding && 'border-components-button-secondary-border-disabled bg-components-button-secondary-bg-disabled',
)}
>
<button
type="button"
@ -117,42 +119,44 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
onClick={withNeedUpgradeCheck(showNewSegmentModal)}
disabled={embedding}
>
<RiAddLine className={cn('h-4 w-4', textColor)} />
<span aria-hidden className={cn('i-ri-add-line h-4 w-4', textColor)} />
<span className={cn('ml-0.5 px-0.5 text-[13px] leading-[16px] font-medium capitalize', textColor)}>
{t('list.action.addButton', { ns: 'datasetDocuments' })}
</span>
</button>
<Popover
position="br"
manualClose
trigger="click"
htmlContent={(
// need to wrapper the button with div when manualClose is true
<DropdownMenu open={isBatchMenuOpen} onOpenChange={setIsBatchMenuOpen}>
<DropdownMenuTrigger
aria-label={t('list.action.batchAdd', { ns: 'datasetDocuments' })}
disabled={embedding}
className={cn(
`rounded-l-none rounded-r-lg border-0 p-2 backdrop-blur-[5px]
hover:bg-state-base-hover disabled:cursor-not-allowed disabled:bg-transparent disabled:hover:bg-transparent`,
isBatchMenuOpen && 'bg-state-base-hover',
)}
>
<div className="flex items-center justify-center">
<span aria-hidden className={cn('i-ri-arrow-down-s-line h-4 w-4', textColor)} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
positionerProps={{ anchor: batchMenuAnchorRef }}
popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-0 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]"
>
<div className="w-full p-1">
<button
type="button"
className="flex w-full items-center rounded-lg px-2 py-1.5 system-md-regular text-text-secondary"
onClick={withNeedUpgradeCheck(showBatchModal)}
<DropdownMenuItem
className="h-auto w-full px-2 py-1.5 system-md-regular"
onClick={() => {
setIsBatchMenuOpen(false)
withNeedUpgradeCheck(showBatchModal)()
}}
>
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
</button>
</DropdownMenuItem>
</div>
)}
btnElement={(
<div className="flex items-center justify-center">
<RiArrowDownSLine className={cn('h-4 w-4', textColor)} />
</div>
)}
btnClassName={open => cn(
`!hover:bg-state-base-hover rounded-l-none! rounded-r-lg! border-0! p-2! backdrop-blur-[5px]
disabled:cursor-not-allowed disabled:bg-transparent disabled:hover:bg-transparent`,
open ? 'bg-state-base-hover!' : '',
)}
popupClassName="min-w-[128px]! bg-components-panel-bg-blur! rounded-xl! border-[0.5px] ring-0!
border-components-panel-border shadow-xl! shadow-shadow-shadow-5! backdrop-blur-[5px]"
className="h-fit min-w-[128px]"
disabled={embedding}
/>
</DropdownMenuContent>
</DropdownMenu>
{isShowPlanUpgradeModal && (
<PlanUpgradeModal
show

View File

@ -3,7 +3,7 @@ import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
import Indicator from '@/app/components/header/indicator'
import Card from './card'
@ -19,45 +19,42 @@ const ApiAccess = ({
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleToggle = () => {
setOpen(!open)
}
return (
<div className="p-3 pt-2">
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="top-start"
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={handleToggle}
>
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
<PopoverTrigger
render={(
<button type="button" className="w-full border-none bg-transparent p-0 text-left">
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="grow system-sm-medium text-text-secondary">{t('appMenus.apiAccess', { ns: 'common' })}</div>}
<Indicator
className={cn('shrink-0', !expand && 'absolute -top-px -right-px')}
color={apiEnabled ? 'green' : 'yellow'}
/>
</div>
</button>
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="grow system-sm-medium text-text-secondary">{t('appMenus.apiAccess', { ns: 'common' })}</div>}
<Indicator
className={cn('shrink-0', !expand && 'absolute -top-px -right-px')}
color={apiEnabled ? 'green' : 'yellow'}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
/>
<PopoverContent
placement="top-start"
sideOffset={4}
alignOffset={-4}
popupClassName="border-none bg-transparent shadow-none"
>
<Card
apiEnabled={apiEnabled}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
</div>
)
}

View File

@ -2,7 +2,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
import Indicator from '@/app/components/header/indicator'
import Card from './card'
@ -16,45 +16,42 @@ const ServiceApi = ({
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleToggle = () => {
setOpen(!open)
}
return (
<div>
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="top-start"
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={handleToggle}
>
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg px-3',
open ? 'bg-components-button-secondary-bg-hover' : 'hover:bg-components-button-secondary-bg-hover',
<PopoverTrigger
render={(
<button type="button" className="w-full border-none bg-transparent p-0 text-left">
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg px-3',
open ? 'bg-components-button-secondary-bg-hover' : 'hover:bg-components-button-secondary-bg-hover',
)}
>
<Indicator
className={cn('shrink-0')}
color={
apiBaseUrl ? 'green' : 'yellow'
}
/>
<div className="grow system-sm-medium text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>
</div>
</button>
)}
>
<Indicator
className={cn('shrink-0')}
color={
apiBaseUrl ? 'green' : 'yellow'
}
/>
<div className="grow system-sm-medium text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
/>
<PopoverContent
placement="top-start"
sideOffset={4}
alignOffset={-4}
popupClassName="border-none bg-transparent shadow-none"
>
<Card
apiBaseUrl={apiBaseUrl}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
</div>
)
}

View File

@ -56,9 +56,9 @@ vi.mock('../components/dataset-card-modals', () => ({
default: () => <div data-testid="card-modals" />,
}))
vi.mock('../components/tag-area', () => ({
default: React.forwardRef<HTMLDivElement, { onClick: (e: React.MouseEvent) => void }>(({ onClick }, ref) => (
<div ref={ref} data-testid="tag-area" onClick={onClick} />
)),
default: ({ onClick }: { onClick: (e: React.MouseEvent) => void, ref?: React.Ref<HTMLDivElement> }) => (
<div data-testid="tag-area" onClick={onClick} />
),
}))
vi.mock('../components/operations-dropdown', () => ({
default: () => <div data-testid="operations-dropdown" />,

View File

@ -1,11 +1,10 @@
import { RiEditLine } from '@remixicon/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OperationItem from '../operation-item'
describe('OperationItem', () => {
const defaultProps = {
Icon: RiEditLine,
iconClassName: 'i-ri-edit-line',
name: 'Edit',
}
@ -17,7 +16,7 @@ describe('OperationItem', () => {
it('should render the icon', () => {
const { container } = render(<OperationItem {...defaultProps} />)
const icon = container.querySelector('svg')
const icon = container.querySelector('.i-ri-edit-line')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('size-4', 'text-text-tertiary')
})

View File

@ -1,6 +1,6 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import OperationsDropdown from '../operations-dropdown'

View File

@ -1,14 +1,14 @@
import type { RemixiconComponentType } from '@remixicon/react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
type OperationItemProps = {
Icon: RemixiconComponentType
iconClassName: string
name: string
handleClick?: () => void
}
const OperationItem = ({
Icon,
iconClassName,
name,
handleClick,
}: OperationItemProps) => {
@ -21,7 +21,7 @@ const OperationItem = ({
handleClick?.()
}}
>
<Icon className="size-4 text-text-tertiary" />
<span aria-hidden className={cn(iconClassName, 'size-4 text-text-tertiary')} />
<span className="system-md-regular px-1 text-text-secondary">
{name}
</span>

View File

@ -11,6 +11,7 @@ type OperationsProps = {
openRenameModal: () => void
handleExportPipeline: () => void
detectIsUsedByApp: () => void
onClose?: () => void
}
const Operations = ({
@ -19,17 +20,33 @@ const Operations = ({
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
onClose,
}: OperationsProps) => {
const { t } = useTranslation()
const handleRename = () => {
onClose?.()
openRenameModal()
}
const handleExport = () => {
onClose?.()
handleExportPipeline()
}
const handleDelete = () => {
onClose?.()
detectIsUsedByApp()
}
return (
<>
<DropdownMenuItem onClick={openRenameModal}>
<DropdownMenuItem onClick={handleRename}>
<span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" />
{t('operation.edit', { ns: 'common' })}
</DropdownMenuItem>
{showExportPipeline && (
<DropdownMenuItem onClick={handleExportPipeline}>
<DropdownMenuItem onClick={handleExport}>
<span aria-hidden className="i-ri-file-download-line size-4 text-text-tertiary" />
{t('operations.exportPipeline', { ns: 'datasetPipeline' })}
</DropdownMenuItem>
@ -37,7 +54,7 @@ const Operations = ({
{showDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem destructive onClick={detectIsUsedByApp}>
<DropdownMenuItem destructive onClick={handleDelete}>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
{t('operation.delete', { ns: 'common' })}
</DropdownMenuItem>

View File

@ -3,14 +3,15 @@ import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../../types'
import CreateMetadataModal from '../create-metadata-modal'
type PortalProps = {
type PopoverProps = {
children: React.ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}
type TriggerProps = {
children: React.ReactNode
onClick: () => void
children?: React.ReactNode
render?: React.ReactNode
}
type ContentProps = {
@ -25,18 +26,37 @@ type CreateContentProps = {
hasBack?: boolean
}
// Mock PortalToFollowElem components
vi.mock('../../../../base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: PortalProps) => (
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: ContentProps) => (
<div data-testid="portal-content" className={className}>{children}</div>
),
}))
vi.mock('../../../../base/ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext<{ open: boolean, onOpenChange?: (open: boolean) => void } | null>(null)
return {
Popover: ({ children, open, onOpenChange }: PopoverProps) => (
<PopoverContext.Provider value={{ open, onOpenChange }}>
<div data-testid="popover-root" data-open={String(open)}>{children}</div>
</PopoverContext.Provider>
),
PopoverTrigger: ({ children, render }: TriggerProps) => {
const context = React.useContext(PopoverContext)
const content = render ?? children
const handleClick = () => context?.onOpenChange?.(!context.open)
if (React.isValidElement(content)) {
const element = content as React.ReactElement<{ onClick?: () => void }>
return React.cloneElement(element, { onClick: handleClick })
}
return <button type="button" data-testid="popover-trigger" onClick={handleClick}>{content}</button>
},
PopoverContent: ({ children, className }: ContentProps) => {
const context = React.useContext(PopoverContext)
if (!context?.open)
return null
return <div data-testid="popover-content" className={className}>{children}</div>
},
}
})
// Mock CreateContent component
vi.mock('../create-content', () => ({
@ -63,9 +83,8 @@ describe('CreateMetadataModal', () => {
onSave={vi.fn()}
/>,
)
// Portal wrapper should exist but closed
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'false')
})
it('should render content when open', () => {
@ -77,7 +96,7 @@ describe('CreateMetadataModal', () => {
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
@ -130,7 +149,7 @@ describe('CreateMetadataModal', () => {
popupLeft={50}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
})
})
@ -146,7 +165,7 @@ describe('CreateMetadataModal', () => {
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger-button'))
expect(setOpen).toHaveBeenCalledWith(true)
})
@ -215,7 +234,7 @@ describe('CreateMetadataModal', () => {
/>,
)
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'false')
rerender(
<CreateMetadataModal
@ -226,7 +245,7 @@ describe('CreateMetadataModal', () => {
/>,
)
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true')
})
it('should handle different trigger elements', () => {

View File

@ -9,14 +9,15 @@ type MetadataItem = {
type: DataType
}
type PortalProps = {
type PopoverProps = {
children: React.ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}
type TriggerProps = {
children: React.ReactNode
onClick: () => void
children?: React.ReactNode
render?: React.ReactNode
}
type ContentProps = {
@ -49,18 +50,37 @@ vi.mock('@/service/knowledge/use-metadata', () => ({
}),
}))
// Mock PortalToFollowElem components
vi.mock('../../../../base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: PortalProps) => (
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: ContentProps) => (
<div data-testid="portal-content">{children}</div>
),
}))
vi.mock('../../../../base/ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext<{ open: boolean, onOpenChange?: (open: boolean) => void } | null>(null)
return {
Popover: ({ children, open, onOpenChange }: PopoverProps) => (
<PopoverContext.Provider value={{ open, onOpenChange }}>
<div data-testid="popover-root" data-open={String(open)}>{children}</div>
</PopoverContext.Provider>
),
PopoverTrigger: ({ children, render }: TriggerProps) => {
const context = React.useContext(PopoverContext)
const content = render ?? children
const handleClick = () => context?.onOpenChange?.(!context.open)
if (React.isValidElement(content)) {
const element = content as React.ReactElement<{ onClick?: () => void }>
return React.cloneElement(element, { onClick: handleClick })
}
return <button type="button" data-testid="popover-trigger" onClick={handleClick}>{content}</button>
},
PopoverContent: ({ children }: ContentProps) => {
const context = React.useContext(PopoverContext)
if (!context?.open)
return null
return <div data-testid="popover-content">{children}</div>
},
}
})
// Mock SelectMetadata component
vi.mock('../select-metadata', () => ({
@ -99,7 +119,7 @@ describe('SelectMetadataModal', () => {
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
})
it('should render trigger element', () => {
@ -115,7 +135,7 @@ describe('SelectMetadataModal', () => {
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
})
it('should render SelectMetadata by default', () => {
it('should not render SelectMetadata before opening', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
@ -125,7 +145,7 @@ describe('SelectMetadataModal', () => {
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
expect(screen.queryByTestId('select-metadata')).not.toBeInTheDocument()
})
it('should pass dataset metadata to SelectMetadata', () => {
@ -138,6 +158,7 @@ describe('SelectMetadataModal', () => {
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('trigger-button'))
expect(screen.getByTestId('list-count')).toHaveTextContent('2')
})
})
@ -154,10 +175,10 @@ describe('SelectMetadataModal', () => {
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger-button'))
// State should toggle
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
})
it('should call onSelect and close when item is selected', () => {
@ -172,6 +193,7 @@ describe('SelectMetadataModal', () => {
/>,
)
fireEvent.click(screen.getByTestId('trigger-button'))
fireEvent.click(screen.getByTestId('select-item'))
expect(handleSelect).toHaveBeenCalledWith({
@ -192,6 +214,7 @@ describe('SelectMetadataModal', () => {
/>,
)
fireEvent.click(screen.getByTestId('trigger-button'))
fireEvent.click(screen.getByTestId('new-btn'))
await waitFor(() => {
@ -211,6 +234,7 @@ describe('SelectMetadataModal', () => {
/>,
)
fireEvent.click(screen.getByTestId('trigger-button'))
fireEvent.click(screen.getByTestId('manage-btn'))
expect(handleManage).toHaveBeenCalled()
@ -230,6 +254,7 @@ describe('SelectMetadataModal', () => {
)
// Go to create step
fireEvent.click(screen.getByTestId('trigger-button'))
fireEvent.click(screen.getByTestId('new-btn'))
await waitFor(() => {
@ -257,6 +282,7 @@ describe('SelectMetadataModal', () => {
)
// Go to create step
fireEvent.click(screen.getByTestId('trigger-button'))
fireEvent.click(screen.getByTestId('new-btn'))
await waitFor(() => {
@ -287,7 +313,7 @@ describe('SelectMetadataModal', () => {
popupPlacement="bottom-start"
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
})
it('should accept custom popupOffset', () => {
@ -301,7 +327,7 @@ describe('SelectMetadataModal', () => {
popupOffset={{ mainAxis: 10, crossAxis: 5 }}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
})
})
@ -317,7 +343,7 @@ describe('SelectMetadataModal', () => {
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
rerender(
<SelectMetadataModal
@ -329,7 +355,7 @@ describe('SelectMetadataModal', () => {
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('popover-root')).toBeInTheDocument()
})
it('should handle empty trigger', () => {

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { Props as CreateContentProps } from './create-content'
import * as React from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../../base/portal-to-follow-elem'
import { Popover, PopoverContent, PopoverTrigger } from '../../../base/ui/popover'
import CreateContent from './create-content'
type Props = {
@ -20,25 +20,25 @@ const CreateMetadataModal: FC<Props> = ({
popupLeft = 20,
...createContentProps
}) => {
const triggerElement = React.isValidElement(trigger)
? trigger
: <button type="button">{trigger}</button>
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="left-start"
offset={{
mainAxis: popupLeft,
crossAxis: -38,
}}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(!open)}
<PopoverTrigger render={triggerElement as React.ReactElement} />
<PopoverContent
placement="left-start"
sideOffset={popupLeft}
alignOffset={-38}
popupClassName="border-none bg-transparent shadow-none"
>
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<CreateContent {...createContentProps} onClose={() => setOpen(false)} onBack={() => setOpen(false)} />
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -1,12 +1,12 @@
'use client'
import type { Placement } from '@floating-ui/react'
import type { FC } from 'react'
import type { MetadataItem } from '../types'
import type { Props as CreateContentProps } from './create-content'
import type { Placement } from '@/app/components/base/ui/placement'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/base/ui/popover'
import { useDatasetMetaData } from '@/service/knowledge/use-metadata'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../../base/portal-to-follow-elem'
import CreateContent from './create-content'
import SelectMetadata from './select-metadata'
@ -38,25 +38,31 @@ const SelectMetadataModal: FC<Props> = ({
const [open, setOpen] = useState(false)
const [step, setStep] = useState(Step.select)
const triggerElement = React.isValidElement(trigger)
? trigger
: <button type="button">{trigger}</button>
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen)
setStep(Step.select)
}, [])
const handleSave = useCallback(async (data: MetadataItem) => {
await onSave(data)
setStep(Step.select)
}, [onSave])
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement={popupPlacement}
offset={popupOffset}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(!open)}
className="block"
<PopoverTrigger render={triggerElement as React.ReactElement} />
<PopoverContent
placement={popupPlacement}
sideOffset={popupOffset.mainAxis}
alignOffset={popupOffset.crossAxis}
popupClassName="border-none bg-transparent shadow-none"
>
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
{step === Step.select
? (
<SelectMetadata
@ -66,7 +72,11 @@ const SelectMetadataModal: FC<Props> = ({
}}
list={datasetMetaData?.doc_metadata || []}
onNew={() => setStep(Step.create)}
onManage={onManage}
onManage={() => {
setOpen(false)
setStep(Step.select)
onManage()
}}
/>
)
: (
@ -77,8 +87,8 @@ const SelectMetadataModal: FC<Props> = ({
onClose={() => setStep(Step.select)}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -1,6 +1,81 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import ItemOperation from '../index'
vi.mock('@/app/components/base/ui/dropdown-menu', () => {
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context)
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({
children,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isOpen, setOpen } = useDropdownMenuContext()
return (
<button
type="button"
onClick={(e) => {
onClick?.(e)
setOpen(!isOpen)
}}
{...props}
>
{children}
</button>
)
},
DropdownMenuContent: ({
children,
popupProps,
}: {
children: React.ReactNode
popupProps?: React.HTMLAttributes<HTMLDivElement>
}) => {
const { isOpen } = useDropdownMenuContext()
if (!isOpen)
return null
return <div data-testid="dropdown-content" {...popupProps}>{children}</div>
},
DropdownMenuItem: ({
children,
onClick,
className,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
className?: string
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
className={className}
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
describe('ItemOperation', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -67,14 +142,27 @@ describe('ItemOperation', () => {
expect(props.onDelete).toHaveBeenCalledTimes(1)
})
it('should call onRenameConversation when clicking rename action', async () => {
const onRenameConversation = vi.fn()
renderComponent({
isShowRenameConversation: true,
onRenameConversation,
})
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.rename'))
expect(onRenameConversation).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should close the menu when mouse leaves the panel and item is not hovering', async () => {
renderComponent()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
const pinText = await screen.findByText('explore.sidebar.action.pin')
const menu = pinText.closest('div')?.parentElement as HTMLElement
await screen.findByText('explore.sidebar.action.pin')
const menu = screen.getByTestId('dropdown-content')
fireEvent.mouseEnter(menu)
fireEvent.mouseLeave(menu)
@ -83,5 +171,25 @@ describe('ItemOperation', () => {
expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument()
})
})
it('should stop propagation when clicking inside the dropdown content', async () => {
const onParentClick = vi.fn()
render(
<div onClick={onParentClick}>
<ItemOperation
isPinned={false}
isShowDelete
togglePin={vi.fn()}
onDelete={vi.fn()}
/>
</div>,
)
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByTestId('dropdown-content'))
expect(onParentClick).not.toHaveBeenCalled()
})
})
})

View File

@ -7,10 +7,14 @@ import {
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { Pin02 } from '../../base/icons/src/vender/line/general'
import s from './style.module.css'
@ -35,61 +39,74 @@ const ItemOperation: FC<IItemOperationProps> = ({
isShowDelete,
onDelete,
}) => {
const { t } = useTranslation()
const { t } = useTranslation('explore')
const { t: tCommon } = useTranslation('common')
const [open, setOpen] = useState(false)
const ref = useRef(null)
const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false)
useEffect(() => {
if (!isItemHovering && !isHovering)
setOpen(false)
}, [isItemHovering, isHovering])
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
<DropdownMenuTrigger
data-testid="item-operation-trigger"
className={cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', (isItemHovering || open) && `${s.open} bg-components-actionbar-bg! shadow-none!`)}
onClick={(e) => {
e.stopPropagation()
}}
>
<div
className={cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', (isItemHovering || open) && `${s.open} bg-components-actionbar-bg! shadow-none!`)}
data-testid="item-operation-trigger"
>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent
className="z-50"
<span className="sr-only">{tCommon('operation.more')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[120px]"
popupProps={{
onMouseEnter: setIsHovering,
onMouseLeave: setNotHovering,
onClick: e => e.stopPropagation(),
}}
>
<div
ref={ref}
className="min-w-[120px] rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]"
onMouseEnter={setIsHovering}
onMouseLeave={setNotHovering}
<DropdownMenuItem
className={cn(s.actionItem, 'gap-2 px-3')}
onClick={(e) => {
e.stopPropagation()
togglePin()
}}
>
<div className={cn(s.actionItem, 'group hover:bg-state-base-hover')} onClick={togglePin}>
<Pin02 className="h-4 w-4 shrink-0 text-text-secondary" />
<span className={s.actionName}>{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
</div>
{isShowRenameConversation && (
<div className={cn(s.actionItem, 'group hover:bg-state-base-hover')} onClick={onRenameConversation}>
<RiEditLine className="h-4 w-4 shrink-0 text-text-secondary" />
<span className={s.actionName}>{t('sidebar.action.rename', { ns: 'explore' })}</span>
</div>
)}
{isShowDelete && (
<div className={cn(s.actionItem, s.deleteActionItem, 'group hover:bg-state-base-hover')} onClick={onDelete}>
<RiDeleteBinLine className={cn(s.deleteActionItemChild, 'h-4 w-4 shrink-0 stroke-current stroke-2 text-text-secondary')} />
<span className={cn(s.actionName, s.deleteActionItemChild)}>{t('sidebar.action.delete', { ns: 'explore' })}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<Pin02 className="h-4 w-4 shrink-0 text-text-secondary" />
<span className={s.actionName}>{isPinned ? t('sidebar.action.unpin') : t('sidebar.action.pin')}</span>
</DropdownMenuItem>
{isShowRenameConversation && (
<DropdownMenuItem
className={cn(s.actionItem, 'gap-2 px-3')}
onClick={(e) => {
e.stopPropagation()
onRenameConversation?.()
}}
>
<RiEditLine className="h-4 w-4 shrink-0 text-text-secondary" />
<span className={s.actionName}>{t('sidebar.action.rename')}</span>
</DropdownMenuItem>
)}
{isShowDelete && (
<DropdownMenuItem
className={cn(s.actionItem, s.deleteActionItem, 'gap-2 px-3 data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive')}
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<RiDeleteBinLine className={cn(s.deleteActionItemChild, 'h-4 w-4 shrink-0 stroke-current stroke-2 text-inherit')} />
<span className={cn(s.actionName, s.deleteActionItemChild, 'text-inherit')}>{t('sidebar.action.delete')}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default React.memo(ItemOperation)

View File

@ -1,7 +1,6 @@
import type { ProviderContextState } from '@/context/provider-context'
import type { ReactNode } from 'react'
import type { IWorkspace } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import { useWorkspacesContext } from '@/context/workspace-context'
import { switchWorkspace } from '@/service/common'
import WorkplaceSelector from '../index'
@ -10,21 +9,30 @@ const toastMocks = vi.hoisted(() => ({
mockNotify: vi.fn(),
}))
type MockSelectState = {
value: string
onValueChange: (value: string | null) => void
}
const selectMocks = vi.hoisted(() => ({
state: {
value: '',
onValueChange: () => {},
} as MockSelectState,
reset: (): MockSelectState => ({
value: '',
onValueChange: () => {},
}),
}))
vi.mock('@/context/workspace-context', () => ({
useWorkspacesContext: vi.fn(),
}))
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: vi.fn(),
}
})
vi.mock('@/service/common', () => ({
switchWorkspace: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
default: {
notify: (args: unknown) => toastMocks.mockNotify(args),
@ -37,6 +45,52 @@ vi.mock('@/app/components/base/ui/toast', () => ({
},
}))
vi.mock('@/app/components/base/ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/select')>()
return {
...actual,
Select: ({
value,
onValueChange,
children,
}: {
value: string
onValueChange: (value: string | null) => void
children: ReactNode
}) => {
selectMocks.state = { value, onValueChange }
return <div data-testid="workplace-selector-root">{children}</div>
},
SelectTrigger: ({ children }: { children: ReactNode }) => (
<button data-testid="workplace-selector-trigger" type="button">
{children}
</button>
),
SelectContent: ({ children }: { children: ReactNode }) => (
<div data-testid="workplace-selector-content">{children}</div>
),
SelectGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectGroupLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({
children,
value,
}: {
children: ReactNode
value: string
}) => (
<button
data-testid={`workspace-option-${value}`}
type="button"
onClick={() => selectMocks.state.onValueChange(value)}
>
{children}
</button>
),
SelectItemText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}
})
describe('WorkplaceSelector', () => {
const mockWorkspaces: IWorkspace[] = [
{ id: '1', name: 'Workspace 1', current: true, plan: 'professional', status: 'normal', created_at: Date.now() },
@ -48,68 +102,41 @@ describe('WorkplaceSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
selectMocks.state = selectMocks.reset()
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: mockWorkspaces,
})
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
isFetchedPlan: true,
isEducationWorkspace: false,
} as ProviderContextState)
vi.stubGlobal('location', { ...window.location, assign: mockAssign })
})
const renderComponent = () => {
return render(
<>
<WorkplaceSelector />
</>,
)
}
const renderComponent = () => render(<WorkplaceSelector />)
describe('Rendering', () => {
it('should render current workspace correctly', () => {
// Act
it('should render current workspace and available workspace options', () => {
renderComponent()
// Assert
expect(screen.getByText('Workspace 1')).toBeInTheDocument()
expect(screen.getByText('W')).toBeInTheDocument() // First letter icon
})
it('should open menu and display all workspaces when clicked', () => {
// Act
renderComponent()
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getAllByText('Workspace 1').length).toBeGreaterThan(0)
expect(screen.getByText('Workspace 2')).toBeInTheDocument()
// The real PlanBadge renders uppercase plan name or "pro"
expect(screen.getByText('pro')).toBeInTheDocument()
expect(screen.getByText('sandbox')).toBeInTheDocument()
expect(screen.getByTestId('workplace-selector-trigger')).toHaveTextContent('Workspace 1')
expect(screen.getByTestId('workspace-option-1')).toBeInTheDocument()
expect(screen.getByTestId('workspace-option-2')).toBeInTheDocument()
expect(screen.getByTestId('workspace-option-1')).toHaveTextContent('Workspace 1')
expect(screen.getByTestId('workspace-option-2')).toHaveTextContent('Workspace 2')
})
})
describe('Workspace Switching', () => {
it('should switch workspace successfully', async () => {
// Arrange
vi.mocked(switchWorkspace).mockResolvedValue({
result: 'success',
new_tenant: mockWorkspaces[1],
})
// Act
renderComponent()
fireEvent.click(screen.getByRole('button'))
const workspace2 = screen.getByText('Workspace 2')
fireEvent.click(workspace2)
fireEvent.click(screen.getByTestId('workspace-option-2'))
// Assert
expect(switchWorkspace).toHaveBeenCalledWith({
await waitFor(() => expect(switchWorkspace).toHaveBeenCalledWith({
url: '/workspaces/switch',
body: { tenant_id: '2' },
})
}))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
@ -121,27 +148,18 @@ describe('WorkplaceSelector', () => {
})
it('should not switch to the already current workspace', () => {
// Act
renderComponent()
fireEvent.click(screen.getByRole('button'))
const workspacesInMenu = screen.getAllByText('Workspace 1')
fireEvent.click(workspacesInMenu[workspacesInMenu.length - 1])
fireEvent.click(screen.getByTestId('workspace-option-1'))
// Assert
expect(switchWorkspace).not.toHaveBeenCalled()
})
it('should handle switching error correctly', async () => {
// Arrange
vi.mocked(switchWorkspace).mockRejectedValue(new Error('Failed'))
// Act
renderComponent()
fireEvent.click(screen.getByRole('button'))
const workspace2 = screen.getByText('Workspace 2')
fireEvent.click(workspace2)
fireEvent.click(screen.getByTestId('workspace-option-2'))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
@ -152,29 +170,23 @@ describe('WorkplaceSelector', () => {
})
describe('Edge Cases', () => {
// find() returns undefined: no workspace with current: true
it('should not crash when no workspace has current: true', () => {
// Arrange
it('should not crash when no workspace has current value', () => {
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: [
{ id: '1', name: 'Workspace 1', current: false, plan: 'professional', status: 'normal', created_at: Date.now() },
],
})
// Act & Assert - should not throw
expect(() => renderComponent()).not.toThrow()
})
// name[0]?.toLocaleUpperCase() undefined: workspace with empty name
it('should not crash when workspace name is empty string', () => {
// Arrange
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: [
{ id: '1', name: '', current: true, plan: 'sandbox', status: 'normal', created_at: Date.now() },
],
})
// Act & Assert - should not throw
expect(() => renderComponent()).not.toThrow()
})
})

View File

@ -1,9 +1,14 @@
import type { Plan } from '@/app/components/billing/type'
import { Menu, MenuButton, MenuItems, Transition } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine } from '@remixicon/react'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectItemText,
SelectTrigger,
} from '@/app/components/base/ui/select'
import { toast } from '@/app/components/base/ui/toast'
import PlanBadge from '@/app/components/header/plan-badge'
import { useWorkspacesContext } from '@/context/workspace-context'
@ -14,6 +19,7 @@ const WorkplaceSelector = () => {
const { t } = useTranslation()
const { workspaces } = useWorkspacesContext()
const currentWorkspace = workspaces.find(v => v.current)
const handleSwitchWorkspace = async (tenant_id: string) => {
try {
if (currentWorkspace?.id === tenant_id)
@ -26,50 +32,48 @@ const WorkplaceSelector = () => {
toast.error(t('provider.saveFailed', { ns: 'common' }))
}
}
return (
<Menu as="div" className="min-w-0">
{({ open }) => (
<>
<MenuButton className={cn(`
group flex w-full cursor-pointer items-center
p-0.5 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} rounded-[10px]
`)}
>
<div className="mr-1.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px] max-[800px]:mr-0">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="flex min-w-0 items-center">
<div className="max-w-[149px] min-w-0 truncate system-sm-medium text-text-secondary max-[800px]:hidden">{currentWorkspace?.name}</div>
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-text-secondary" />
</div>
</MenuButton>
<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
anchor="bottom start"
className={cn(`
shadows-shadow-lg absolute left-[-15px] z-[1000] mt-1 flex max-h-[400px] w-[280px] flex-col items-start overflow-y-auto
rounded-xl bg-components-panel-bg-blur backdrop-blur-[5px]
`)}
>
<div className="flex w-full flex-col items-start self-stretch rounded-xl border-[0.5px] border-components-panel-border p-1 pb-2 shadow-lg">
<div className="flex items-start self-stretch px-3 pt-1 pb-0.5">
<span className="flex-1 system-xs-medium-uppercase text-text-tertiary">{t('userProfile.workspace', { ns: 'common' })}</span>
</div>
{workspaces.map(workspace => (
<div className="flex items-center gap-2 self-stretch rounded-lg py-1 pr-2 pl-3 hover:bg-state-base-hover" key={workspace.id} onClick={() => handleSwitchWorkspace(workspace.id)}>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">{workspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="line-clamp-1 grow cursor-pointer overflow-hidden system-md-regular text-ellipsis text-text-secondary">{workspace.name}</div>
<PlanBadge plan={workspace.plan as Plan} />
</div>
))}
<Select
value={currentWorkspace?.id ?? ''}
onValueChange={(value) => {
if (value)
void handleSwitchWorkspace(value)
}}
>
<SelectTrigger
className="w-auto cursor-pointer rounded-[10px] border-0 bg-transparent p-0.5 hover:bg-state-base-hover data-popup-open:bg-state-base-hover"
>
<div className="flex items-center">
<div className="mr-1.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px] max-[800px]:mr-0">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">
{currentWorkspace?.name[0]?.toLocaleUpperCase()}
</span>
</div>
<div className="max-w-[149px] min-w-0 truncate system-sm-medium text-text-secondary max-[800px]:hidden">
{currentWorkspace?.name}
</div>
</div>
</SelectTrigger>
<SelectContent popupClassName="w-[280px]">
<SelectGroup>
<SelectGroupLabel>
{t('userProfile.workspace', { ns: 'common' })}
</SelectGroupLabel>
{workspaces.map(workspace => (
<SelectItem key={workspace.id} value={workspace.id} className="gap-2 py-1 pr-2 pl-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">
{workspace.name[0]?.toLocaleUpperCase()}
</span>
</div>
</MenuItems>
</Transition>
</>
)}
</Menu>
<SelectItemText className="system-md-regular">{workspace.name}</SelectItemText>
<PlanBadge plan={workspace.plan as Plan} />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
export default WorkplaceSelector

View File

@ -1,5 +1,6 @@
import type { DataSourceCredential } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import Operator from '../operator'
@ -9,10 +10,6 @@ import Operator from '../operator'
*/
// Helper to open dropdown
const openDropdown = () => {
fireEvent.click(screen.getByRole('button'))
}
describe('Operator Component', () => {
const mockOnAction = vi.fn()
const mockOnRename = vi.fn()
@ -37,7 +34,7 @@ describe('Operator Component', () => {
// Act
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
// Assert
expect(await screen.findByText('plugin.auth.setDefault')).toBeInTheDocument()
@ -53,7 +50,7 @@ describe('Operator Component', () => {
// Act
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
// Assert
expect(await screen.findByText('plugin.auth.setDefault')).toBeInTheDocument()
@ -71,11 +68,13 @@ describe('Operator Component', () => {
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
fireEvent.click(await screen.findByText('common.operation.rename'))
// Assert
expect(mockOnRename).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(mockOnRename).toHaveBeenCalledTimes(1)
})
expect(mockOnAction).not.toHaveBeenCalled()
})
@ -85,7 +84,7 @@ describe('Operator Component', () => {
render(<Operator credentialItem={credential} onAction={mockOnAction} />)
// Act & Assert
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
const renameBtn = await screen.findByText('common.operation.rename')
expect(() => fireEvent.click(renameBtn)).not.toThrow()
})
@ -96,11 +95,13 @@ describe('Operator Component', () => {
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
fireEvent.click(await screen.findByText('plugin.auth.setDefault'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('setDefault', credential)
await waitFor(() => {
expect(mockOnAction).toHaveBeenCalledWith('setDefault', credential)
})
})
it('should call onAction for "edit" action', async () => {
@ -109,11 +110,13 @@ describe('Operator Component', () => {
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
fireEvent.click(await screen.findByText('common.operation.edit'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('edit', credential)
await waitFor(() => {
expect(mockOnAction).toHaveBeenCalledWith('edit', credential)
})
})
it('should call onAction for "change" action', async () => {
@ -122,11 +125,13 @@ describe('Operator Component', () => {
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
fireEvent.click(await screen.findByText('common.dataSource.notion.changeAuthorizedPages'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('change', credential)
await waitFor(() => {
expect(mockOnAction).toHaveBeenCalledWith('change', credential)
})
})
it('should call onAction for "delete" action', async () => {
@ -135,11 +140,13 @@ describe('Operator Component', () => {
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
await userEvent.setup().click(screen.getByRole('button'))
fireEvent.click(await screen.findByText('common.operation.remove'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('delete', credential)
await waitFor(() => {
expect(mockOnAction).toHaveBeenCalledWith('delete', credential)
})
})
})
})

View File

@ -1,21 +1,20 @@
import type {
DataSourceCredential,
} from './types'
import type { Item } from '@/app/components/base/dropdown'
import {
RiDeleteBinLine,
RiEditLine,
RiEqualizer2Line,
RiHome9Line,
RiStickyNoteAddLine,
} from '@remixicon/react'
import {
memo,
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Dropdown from '@/app/components/base/dropdown'
import ActionButton from '@/app/components/base/action-button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
type OperatorProps = {
@ -29,106 +28,60 @@ const Operator = ({
onRename,
}: OperatorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const {
type,
} = credentialItem
const items = useMemo(() => {
const commonItems = [
{
value: 'setDefault',
text: (
<div className="flex items-center">
<RiHome9Line className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">{t('auth.setDefault', { ns: 'plugin' })}</div>
</div>
),
},
...(
type === CredentialTypeEnum.OAUTH2
? [
{
value: 'rename',
text: (
<div className="flex items-center">
<RiEditLine className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">{t('operation.rename', { ns: 'common' })}</div>
</div>
),
},
]
: []
),
...(
type === CredentialTypeEnum.API_KEY
? [
{
value: 'edit',
text: (
<div className="flex items-center">
<RiEqualizer2Line className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">{t('operation.edit', { ns: 'common' })}</div>
</div>
),
},
]
: []
),
]
if (type === CredentialTypeEnum.OAUTH2) {
const oAuthItems = [
{
value: 'change',
text: (
<div className="flex items-center">
<RiStickyNoteAddLine className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="mb-1 text-text-secondary system-sm-semibold">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
</div>
),
},
]
commonItems.push(...oAuthItems)
}
return commonItems
}, [t, type])
const secondItems = useMemo(() => {
return [
{
value: 'delete',
text: (
<div className="flex items-center">
<RiDeleteBinLine className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">
{t('operation.remove', { ns: 'common' })}
</div>
</div>
),
},
]
}, [])
const handleSelect = useCallback((item: Item) => {
if (item.value === 'rename') {
onRename?.()
return
}
onAction(
item.value as string,
credentialItem,
)
}, [onAction, credentialItem, onRename])
const handleAction = useCallback((action: string) => {
setOpen(false)
queueMicrotask(() => {
if (action === 'rename') {
onRename?.()
return
}
onAction(action, credentialItem)
})
}, [credentialItem, onAction, onRename])
return (
<Dropdown
items={items}
secondItems={secondItems}
onSelect={handleSelect}
popupClassName="z-1002"
triggerProps={{
size: 'l',
}}
itemClassName="py-2 h-auto hover:bg-state-base-hover"
secondItemClassName="py-2 h-auto hover:bg-state-base-hover"
/>
<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>
<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" />
<div className="text-text-secondary system-sm-semibold">{t('auth.setDefault', { ns: 'plugin' })}</div>
</DropdownMenuItem>
{type === CredentialTypeEnum.OAUTH2 && (
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('rename')}>
<span aria-hidden className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">{t('operation.rename', { ns: 'common' })}</div>
</DropdownMenuItem>
)}
{type === CredentialTypeEnum.API_KEY && (
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('edit')}>
<span aria-hidden className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
<div className="text-text-secondary system-sm-semibold">{t('operation.edit', { ns: 'common' })}</div>
</DropdownMenuItem>
)}
{type === CredentialTypeEnum.OAUTH2 && (
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('change')}>
<span aria-hidden className="i-ri-sticky-note-add-line h-4 w-4 text-text-tertiary" />
<div className="mb-1 text-text-secondary system-sm-semibold">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem destructive className="h-auto gap-2 py-2" onClick={() => handleAction('delete')}>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4" />
<div className="system-sm-semibold">
{t('operation.remove', { ns: 'common' })}
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -6,19 +6,26 @@ vi.mock('../../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({ children, onValueChange }: { children: ReactNode, onValueChange: (value: string | undefined) => void }) => (
<div>
<button type="button" onClick={() => onValueChange('updated')}>select-updated</button>
<button type="button" onClick={() => onValueChange(undefined)}>select-empty</button>
{children}
</div>
),
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <div>SelectValue</div>,
}))
vi.mock('@/app/components/base/ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/select')>()
return {
...actual,
Select: ({ children, onValueChange }: { children: ReactNode, onValueChange: (value: string | undefined) => void }) => (
<div>
<button type="button" onClick={() => onValueChange('updated')}>select-updated</button>
<button type="button" onClick={() => onValueChange(undefined)}>select-empty</button>
{children}
</div>
),
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <div>SelectValue</div>,
SelectItemText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => <span data-testid="select-item-indicator" />,
}
})
describe('ParameterItem select mode', () => {
it('should propagate both explicit and empty select values', () => {

View File

@ -10,7 +10,7 @@ import PromptEditor from '@/app/components/base/prompt-editor'
import Radio from '@/app/components/base/radio'
import Switch from '@/app/components/base/switch'
import TagInput from '@/app/components/base/tag-input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Slider } from '@/app/components/base/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { BlockEnum } from '@/app/components/workflow/types'
@ -299,7 +299,10 @@ function ParameterItem({
</SelectTrigger>
<SelectContent>
{parameterRule.options!.map(option => (
<SelectItem key={option} value={option}>{option}</SelectItem>
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>

View File

@ -26,66 +26,83 @@ const MockSelectContext = React.createContext<{
onValueChange: () => {},
})
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({
value,
onValueChange,
children,
}: {
value: string
onValueChange: (value: string) => void
children: React.ReactNode
}) => (
<MockSelectContext.Provider value={{ value, onValueChange }}>
<div data-testid="select-root">{children}</div>
</MockSelectContext.Provider>
),
SelectTrigger: ({
children,
className,
'data-testid': testId,
}: {
'children': React.ReactNode
'className'?: string
'data-testid'?: string
}) => (
<button data-testid={testId ?? 'select-trigger'} data-class={className}>
{children}
</button>
),
SelectValue: () => {
const { value } = React.useContext(MockSelectContext)
return <span data-testid="selected-value">{value}</span>
},
SelectContent: ({
children,
popupClassName,
}: {
children: React.ReactNode
popupClassName?: string
}) => (
<div data-testid="select-content" data-popup-class={popupClassName}>
{children}
</div>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode
value: string
}) => {
const { onValueChange } = React.useContext(MockSelectContext)
return (
<button
data-testid={`select-item-${value}`}
onClick={() => onValueChange(value)}
>
vi.mock('@/app/components/base/ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/select')>()
return {
...actual,
Select: ({
value,
onValueChange,
children,
}: {
value: string
onValueChange: (value: string) => void
children: React.ReactNode
}) => (
<MockSelectContext.Provider value={{ value, onValueChange }}>
<div data-testid="select-root">{children}</div>
</MockSelectContext.Provider>
),
SelectTrigger: ({
children,
className,
'data-testid': testId,
}: {
'children': React.ReactNode
'className'?: string
'data-testid'?: string
}) => (
<button data-testid={testId ?? 'select-trigger'} data-class={className}>
{children}
</button>
)
},
}))
),
SelectValue: () => {
const { value } = React.useContext(MockSelectContext)
return <span data-testid="selected-value">{value}</span>
},
SelectContent: ({
children,
popupClassName,
}: {
children: React.ReactNode
popupClassName?: string
}) => (
<div data-testid="select-content" data-popup-class={popupClassName}>
{children}
</div>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode
value: string
}) => {
const { onValueChange } = React.useContext(MockSelectContext)
return (
<button
data-testid={`select-item-${value}`}
onClick={() => onValueChange(value)}
>
{children}
</button>
)
},
SelectItemText: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => <span data-class={className}>{children}</span>,
SelectItemIndicator: ({
className,
}: {
className?: string
}) => <span data-testid="select-item-indicator" data-class={className} />,
}
})
// ==================== Test Utilities ====================

View File

@ -1,7 +1,7 @@
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { languages } from '@/i18n-config/language'
type Props = {
@ -37,7 +37,7 @@ const TTSParamsPanel = ({
return (
<>
<div className="mb-3">
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
<div className="mb-1 flex items-center py-1 system-sm-semibold text-text-secondary">
{t('voice.voiceSettings.language', { ns: 'appDebug' })}
</div>
<Select
@ -58,14 +58,15 @@ const TTSParamsPanel = ({
<SelectContent popupClassName="w-[354px]">
{supportedLanguages.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.name}
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mb-3">
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
<div className="mb-1 flex items-center py-1 system-sm-semibold text-text-secondary">
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
</div>
<Select
@ -86,7 +87,8 @@ const TTSParamsPanel = ({
<SelectContent popupClassName="w-[354px]">
{voiceList.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.label}
<SelectItemText>{item.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>

View File

@ -36,34 +36,85 @@ vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({
}))
vi.mock('@/app/components/base/ui/button', () => ({
Button: ({ children }: { children: React.ReactNode }) => <span data-testid="button-content">{children}</span>,
Button: ({ children, onClick, className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" data-testid="button-content" className={className} onClick={onClick} {...props}>{children}</button>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
const React = await import('react')
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context)
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
PortalToFollowElem: ({
DropdownMenu: ({
open,
onOpenChange,
children,
}: {
open: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}) => {
portalOpen = open
return <div>{children}</div>
return (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
)
},
PortalToFollowElemTrigger: ({
DropdownMenuTrigger: ({
children,
onClick,
render,
}: {
children: React.ReactNode
onClick: () => void
}) => <button data-testid="dropdown-trigger" onClick={onClick}>{children}</button>,
PortalToFollowElemContent: ({
onClick?: React.MouseEventHandler<HTMLElement>
render?: React.ReactElement
}) => {
const { isOpen, setOpen } = useDropdownMenuContext()
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
onClick?.(e)
setOpen(!isOpen)
}
if (render)
return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record<string, unknown>, children)
return <button data-testid="dropdown-trigger" onClick={handleClick}>{children}</button>
},
DropdownMenuContent: ({
children,
}: {
children: React.ReactNode
}) => portalOpen ? <div data-testid="dropdown-content">{children}</div> : null,
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-item"
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
@ -131,13 +182,13 @@ describe('InstallPluginDropdown', () => {
expect(onSwitchToMarketplaceTab).toHaveBeenCalledTimes(1)
})
it('opens the github installer when github is selected', () => {
it('opens the github installer when github is selected', async () => {
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(screen.getByText('plugin.source.github'))
expect(screen.getByTestId('github-modal')).toBeInTheDocument()
expect(await screen.findByTestId('github-modal')).toBeInTheDocument()
})
it('opens the local package installer when a file is selected', () => {
@ -153,4 +204,40 @@ describe('InstallPluginDropdown', () => {
expect(screen.getByTestId('local-modal')).toBeInTheDocument()
expect(screen.getByText('plugin.difypkg')).toBeInTheDocument()
})
it('triggers the hidden file input when local is selected from the menu', () => {
const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click')
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(screen.getByText('plugin.source.local'))
expect(clickSpy).toHaveBeenCalledTimes(1)
clickSpy.mockRestore()
})
it('closes the github installer when the modal requests close', async () => {
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(screen.getByText('plugin.source.github'))
fireEvent.click(await screen.findByTestId('close-github-modal'))
expect(screen.queryByTestId('github-modal')).not.toBeInTheDocument()
})
it('closes the local package installer when the modal requests close', () => {
const { container } = render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.change(container.querySelector('input[type="file"]')!, {
target: {
files: [new File(['content'], 'plugin.difypkg')],
},
})
fireEvent.click(screen.getByTestId('close-local-modal'))
expect(screen.queryByTestId('local-modal')).not.toBeInTheDocument()
})
})

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