mirror of
https://github.com/langgenius/dify.git
synced 2026-04-29 06:58:05 +08:00
Merge branch 'feat/dropdown-migaration' into deploy/dev
This commit is contained in:
@ -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):
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
14
api/uv.lock
generated
@ -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
692
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 }) => (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)' }}>
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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(),
|
||||
},
|
||||
}
|
||||
@ -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
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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'
|
||||
7
web/app/components/base/ui/overlay-shared.ts
Normal file
7
web/app/components/base/ui/overlay-shared.ts
Normal 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'
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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> </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)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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" />,
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
Reference in New Issue
Block a user