diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index d329d22309..b2b1049f0c 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -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//completion-conversations/") @@ -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//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//chat-conversations/") @@ -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): diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 6e7d586e35..ec29970ba6 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -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") diff --git a/api/controllers/console/evaluation/evaluation.py b/api/controllers/console/evaluation/evaluation.py index 31490020c3..ef901f8996 100644 --- a/api/controllers/console/evaluation/evaluation.py +++ b/api/controllers/console/evaluation/evaluation.py @@ -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") diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index 661a207658..0435661227 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -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) diff --git a/api/controllers/console/snippets/snippet_workflow_draft_variable.py b/api/controllers/console/snippets/snippet_workflow_draft_variable.py index ce3f5cef52..7688807c19 100644 --- a/api/controllers/console/snippets/snippet_workflow_draft_variable.py +++ b/api/controllers/console/snippets/snippet_workflow_draft_variable.py @@ -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 diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py index 1afcbdb5b9..5cb1e9087c 100644 --- a/api/fields/conversation_fields.py +++ b/api/fields/conversation_fields.py @@ -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: diff --git a/api/fields/conversation_variable_fields.py b/api/fields/conversation_variable_fields.py index c55014a368..cb6cdb309a 100644 --- a/api/fields/conversation_variable_fields.py +++ b/api/fields/conversation_variable_fields.py @@ -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) diff --git a/api/fields/workflow_app_log_fields.py b/api/fields/workflow_app_log_fields.py index 195b720285..7d45bc977d 100644 --- a/api/fields/workflow_app_log_fields.py +++ b/api/fields/workflow_app_log_fields.py @@ -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] diff --git a/api/fields/workflow_run_fields.py b/api/fields/workflow_run_fields.py index 35bb442c59..8c659086ed 100644 --- a/api/fields/workflow_run_fields.py +++ b/api/fields/workflow_run_fields.py @@ -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] diff --git a/api/pyproject.toml b/api/pyproject.toml index c2c40243a9..af8eb864b0 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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", diff --git a/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py index d457b59d58..48fec441c5 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py @@ -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 diff --git a/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py b/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py index 6a8e186958..39c58987fd 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py @@ -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" diff --git a/api/tests/unit_tests/controllers/console/app/test_conversation_api.py b/api/tests/unit_tests/controllers/console/app/test_conversation_api.py index 11b3b3470d..24b7e39f73 100644 --- a/api/tests/unit_tests/controllers/console/app/test_conversation_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_conversation_api.py @@ -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: diff --git a/api/tests/unit_tests/services/test_async_workflow_service.py b/api/tests/unit_tests/services/test_async_workflow_service.py index 361e95a557..73fc399ac3 100644 --- a/api/tests/unit_tests/services/test_async_workflow_service.py +++ b/api/tests/unit_tests/services/test_async_workflow_service.py @@ -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( diff --git a/api/uv.lock b/api/uv.lock index fa5e876429..db00ccf800 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -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]] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d6dfa8b2f..d37f6b7977 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,8 +274,8 @@ catalogs: specifier: 10.6.0 version: 10.6.0 dompurify: - specifier: 3.3.3 - version: 3.3.3 + specifier: 3.4.0 + version: 3.4.0 echarts: specifier: 6.0.0 version: 6.0.0 @@ -334,8 +334,8 @@ catalogs: specifier: 2.3.6 version: 2.3.6 hono: - specifier: 4.12.12 - version: 4.12.12 + specifier: 4.12.14 + version: 4.12.14 html-entities: specifier: 2.6.0 version: 2.6.0 @@ -526,8 +526,8 @@ catalogs: specifier: 12.0.0-beta.1 version: 12.0.0-beta.1 vite-plus: - specifier: 0.1.16 - version: 0.1.16 + specifier: 0.1.18 + version: 0.1.18 vitest-canvas-mock: specifier: 1.1.4 version: 1.1.4 @@ -551,8 +551,8 @@ overrides: flatted@<=3.4.1: 3.4.2 glob@>=10.2.0 <10.5.0: 11.1.0 is-core-module: npm:@nolyfill/is-core-module@^1.0.39 - lodash@>=4.0.0 <= 4.17.23: 4.18.0 lodash-es@>=4.0.0 <= 4.17.23: 4.18.0 + lodash@>=4.0.0 <= 4.17.23: 4.18.0 picomatch@<2.3.2: 2.3.2 picomatch@>=4.0.0 <4.0.4: 4.0.4 rollup@>=4.0.0 <4.59.0: 4.59.0 @@ -565,8 +565,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 @@ -575,11 +575,11 @@ importers: .: devDependencies: vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 - version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-core@0.1.18 + version: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) e2e: devDependencies: @@ -599,11 +599,11 @@ importers: specifier: 'catalog:' version: 6.0.2 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 - version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-core@0.1.18 + version: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) packages/dify-ui: dependencies: @@ -640,7 +640,7 @@ importers: version: 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.4(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 4.1.4(@voidzero-dev/vite-plus-test@0.1.18) eslint: specifier: 'catalog:' version: 10.2.0(jiti@2.6.1) @@ -648,14 +648,14 @@ importers: specifier: 'catalog:' version: 6.0.2 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 - version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-core@0.1.18 + version: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) vitest: - specifier: npm:@voidzero-dev/vite-plus-test@0.1.16 - version: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-test@0.1.18 + version: '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' web: dependencies: @@ -775,7 +775,7 @@ importers: version: 10.6.0 dompurify: specifier: 'catalog:' - version: 3.3.3 + version: 3.4.0 echarts: specifier: 'catalog:' version: 6.0.0 @@ -968,7 +968,7 @@ importers: devDependencies: '@antfu/eslint-config': specifier: 'catalog:' - version: 8.2.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.3)(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(typescript@6.0.2) + version: 8.2.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.3)(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.18)(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.20.0))(typescript@6.0.2) '@chromatic-com/storybook': specifier: 'catalog:' version: 5.1.2(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) @@ -983,7 +983,7 @@ importers: version: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) '@hono/node-server': specifier: 'catalog:' - version: 1.19.14(hono@4.12.12) + version: 1.19.14(hono@4.12.14) '@iconify-json/heroicons': specifier: 'catalog:' version: 1.2.3 @@ -995,7 +995,7 @@ importers: version: link:../packages/dify-ui '@mdx-js/loader': specifier: 'catalog:' - version: 3.1.1(webpack@5.105.4(uglify-js@3.19.3)) + version: 3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) '@mdx-js/react': specifier: 'catalog:' version: 3.1.1(@types/react@19.2.14)(react@19.2.5) @@ -1007,13 +1007,13 @@ importers: version: 16.2.3 '@next/mdx': specifier: 'catalog:' - version: 16.2.3(@mdx-js/loader@3.1.1(webpack@5.105.4(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5)) + version: 16.2.3(@mdx-js/loader@3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5)) '@rgrove/parse-xml': specifier: 'catalog:' version: 4.2.0 '@storybook/addon-docs': specifier: 'catalog:' - version: 10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + version: 10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) '@storybook/addon-links': specifier: 'catalog:' version: 10.3.5(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) @@ -1025,7 +1025,7 @@ importers: version: 10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@storybook/nextjs-vite': specifier: 'catalog:' - version: 10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3)) + version: 10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) '@storybook/react': specifier: 'catalog:' version: 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) @@ -1034,7 +1034,7 @@ importers: version: 4.2.2 '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.2(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 4.2.2(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) '@tanstack/eslint-plugin-query': specifier: 'catalog:' version: 5.99.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) @@ -1100,13 +1100,13 @@ importers: version: 7.0.0-dev.20260413.1 '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.24(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5) + version: 0.5.24(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.5) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.4(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 4.1.4(@voidzero-dev/vite-plus-test@0.1.18) agentation: specifier: 'catalog:' version: 3.0.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1121,7 +1121,7 @@ importers: version: 0.6.1(eslint@10.2.0(jiti@2.6.1)) eslint-plugin-better-tailwindcss: specifier: 'catalog:' - version: 4.4.1(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.2.2)(typescript@6.0.2) + version: 4.4.1(eslint@10.2.0(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.2.2)(typescript@6.0.2) eslint-plugin-hyoban: specifier: 'catalog:' version: 0.14.1(eslint@10.2.0(jiti@2.6.1)) @@ -1145,7 +1145,7 @@ importers: version: 20.9.0 hono: specifier: 'catalog:' - version: 4.12.12 + version: 4.12.14 knip: specifier: 'catalog:' version: 6.4.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) @@ -1154,7 +1154,7 @@ importers: version: 8.5.9 react-server-dom-webpack: specifier: 'catalog:' - version: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)) + version: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) storybook: specifier: 'catalog:' version: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1172,22 +1172,22 @@ importers: version: 3.19.3 vinext: specifier: 'catalog:' - version: 0.0.41(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)(typescript@6.0.2) + version: 0.0.41(453b4e184a832f83060410b31544dc36) vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 - version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-core@0.1.18 + version: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plugin-inspect: specifier: 'catalog:' - version: 12.0.0-beta.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0) + version: 12.0.0-beta.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0) vite-plus: specifier: 'catalog:' - version: 0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) vitest: - specifier: npm:@voidzero-dev/vite-plus-test@0.1.16 - version: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-test@0.1.18 + version: '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vitest-canvas-mock: specifier: 'catalog:' - version: 1.1.4(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 1.1.4(@voidzero-dev/vite-plus-test@0.1.18) packages: @@ -2550,15 +2550,15 @@ packages: cpu: [x64] os: [win32] - '@oxc-project/runtime@0.123.0': - resolution: {integrity: sha512-wRf0z8saz9tHLcK3YeTeBmwISrpy4bBimvKxUmryiIhbt+ZJb0nwwJNL3D8xpeWbNfZlGSlzRBZbfcbApIGZJw==} + '@oxc-project/runtime@0.124.0': + resolution: {integrity: sha512-sSg6n37J3w3mM4odFvRqzQENf6+qxKnvStr/gU0FgRRg1VE/4MqryLd9PJmE0a7K5xlDfbrctBtSagaFH6ij9Q==} engines: {node: ^20.19.0 || >=22.12.0} '@oxc-project/types@0.121.0': resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==} - '@oxc-project/types@0.123.0': - resolution: {integrity: sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==} + '@oxc-project/types@0.124.0': + resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} '@oxc-resolver/binding-android-arm-eabi@11.19.1': resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} @@ -2668,124 +2668,124 @@ packages: cpu: [x64] os: [win32] - '@oxfmt/binding-android-arm-eabi@0.43.0': - resolution: {integrity: sha512-CgU2s+/9hHZgo0IxVxrbMPrMj+tJ6VM3mD7Mr/4oiz4FNTISLoCvRmB5nk4wAAle045RtRjd86m673jwPyb1OQ==} + '@oxfmt/binding-android-arm-eabi@0.45.0': + resolution: {integrity: sha512-A/UMxFob1fefCuMeGxQBulGfFE38g2Gm23ynr3u6b+b7fY7/ajGbNsa3ikMIkGMLJW/TRoQaMoP1kME7S+815w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.43.0': - resolution: {integrity: sha512-T9OfRwjA/EdYxAqbvR7TtqLv5nIrwPXuCtTwOHtS7aR9uXyn74ZYgzgTo6/ZwvTq9DY4W+DsV09hB2EXgn9EbA==} + '@oxfmt/binding-android-arm64@0.45.0': + resolution: {integrity: sha512-L63z4uZmHjgvvqvMJD7mwff8aSBkM0+X4uFr6l6U5t6+Qc9DCLVZWIunJ7Gm4fn4zHPdSq6FFQnhu9yqqobxIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.43.0': - resolution: {integrity: sha512-o3i49ZUSJWANzXMAAVY1wnqb65hn4JVzwlRQ5qfcwhRzIA8lGVaud31Q3by5ALHPrksp5QEaKCQF9aAS3TXpZA==} + '@oxfmt/binding-darwin-arm64@0.45.0': + resolution: {integrity: sha512-UV34dd623FzqT+outIGndsCA/RBB+qgB3XVQhgmmJ9PJwa37NzPC9qzgKeOhPKxVk2HW+JKldQrVL54zs4Noww==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.43.0': - resolution: {integrity: sha512-vWECzzCFkb0kK6jaHjbtC5sC3adiNWtqawFCxhpvsWlzVeKmv5bNvkB4nux+o4JKWTpHCM57NDK/MeXt44txmA==} + '@oxfmt/binding-darwin-x64@0.45.0': + resolution: {integrity: sha512-pMNJv0CMa1pDefVPeNbuQxibh8ITpWDFEhMC/IBB9Zlu76EbgzYwrzI4Cb11mqX2+rIYN70UTrh3z06TM59ptQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.43.0': - resolution: {integrity: sha512-rgz8JpkKiI/umOf7fl9gwKyQasC8bs5SYHy6g7e4SunfLBY3+8ATcD5caIg8KLGEtKFm5ujKaH8EfjcmnhzTLg==} + '@oxfmt/binding-freebsd-x64@0.45.0': + resolution: {integrity: sha512-xTcRoxbbo61sW2+ZRPeH+vp/o9G8gkdhiVumFU+TpneiPm14c79l6GFlxPXlCE9bNWikigbsrvJw46zCVAQFfg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.43.0': - resolution: {integrity: sha512-nWYnF3vIFzT4OM1qL/HSf1Yuj96aBuKWSaObXHSWliwAk2rcj7AWd6Lf7jowEBQMo4wCZVnueIGw/7C4u0KTBQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.45.0': + resolution: {integrity: sha512-hWL8Hdni+3U1mPFx1UtWeGp3tNb6EhBAUHRMbKUxVkOp3WwoJbpVO2bfUVbS4PfpledviXXNHSTl1veTa6FhkQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.43.0': - resolution: {integrity: sha512-sFg+NWJbLfupYTF4WELHAPSnLPOn1jiDZ33Z1jfDnTaA+cC3iB35x0FMMZTFdFOz3icRIArncwCcemJFGXu6TQ==} + '@oxfmt/binding-linux-arm-musleabihf@0.45.0': + resolution: {integrity: sha512-6Blt/0OBT7vvfQpqYuYbpbFLPqSiaYpEJzUUWhinPEuADypDbtV1+LdjM0vYBNGPvnj85ex7lTerEX6JGcPt9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.43.0': - resolution: {integrity: sha512-MelWqv68tX6wZEILDrTc9yewiGXe7im62+5x0bNXlCYFOZdA+VnYiJfAihbROsZ5fm90p9C3haFrqjj43XnlAA==} + '@oxfmt/binding-linux-arm64-gnu@0.45.0': + resolution: {integrity: sha512-jLjoLfe+hGfjhA8hNBSdw85yCA8ePKq7ME4T+g6P9caQXvmt6IhE2X7iVjnVdkmYUWEzZrxlh4p6RkDmAMJY/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.43.0': - resolution: {integrity: sha512-ROaWfYh+6BSJ1Arwy5ujijTlwnZetxDxzBpDc1oBR4d7rfrPBqzeyjd5WOudowzQUgyavl2wEpzn1hw3jWcqLA==} + '@oxfmt/binding-linux-arm64-musl@0.45.0': + resolution: {integrity: sha512-XQKXZIKYJC3GQJ8FnD3iMntpw69Wd9kDDK/Xt79p6xnFYlGGxSNv2vIBvRTDg5CKByWFWWZLCRDOXoP/m6YN4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.43.0': - resolution: {integrity: sha512-PJRs/uNxmFipJJ8+SyKHh7Y7VZIKQicqrrBzvfyM5CtKi8D7yZKTwUOZV3ffxmiC2e7l1SDJpkBEOyue5NAFsg==} + '@oxfmt/binding-linux-ppc64-gnu@0.45.0': + resolution: {integrity: sha512-+g5RiG+xOkdrCWkKodv407nTvMq4vYM18Uox2MhZBm/YoqFxxJpWKsloskFFG5NU13HGPw1wzYjjOVcyd9moCA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.43.0': - resolution: {integrity: sha512-j6biGAgzIhj+EtHXlbNumvwG7XqOIdiU4KgIWRXAEj/iUbHKukKW8eXa4MIwpQwW1YkxovduKtzEAPnjlnAhVQ==} + '@oxfmt/binding-linux-riscv64-gnu@0.45.0': + resolution: {integrity: sha512-V7dXKoSyEbWAkkSF4JJNtF+NJZDmJoSarSoP30WCsB3X636Rehd3CvxBj49FIJxEBFWhvcUjGSHVeU8Erck1bQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.43.0': - resolution: {integrity: sha512-RYWxAcslKxvy7yri24Xm9cmD0RiANaiEPs007EFG6l9h1ChM69Q5SOzACaCoz4Z9dEplnhhneeBaTWMEdpgIbA==} + '@oxfmt/binding-linux-riscv64-musl@0.45.0': + resolution: {integrity: sha512-Vdelft1sAEYojVGgcODEFXSWYQYlIvoyIGWebKCuUibd1tvS1TjTx413xG2ZLuHpYj45CkN/ztMLMX6jrgqpgg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.43.0': - resolution: {integrity: sha512-DT6Q8zfQQy3jxpezAsBACEHNUUixKSYTwdXeXojNHe4DQOoxjPdjr3Szu6BRNjxLykZM/xMNmp9ElOIyDppwtw==} + '@oxfmt/binding-linux-s390x-gnu@0.45.0': + resolution: {integrity: sha512-RR7xKgNpqwENnK0aYCGYg0JycY2n93J0reNjHyes+I9Gq52dH95x+CBlnlAQHCPfz6FGnKA9HirgUl14WO6o7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.43.0': - resolution: {integrity: sha512-R8Yk7iYcuZORXmCfFZClqbDxRZgZ9/HEidUuBNdoX8Ptx07cMePnMVJ/woB84lFIDjh2ROHVaOP40Ds3rBXFqg==} + '@oxfmt/binding-linux-x64-gnu@0.45.0': + resolution: {integrity: sha512-U/QQ0+BQNSHxjuXR/utvXnQ50Vu5kUuqEomZvQ1/3mhgbBiMc2WU9q5kZ5WwLp3gnFIx9ibkveoRSe2EZubkqg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.43.0': - resolution: {integrity: sha512-F2YYqyvnQNvi320RWZNAvsaWEHwmW3k4OwNJ1hZxRKXupY63expbBaNp6jAgvYs7y/g546vuQnGHQuCBhslhLQ==} + '@oxfmt/binding-linux-x64-musl@0.45.0': + resolution: {integrity: sha512-o5TLOUCF0RWQjsIS06yVC+kFgp092/yLe6qBGSUvtnmTVw9gxjpdQSXc3VN5Cnive4K11HNstEZF8ROKHfDFSw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.43.0': - resolution: {integrity: sha512-OE6TdietLXV3F6c7pNIhx/9YC1/2YFwjU9DPc/fbjxIX19hNIaP1rS0cFjCGJlGX+cVJwIKWe8Mos+LdQ1yAJw==} + '@oxfmt/binding-openharmony-arm64@0.45.0': + resolution: {integrity: sha512-RnGcV3HgPuOjsGx/k9oyRNKmOp+NBLGzZTdPDYbc19r7NGeYPplnUU/BfU35bX2Y/O4ejvHxcfkvW2WoYL/gsg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.43.0': - resolution: {integrity: sha512-0nWK6a7pGkbdoypfVicmV9k/N1FwjPZENoqhlTU+5HhZnAhpIO3za30nEE33u6l6tuy9OVfpdXUqxUgZ+4lbZw==} + '@oxfmt/binding-win32-arm64-msvc@0.45.0': + resolution: {integrity: sha512-v3Vj7iKKsUFwt9w5hsqIIoErKVoENC6LoqfDlteOQ5QMDCXihlqLoxpmviUhXnNncg4zV6U9BPwlBbwa+qm4wg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.43.0': - resolution: {integrity: sha512-9aokTR4Ft+tRdvgN/pKzSkVy2ksc4/dCpDm9L/xFrbIw0yhLtASLbvoG/5WOTUh/BRPPnfGTsWznEqv0dlOmhA==} + '@oxfmt/binding-win32-ia32-msvc@0.45.0': + resolution: {integrity: sha512-N8yotPBX6ph0H3toF4AEpdCeVPrdcSetj+8eGiZGsrLsng3bs/Q5HPu4bbSxip5GBPx5hGbGHrZwH4+rcrjhHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.43.0': - resolution: {integrity: sha512-4bPgdQux2ZLWn3bf2TTXXMHcJB4lenmuxrLqygPmvCJ104Yqzj1UctxSRzR31TiJ4MLaG22RK8dUsVpJtrCz5g==} + '@oxfmt/binding-win32-x64-msvc@0.45.0': + resolution: {integrity: sha512-w5MMTRCK1dpQeRA+HHqXQXyN33DlG/N2LOYxJmaT4fJjcmZrbNnqw7SmIk7I2/a2493PPLZ+2E/Ar6t2iKVMug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2820,124 +2820,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.58.0': - resolution: {integrity: sha512-1T7UN3SsWWxpWyWGn1cT3ASNJOo+pI3eUkmEl7HgtowapcV8kslYpFQcYn431VuxghXakPNlbjRwhqmR37PFOg==} + '@oxlint/binding-android-arm-eabi@1.60.0': + resolution: {integrity: sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.58.0': - resolution: {integrity: sha512-GryzujxuiRv2YFF7bRy8mKcxlbuAN+euVUtGJt9KKbLT8JBUIosamVhcthLh+VEr6KE6cjeVMAQxKAzJcoN7dg==} + '@oxlint/binding-android-arm64@1.60.0': + resolution: {integrity: sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.58.0': - resolution: {integrity: sha512-7/bRSJIwl4GxeZL9rPZ11anNTyUO9epZrfEJH/ZMla3+/gbQ6xZixh9nOhsZ0QwsTW7/5J2A/fHbD1udC5DQQA==} + '@oxlint/binding-darwin-arm64@1.60.0': + resolution: {integrity: sha512-pJsgd9AfplLGBm1fIr25V6V14vMrayhx4uIQvlfH7jWs2SZwSrvi3TfgfJySB8T+hvyEH8K2zXljQiUnkgUnfQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.58.0': - resolution: {integrity: sha512-EqdtJSiHweS2vfILNrpyJ6HUwpEq2g7+4Zx1FPi4hu3Hu7tC3znF6ufbXO8Ub2LD4mGgznjI7kSdku9NDD1Mkg==} + '@oxlint/binding-darwin-x64@1.60.0': + resolution: {integrity: sha512-Ue1aXHX49ivwflKqGJc7zcd/LeLgbhaTcDCQStgx5x06AXgjEAZmvrlMuIkWd4AL4FHQe6QJ9f33z04Cg448VQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.58.0': - resolution: {integrity: sha512-VQt5TH4M42mY20F545G637RKxV/yjwVtKk2vfXuazfReSIiuvWBnv+FVSvIV5fKVTJNjt3GSJibh6JecbhGdBw==} + '@oxlint/binding-freebsd-x64@1.60.0': + resolution: {integrity: sha512-YCyQzsQtusQw+gNRW9rRTifSO+Dt/+dtCl2NHoDMZqJlRTEZ/Oht9YnuporI9yiTx7+cB+eqzX3MtHHVHGIWhg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.58.0': - resolution: {integrity: sha512-fBYcj4ucwpAtjJT3oeBdFBYKvNyjRSK+cyuvBOTQjh0jvKp4yeA4S/D0IsCHus/VPaNG5L48qQkh+Vjy3HL2/Q==} + '@oxlint/binding-linux-arm-gnueabihf@1.60.0': + resolution: {integrity: sha512-c7dxM2Zksa45Qw16i2iGY3Fti2NirJ38FrsBsKw+qcJ0OtqTsBgKJLF0xV+yLG56UH01Z8WRPgsw31e0MoRoGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.58.0': - resolution: {integrity: sha512-0BeuFfwlUHlJ1xpEdSD1YO3vByEFGPg36uLjK1JgFaxFb4W6w17F8ET8sz5cheZ4+x5f2xzdnRrrWv83E3Yd8g==} + '@oxlint/binding-linux-arm-musleabihf@1.60.0': + resolution: {integrity: sha512-ZWALoA42UYqBEP1Tbw9OWURgFGS1nWj2AAvLdY6ZcGx/Gj93qVCBKjcvwXMupZibYwFbi9s/rzqkZseb/6gVtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.58.0': - resolution: {integrity: sha512-TXlZgnPTlxrQzxG9ZXU7BNwx1Ilrr17P3GwZY0If2EzrinqRH3zXPc3HrRcBJgcsoZNMuNL5YivtkJYgp467UQ==} + '@oxlint/binding-linux-arm64-gnu@1.60.0': + resolution: {integrity: sha512-tpy+1w4p9hN5CicMCxqNy6ymfRtV5ayE573vFNjp1k1TN/qhLFgflveZoE/0++RlkHikBz2vY545NWm/hp7big==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.58.0': - resolution: {integrity: sha512-zSoYRo5dxHLcUx93Stl2hW3hSNjPt99O70eRVWt5A1zwJ+FPjeCCANCD2a9R4JbHsdcl11TIQOjyigcRVOH2mw==} + '@oxlint/binding-linux-arm64-musl@1.60.0': + resolution: {integrity: sha512-eDYDXZGhQAXyn6GwtwiX/qcLS0HlOLPJ/+iiIY8RYr+3P8oKBmgKxADLlniL6FtWfE7pPk7IGN9/xvDEvDvFeg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.58.0': - resolution: {integrity: sha512-NQ0U/lqxH2/VxBYeAIvMNUK1y0a1bJ3ZicqkF2c6wfakbEciP9jvIE4yNzCFpZaqeIeRYaV7AVGqEO1yrfVPjA==} + '@oxlint/binding-linux-ppc64-gnu@1.60.0': + resolution: {integrity: sha512-nxehly5XYBHUWI9VJX1bqCf9j/B43DaK/aS/T1fcxCpX3PA4Rm9BB54nPD1CKayT8xg6REN1ao+01hSRNgy8OA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.58.0': - resolution: {integrity: sha512-X9J+kr3gIC9FT8GuZt0ekzpNUtkBVzMVU4KiKDSlocyQuEgi3gBbXYN8UkQiV77FTusLDPsovjo95YedHr+3yg==} + '@oxlint/binding-linux-riscv64-gnu@1.60.0': + resolution: {integrity: sha512-j1qf/NaUfOWQutjeoooNG1Q0zsK0XGmSu1uDLq3cctquRF3j7t9Hxqf/76ehCc5GEUAanth2W4Fa+XT1RFg/nw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.58.0': - resolution: {integrity: sha512-CDze3pi1OO3Wvb/QsXjmLEY4XPKGM6kIo82ssNOgmcl1IdndF9VSGAE38YLhADWmOac7fjqhBw82LozuUVxD0Q==} + '@oxlint/binding-linux-riscv64-musl@1.60.0': + resolution: {integrity: sha512-YELKPRefQ/q/h3RUmeRfPCUhh2wBvgV1RyZ/F9M9u8cDyXsQW2ojv1DeWQTt466yczDITjZnIOg/s05pk7Ve2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.58.0': - resolution: {integrity: sha512-b/89glbxFaEAcA6Uf1FvCNecBJEgcUTsV1quzrqXM/o4R1M4u+2KCVuyGCayN2UpsRWtGGLb+Ver0tBBpxaPog==} + '@oxlint/binding-linux-s390x-gnu@1.60.0': + resolution: {integrity: sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.58.0': - resolution: {integrity: sha512-0/yYpkq9VJFCEcuRlrViGj8pJUFFvNS4EkEREaN7CB1EcLXJIaVSSa5eCihwBGXtOZxhnblWgxks9juRdNQI7w==} + '@oxlint/binding-linux-x64-gnu@1.60.0': + resolution: {integrity: sha512-XjKHdFVCpZZZSWBCKyyqCq65s2AKXykMXkjLoKYODrD+f5toLhlwsMESscu8FbgnJQ4Y/dpR/zdazsahmgBJIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.58.0': - resolution: {integrity: sha512-hr6FNvmcAXiH+JxSvaJ4SJ1HofkdqEElXICW9sm3/Rd5eC3t7kzvmLyRAB3NngKO2wzXRCAm4Z/mGWfrsS4X8w==} + '@oxlint/binding-linux-x64-musl@1.60.0': + resolution: {integrity: sha512-js29ZWIuPhNWzY8NC7KoffEMEeWG105vbmm+8EOJsC+T/jHBiKIJEUF78+F/IrgEWMMP9N0kRND4Pp75+xAhKg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.58.0': - resolution: {integrity: sha512-R+O368VXgRql1K6Xar+FEo7NEwfo13EibPMoTv3sesYQedRXd6m30Dh/7lZMxnrQVFfeo4EOfYIP4FpcgWQNHg==} + '@oxlint/binding-openharmony-arm64@1.60.0': + resolution: {integrity: sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.58.0': - resolution: {integrity: sha512-Q0FZiAY/3c4YRj4z3h9K1PgaByrifrfbBoODSeX7gy97UtB7pySPUQfC2B/GbxWU6k7CzQrRy5gME10PltLAFQ==} + '@oxlint/binding-win32-arm64-msvc@1.60.0': + resolution: {integrity: sha512-WA/yc7f7ZfCefBXVzNHn1Ztulb1EFwNBb4jMZ6pjML0zz6pHujlF3Q3jySluz3XHl/GNeMTntG1seUBWVMlMag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.58.0': - resolution: {integrity: sha512-Y8FKBABrSPp9H0QkRLHDHOSUgM/309a3IvOVgPcVxYcX70wxJrk608CuTg7w+C6vEd724X5wJoNkBcGYfH7nNQ==} + '@oxlint/binding-win32-ia32-msvc@1.60.0': + resolution: {integrity: sha512-33YxL1sqwYNZXtn3MD/4dno6s0xeedXOJlT1WohkVD565WvohClZUr7vwKdAk954n4xiEWJkewiCr+zLeq7AeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.58.0': - resolution: {integrity: sha512-bCn5rbiz5My+Bj7M09sDcnqW0QJyINRVxdZ65x1/Y2tGrMwherwK/lpk+HRQCKvXa8pcaQdF5KY5j54VGZLwNg==} + '@oxlint/binding-win32-x64-msvc@1.60.0': + resolution: {integrity: sha512-JOro4ZcfBLamJCyfURQmOQByoorgOdx3ZjAkSqnb/CyG/i+lN3KoV5LAgk5ZAW6DPq7/Cx7n23f8DuTWXTWgyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -4511,16 +4511,16 @@ packages: '@vitest/utils@4.1.4': resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} - '@voidzero-dev/vite-plus-core@0.1.16': - resolution: {integrity: sha512-fOyf14CXjcXqANFs2fCXEX+0Tn9ZjmqfFV+qTnARwIF1Kzl8WquO4XtvlDgs/fTQ91H4AyoNUgkvWdKS+C4xYA==} + '@voidzero-dev/vite-plus-core@0.1.18': + resolution: {integrity: sha512-3PmXOL26yHzlw8ET9SwXCmglGzUYq2fOTYf2t0mxvVIs7ua3bnf6tOnmR+6YX5k1Ez26B0ooYzx+znc8k+CAMw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@arethetypeswrong/core': ^0.18.1 - '@tsdown/css': 0.21.7 - '@tsdown/exe': 0.21.7 + '@tsdown/css': 0.21.8 + '@tsdown/exe': 0.21.8 '@types/node': ^20.19.0 || >=22.12.0 '@vitejs/devtools': ^0.1.0 - esbuild: ^0.28.0 + esbuild: 0.27.2 jiti: '>=1.21.0' less: ^4.0.0 publint: ^0.3.0 @@ -4571,54 +4571,56 @@ packages: yaml: optional: true - '@voidzero-dev/vite-plus-darwin-arm64@0.1.16': - resolution: {integrity: sha512-InG0ZmuGh7DTrn7zWQ0UvKapElphKI6G1oYfys+jraedG70EhIIee9gtO+mTE1T0bF67SgAcLXwNyaiNda0XwA==} + '@voidzero-dev/vite-plus-darwin-arm64@0.1.18': + resolution: {integrity: sha512-bw2pWWE8RZRELWjXcdxdmRaOaYjmGmsxEm23TxvGxQXFb7k9l51W8tpjxariPGLxrEl+Cw5u601IL5LASaPJ5w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@voidzero-dev/vite-plus-darwin-x64@0.1.16': - resolution: {integrity: sha512-LGNrECstuhkCRKRj/dE98Xcprw8HU3VMIMJnZsnDR2C5RB2HADNIu21at/a/G3giA9eWm7uhtPp9FvUtTCK9TA==} + '@voidzero-dev/vite-plus-darwin-x64@0.1.18': + resolution: {integrity: sha512-8TFj6yJNsumoH+yFc+6zf3g2UuzvrPHq2FAAVORffaVZ29PWnDSsXjegaIBmoAtGO5Xb4lcilQx7NoF9hONrZg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.16': - resolution: {integrity: sha512-AoFKu6dIOtlkp/mwmtU8ES2uzoaxCHhIym1Tk7qMxyvke4IXnye6VDc4kPMRQwD8mwR3T3bO0HuaEEHxrIWDxw==} + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.18': + resolution: {integrity: sha512-xHRqncKanOZ0zNnZSufL4Yx/gWrIFkCjU6jFzCukBOOCrcemq3SrALPHrNf+Nw1RLwNptGUZn2Vx/IjRLzUQDw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.16': - resolution: {integrity: sha512-PloCsGTRIhcXIpUOJ6PqVG8gYNpq+ooJNyqy5sQ82BRnJuo8oV7uBLFvg0X9B3Bzh+vO1F8/+92+o5TiL35JMg==} + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.18': + resolution: {integrity: sha512-CA6XxZbkT8lYwWzS2yAj6exr7nHl3R8Sz+ZdOhYCU4yR2qvzGatdVgFr7oPnrkHLF426cHJ172rmNNj8NKie/w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.16': - resolution: {integrity: sha512-nY9/2g+qjhwsW5U3MrFLlx+bOBsdOJiO2HzbxQy7jo/S3jPTnXhFlrRegQuAmqrHAXrSdNwgblgRpICKhx1xZg==} + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.18': + resolution: {integrity: sha512-xBO3MtLGVASPjH/GDRxexfLCT0othVpiFMdEQ83Y+woVNbrrzcdQTGFUuFG4cAiMhtmjytyFwPBtZ76BWsDO3w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-x64-musl@0.1.16': - resolution: {integrity: sha512-JGKEAMoXqzdr9lHT/13uRNV9uzrSYXAFhjAfIC8WEQMG2VUFksvq5/TOc26hzmzbqu+bxRmfN8h1aVTDL8KwFg==} + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.18': + resolution: {integrity: sha512-ADNis6SMarY7i8+b2ynUJ1PiqCHqnVwY7EQ+fSGug5zZ+W/cZq14+VWPxOvGR9LJk+iol8XuqsHy4BaV2+gjzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-test@0.1.16': - resolution: {integrity: sha512-d/rJPX/heMzoAFdnpZsp04MAa6nw1yH1tA4mVCV4m8goVcE9nAvt69mjLMzE8N/rYIQOSgenf3hDXuQRuD6OKQ==} + '@voidzero-dev/vite-plus-test@0.1.18': + resolution: {integrity: sha512-dovC2kJgiwMI8ay0i+3NvQGCDWPj8HQB2ONP/HbdJ5/XQVPq13+BihnCq8/ztz6uGhiDD8Nu4OZ3RgB14uvTfA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/ui': 4.1.2 + '@vitest/coverage-istanbul': 4.1.4 + '@vitest/coverage-v8': 4.1.4 + '@vitest/ui': 4.1.4 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -4629,6 +4631,10 @@ packages: optional: true '@types/node': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -4636,14 +4642,14 @@ packages: jsdom: optional: true - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.16': - resolution: {integrity: sha512-IugPUCLY7HmiPcCeuHKUqO1+G2vxHnYzAGhS02AixD0sJLTAIKCUANDOiVUFf/HMw+jh/UkugW7MWek8lf/JrQ==} + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.18': + resolution: {integrity: sha512-EcDETMHG8xgjIlMizIu/wf0UtRZLGz+lHFvYFZVCkz4vLLz93a06vZ+3Oi9xY2Kc8aOHsCf8Gj5/dox/03cscw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.16': - resolution: {integrity: sha512-tq93CIeMs92HF7rdylJknRiyzMOWMKCmpw+g8nl5Q5nmUDNLUsrL3CGfbyqjgbruuPnIr761r9MfydPqZU/cYg==} + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.18': + resolution: {integrity: sha512-jBgL4ZjSJJu3FDcrqj4muzbr0WKlU6Ym1ilHQnq8R+2TRvE0AtvAMMuphICDslZGi6EK3fwJ+r2Lv7GU1AipQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5455,8 +5461,8 @@ packages: resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} engines: {node: '>=20'} - dompurify@3.3.3: - resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + dompurify@3.4.0: + resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -6179,8 +6185,8 @@ packages: resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} engines: {node: '>=6'} - hono@4.12.12: - resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} + hono@4.12.14: + resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} engines: {node: '>=16.9.0'} hosted-git-info@9.0.2: @@ -7116,8 +7122,8 @@ packages: oxc-resolver@11.19.1: resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} - oxfmt@0.43.0: - resolution: {integrity: sha512-KTYNG5ISfHSdmeZ25Xzb3qgz9EmQvkaGAxgBY/p38+ZiAet3uZeu7FnMwcSQJg152Qwl0wnYAxDc+Z/H6cvrwA==} + oxfmt@0.45.0: + resolution: {integrity: sha512-0o/COoN9fY50bjVeM7PQsNgbhndKurBIeTIcspW033OumksjJJmIVDKjAk5HMwU/GHTxSOdGDdhJ6BRzGPmsHg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -7125,8 +7131,8 @@ packages: resolution: {integrity: sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ==} hasBin: true - oxlint@1.58.0: - resolution: {integrity: sha512-t4s9leczDMqlvOSjnbCQe7gtoLkWgBGZ7sBdCJ9EOj5IXFSG/X7OAzK4yuH4iW+4cAYe8kLFbC8tuYMwWZm+Cg==} + oxlint@1.60.0: + resolution: {integrity: sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -8406,8 +8412,8 @@ packages: storybook: ^0.0.0-0 || ^9.0.0 || ^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - vite-plus@0.1.16: - resolution: {integrity: sha512-sgYHc5zWLSDInaHb/abvEA7UOwh7sUWuyNt+Slphj55jPvzodT8Dqw115xyKwDARTuRFSpm1eo/t58qZ8/NylQ==} + vite-plus@0.1.18: + resolution: {integrity: sha512-RiWUoOmQiJMtd4Dfm6WD0v0Selqh/nQzmaGVIrkfnr+2s5UxGVZy7n2TCO5ZnR7w9noMIgtUAQN8GtKhwHEiOQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -8809,17 +8815,17 @@ snapshots: idb: 8.0.0 tslib: 2.8.1 - '@antfu/eslint-config@8.2.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.3)(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(typescript@6.0.2)': + '@antfu/eslint-config@8.2.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.3)(@typescript-eslint/rule-tester@8.57.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.18)(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.20.0))(typescript@6.0.2)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.2.0 - '@e18e/eslint-plugin': 0.3.0(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0)) + '@e18e/eslint-plugin': 0.3.0(eslint@10.2.0(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.20.0)) '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.2.0(jiti@2.6.1)) '@eslint/markdown': 8.0.1 '@stylistic/eslint-plugin': 5.10.0(eslint@10.2.0(jiti@2.6.1)) '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) '@typescript-eslint/parser': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@vitest/eslint-plugin': 1.6.15(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@vitest/eslint-plugin': 1.6.15(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.18)(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) ansis: 4.2.0 cac: 7.0.0 eslint: 10.2.0(jiti@2.6.1) @@ -9207,12 +9213,12 @@ snapshots: '@date-fns/tz@1.4.1': {} - '@e18e/eslint-plugin@0.3.0(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))': + '@e18e/eslint-plugin@0.3.0(eslint@10.2.0(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.20.0))': dependencies: eslint-plugin-depend: 1.5.0(eslint@10.2.0(jiti@2.6.1)) optionalDependencies: eslint: 10.2.0(jiti@2.6.1) - oxlint: 1.58.0(oxlint-tsgolint@0.20.0) + oxlint: 1.60.0(oxlint-tsgolint@0.20.0) '@egoist/tailwindcss-icons@1.9.2(tailwindcss@4.2.2)': dependencies: @@ -9598,9 +9604,9 @@ snapshots: dependencies: react: 19.2.5 - '@hono/node-server@1.19.14(hono@4.12.12)': + '@hono/node-server@1.19.14(hono@4.12.14)': dependencies: - hono: 4.12.12 + hono: 4.12.14 '@humanfs/core@0.19.1': {} @@ -9756,11 +9762,11 @@ snapshots: dependencies: minipass: 7.1.3 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@6.0.2) - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' optionalDependencies: typescript: 6.0.2 @@ -9945,12 +9951,12 @@ snapshots: lexical: 0.43.0 yjs: 13.6.30 - '@mdx-js/loader@3.1.1(webpack@5.105.4(uglify-js@3.19.3))': + '@mdx-js/loader@3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': dependencies: '@mdx-js/mdx': 3.1.1 source-map: 0.7.6 optionalDependencies: - webpack: 5.105.4(uglify-js@3.19.3) + webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3) transitivePeerDependencies: - supports-color @@ -10032,11 +10038,11 @@ snapshots: dependencies: fast-glob: 3.3.1 - '@next/mdx@16.2.3(@mdx-js/loader@3.1.1(webpack@5.105.4(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5))': + '@next/mdx@16.2.3(@mdx-js/loader@3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5))': dependencies: source-map: 0.7.6 optionalDependencies: - '@mdx-js/loader': 3.1.1(webpack@5.105.4(uglify-js@3.19.3)) + '@mdx-js/loader': 3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.5) '@next/swc-darwin-arm64@16.2.3': @@ -10208,11 +10214,11 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.121.0': optional: true - '@oxc-project/runtime@0.123.0': {} + '@oxc-project/runtime@0.124.0': {} '@oxc-project/types@0.121.0': {} - '@oxc-project/types@0.123.0': {} + '@oxc-project/types@0.124.0': {} '@oxc-resolver/binding-android-arm-eabi@11.19.1': optional: true @@ -10279,61 +10285,61 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.19.1': optional: true - '@oxfmt/binding-android-arm-eabi@0.43.0': + '@oxfmt/binding-android-arm-eabi@0.45.0': optional: true - '@oxfmt/binding-android-arm64@0.43.0': + '@oxfmt/binding-android-arm64@0.45.0': optional: true - '@oxfmt/binding-darwin-arm64@0.43.0': + '@oxfmt/binding-darwin-arm64@0.45.0': optional: true - '@oxfmt/binding-darwin-x64@0.43.0': + '@oxfmt/binding-darwin-x64@0.45.0': optional: true - '@oxfmt/binding-freebsd-x64@0.43.0': + '@oxfmt/binding-freebsd-x64@0.45.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.43.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.45.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.43.0': + '@oxfmt/binding-linux-arm-musleabihf@0.45.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.43.0': + '@oxfmt/binding-linux-arm64-gnu@0.45.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.43.0': + '@oxfmt/binding-linux-arm64-musl@0.45.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.43.0': + '@oxfmt/binding-linux-ppc64-gnu@0.45.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.43.0': + '@oxfmt/binding-linux-riscv64-gnu@0.45.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.43.0': + '@oxfmt/binding-linux-riscv64-musl@0.45.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.43.0': + '@oxfmt/binding-linux-s390x-gnu@0.45.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.43.0': + '@oxfmt/binding-linux-x64-gnu@0.45.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.43.0': + '@oxfmt/binding-linux-x64-musl@0.45.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.43.0': + '@oxfmt/binding-openharmony-arm64@0.45.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.43.0': + '@oxfmt/binding-win32-arm64-msvc@0.45.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.43.0': + '@oxfmt/binding-win32-ia32-msvc@0.45.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.43.0': + '@oxfmt/binding-win32-x64-msvc@0.45.0': optional: true '@oxlint-tsgolint/darwin-arm64@0.20.0': @@ -10354,61 +10360,61 @@ snapshots: '@oxlint-tsgolint/win32-x64@0.20.0': optional: true - '@oxlint/binding-android-arm-eabi@1.58.0': + '@oxlint/binding-android-arm-eabi@1.60.0': optional: true - '@oxlint/binding-android-arm64@1.58.0': + '@oxlint/binding-android-arm64@1.60.0': optional: true - '@oxlint/binding-darwin-arm64@1.58.0': + '@oxlint/binding-darwin-arm64@1.60.0': optional: true - '@oxlint/binding-darwin-x64@1.58.0': + '@oxlint/binding-darwin-x64@1.60.0': optional: true - '@oxlint/binding-freebsd-x64@1.58.0': + '@oxlint/binding-freebsd-x64@1.60.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.58.0': + '@oxlint/binding-linux-arm-gnueabihf@1.60.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.58.0': + '@oxlint/binding-linux-arm-musleabihf@1.60.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.58.0': + '@oxlint/binding-linux-arm64-gnu@1.60.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.58.0': + '@oxlint/binding-linux-arm64-musl@1.60.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.58.0': + '@oxlint/binding-linux-ppc64-gnu@1.60.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.58.0': + '@oxlint/binding-linux-riscv64-gnu@1.60.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.58.0': + '@oxlint/binding-linux-riscv64-musl@1.60.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.58.0': + '@oxlint/binding-linux-s390x-gnu@1.60.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.58.0': + '@oxlint/binding-linux-x64-gnu@1.60.0': optional: true - '@oxlint/binding-linux-x64-musl@1.58.0': + '@oxlint/binding-linux-x64-musl@1.60.0': optional: true - '@oxlint/binding-openharmony-arm64@1.58.0': + '@oxlint/binding-openharmony-arm64@1.60.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.58.0': + '@oxlint/binding-win32-arm64-msvc@1.60.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.58.0': + '@oxlint/binding-win32-ia32-msvc@1.60.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.58.0': + '@oxlint/binding-win32-x64-msvc@1.60.0': optional: true '@parcel/watcher-android-arm64@2.5.6': @@ -10991,10 +10997,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3))': + '@storybook/addon-docs@10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.5) - '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) react: 19.2.5 @@ -11024,25 +11030,26 @@ snapshots: storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 - '@storybook/builder-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3))': + '@storybook/builder-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': dependencies: - '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3))': + '@storybook/csf-plugin@10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': dependencies: storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) unplugin: 2.3.11 optionalDependencies: + esbuild: 0.27.2 rollup: 4.59.0 - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - webpack: 5.105.4(uglify-js@3.19.3) + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3) '@storybook/global@5.0.0': {} @@ -11051,18 +11058,18 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - '@storybook/nextjs-vite@10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3))': + '@storybook/nextjs-vite@10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': dependencies: - '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) '@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) - '@storybook/react-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3)) + '@storybook/react-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5) - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) optionalDependencies: typescript: 6.0.2 transitivePeerDependencies: @@ -11079,11 +11086,11 @@ snapshots: react-dom: 19.2.5(react@19.2.5) storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@storybook/react-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(uglify-js@3.19.3))': + '@storybook/react-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(uglify-js@3.19.3)) + '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) '@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) empathic: 2.0.0 magic-string: 0.30.21 @@ -11093,7 +11100,7 @@ snapshots: resolve: 1.22.11 storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tsconfig-paths: 4.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - esbuild - rollup @@ -11232,12 +11239,12 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.2 - '@tailwindcss/vite@4.2.2(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': + '@tailwindcss/vite@4.2.2(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' '@tanstack/devtools-client@0.0.6': dependencies: @@ -11943,12 +11950,12 @@ snapshots: '@resvg/resvg-wasm': 2.4.0 satori: 0.16.0 - '@vitejs/devtools-kit@0.1.11(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0)': + '@vitejs/devtools-kit@0.1.11(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0)': dependencies: '@vitejs/devtools-rpc': 0.1.11(typescript@6.0.2)(ws@8.20.0) birpc: 4.0.0 ohash: 2.0.11 - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - typescript - ws @@ -11965,12 +11972,12 @@ snapshots: transitivePeerDependencies: - typescript - '@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': + '@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - '@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)': + '@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.5)': dependencies: '@rolldown/pluginutils': 1.0.0-rc.15 es-module-lexer: 2.0.0 @@ -11981,12 +11988,12 @@ snapshots: srvx: 0.11.15 strip-literal: 3.1.0 turbo-stream: 3.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) optionalDependencies: - react-server-dom-webpack: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)) + react-server-dom-webpack: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) - '@vitest/coverage-v8@4.1.4(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': + '@vitest/coverage-v8@4.1.4(@voidzero-dev/vite-plus-test@0.1.18)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.4 @@ -11998,9 +12005,9 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitest: '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - '@vitest/eslint-plugin@1.6.15(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': + '@vitest/eslint-plugin@1.6.15(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.18)(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: '@typescript-eslint/scope-manager': 8.58.2 '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) @@ -12008,7 +12015,7 @@ snapshots: optionalDependencies: '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) typescript: 6.0.2 - vitest: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitest: '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - supports-color @@ -12044,14 +12051,15 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': + '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': dependencies: - '@oxc-project/runtime': 0.123.0 - '@oxc-project/types': 0.123.0 + '@oxc-project/runtime': 0.124.0 + '@oxc-project/types': 0.124.0 lightningcss: 1.32.0 postcss: 8.5.9 optionalDependencies: '@types/node': 25.6.0 + esbuild: 0.27.2 fsevents: 2.3.3 jiti: 2.6.1 sass: 1.98.0 @@ -12060,29 +12068,29 @@ snapshots: typescript: 6.0.2 yaml: 2.8.3 - '@voidzero-dev/vite-plus-darwin-arm64@0.1.16': + '@voidzero-dev/vite-plus-darwin-arm64@0.1.18': optional: true - '@voidzero-dev/vite-plus-darwin-x64@0.1.16': + '@voidzero-dev/vite-plus-darwin-x64@0.1.18': optional: true - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.16': + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.18': optional: true - '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.16': + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.18': optional: true - '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.16': + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.18': optional: true - '@voidzero-dev/vite-plus-linux-x64-musl@0.1.16': + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.18': optional: true - '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': + '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + '@voidzero-dev/vite-plus-core': 0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) es-module-lexer: 1.7.0 obug: 2.1.1 pixelmatch: 7.1.0 @@ -12091,11 +12099,12 @@ snapshots: std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.4 - tinyglobby: 0.2.15 - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + tinyglobby: 0.2.16 + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' ws: 8.20.0 optionalDependencies: '@types/node': 25.6.0 + '@vitest/coverage-v8': 4.1.4(@voidzero-dev/vite-plus-test@0.1.18) happy-dom: 20.9.0 transitivePeerDependencies: - '@arethetypeswrong/core' @@ -12118,10 +12127,10 @@ snapshots: - utf-8-validate - yaml - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.16': + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.18': optional: true - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.16': + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.18': optional: true '@volar/language-core@2.4.28': @@ -12958,7 +12967,7 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 - dompurify@3.3.3: + dompurify@3.4.0: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -13146,7 +13155,7 @@ snapshots: dependencies: eslint: 10.2.0(jiti@2.6.1) - eslint-plugin-better-tailwindcss@4.4.1(eslint@10.2.0(jiti@2.6.1))(oxlint@1.58.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.2.2)(typescript@6.0.2): + eslint-plugin-better-tailwindcss@4.4.1(eslint@10.2.0(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.2.2)(typescript@6.0.2): dependencies: '@eslint/css-tree': 4.0.1 '@valibot/to-json-schema': 1.6.0(valibot@1.3.1(typescript@6.0.2)) @@ -13159,7 +13168,7 @@ snapshots: valibot: 1.3.1(typescript@6.0.2) optionalDependencies: eslint: 10.2.0(jiti@2.6.1) - oxlint: 1.58.0(oxlint-tsgolint@0.20.0) + oxlint: 1.60.0(oxlint-tsgolint@0.20.0) transitivePeerDependencies: - '@eslint/css' - typescript @@ -13993,7 +14002,7 @@ snapshots: hex-rgb@4.3.0: {} - hono@4.12.12: {} + hono@4.12.14: {} hosted-git-info@9.0.2: dependencies: @@ -15199,29 +15208,29 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - oxfmt@0.43.0: + oxfmt@0.45.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.43.0 - '@oxfmt/binding-android-arm64': 0.43.0 - '@oxfmt/binding-darwin-arm64': 0.43.0 - '@oxfmt/binding-darwin-x64': 0.43.0 - '@oxfmt/binding-freebsd-x64': 0.43.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.43.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.43.0 - '@oxfmt/binding-linux-arm64-gnu': 0.43.0 - '@oxfmt/binding-linux-arm64-musl': 0.43.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.43.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.43.0 - '@oxfmt/binding-linux-riscv64-musl': 0.43.0 - '@oxfmt/binding-linux-s390x-gnu': 0.43.0 - '@oxfmt/binding-linux-x64-gnu': 0.43.0 - '@oxfmt/binding-linux-x64-musl': 0.43.0 - '@oxfmt/binding-openharmony-arm64': 0.43.0 - '@oxfmt/binding-win32-arm64-msvc': 0.43.0 - '@oxfmt/binding-win32-ia32-msvc': 0.43.0 - '@oxfmt/binding-win32-x64-msvc': 0.43.0 + '@oxfmt/binding-android-arm-eabi': 0.45.0 + '@oxfmt/binding-android-arm64': 0.45.0 + '@oxfmt/binding-darwin-arm64': 0.45.0 + '@oxfmt/binding-darwin-x64': 0.45.0 + '@oxfmt/binding-freebsd-x64': 0.45.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.45.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.45.0 + '@oxfmt/binding-linux-arm64-gnu': 0.45.0 + '@oxfmt/binding-linux-arm64-musl': 0.45.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.45.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.45.0 + '@oxfmt/binding-linux-riscv64-musl': 0.45.0 + '@oxfmt/binding-linux-s390x-gnu': 0.45.0 + '@oxfmt/binding-linux-x64-gnu': 0.45.0 + '@oxfmt/binding-linux-x64-musl': 0.45.0 + '@oxfmt/binding-openharmony-arm64': 0.45.0 + '@oxfmt/binding-win32-arm64-msvc': 0.45.0 + '@oxfmt/binding-win32-ia32-msvc': 0.45.0 + '@oxfmt/binding-win32-x64-msvc': 0.45.0 oxlint-tsgolint@0.20.0: optionalDependencies: @@ -15232,27 +15241,27 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.20.0 '@oxlint-tsgolint/win32-x64': 0.20.0 - oxlint@1.58.0(oxlint-tsgolint@0.20.0): + oxlint@1.60.0(oxlint-tsgolint@0.20.0): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.58.0 - '@oxlint/binding-android-arm64': 1.58.0 - '@oxlint/binding-darwin-arm64': 1.58.0 - '@oxlint/binding-darwin-x64': 1.58.0 - '@oxlint/binding-freebsd-x64': 1.58.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.58.0 - '@oxlint/binding-linux-arm-musleabihf': 1.58.0 - '@oxlint/binding-linux-arm64-gnu': 1.58.0 - '@oxlint/binding-linux-arm64-musl': 1.58.0 - '@oxlint/binding-linux-ppc64-gnu': 1.58.0 - '@oxlint/binding-linux-riscv64-gnu': 1.58.0 - '@oxlint/binding-linux-riscv64-musl': 1.58.0 - '@oxlint/binding-linux-s390x-gnu': 1.58.0 - '@oxlint/binding-linux-x64-gnu': 1.58.0 - '@oxlint/binding-linux-x64-musl': 1.58.0 - '@oxlint/binding-openharmony-arm64': 1.58.0 - '@oxlint/binding-win32-arm64-msvc': 1.58.0 - '@oxlint/binding-win32-ia32-msvc': 1.58.0 - '@oxlint/binding-win32-x64-msvc': 1.58.0 + '@oxlint/binding-android-arm-eabi': 1.60.0 + '@oxlint/binding-android-arm64': 1.60.0 + '@oxlint/binding-darwin-arm64': 1.60.0 + '@oxlint/binding-darwin-x64': 1.60.0 + '@oxlint/binding-freebsd-x64': 1.60.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.60.0 + '@oxlint/binding-linux-arm-musleabihf': 1.60.0 + '@oxlint/binding-linux-arm64-gnu': 1.60.0 + '@oxlint/binding-linux-arm64-musl': 1.60.0 + '@oxlint/binding-linux-ppc64-gnu': 1.60.0 + '@oxlint/binding-linux-riscv64-gnu': 1.60.0 + '@oxlint/binding-linux-riscv64-musl': 1.60.0 + '@oxlint/binding-linux-s390x-gnu': 1.60.0 + '@oxlint/binding-linux-x64-gnu': 1.60.0 + '@oxlint/binding-linux-x64-musl': 1.60.0 + '@oxlint/binding-openharmony-arm64': 1.60.0 + '@oxlint/binding-win32-arm64-msvc': 1.60.0 + '@oxlint/binding-win32-ia32-msvc': 1.60.0 + '@oxlint/binding-win32-x64-msvc': 1.60.0 oxlint-tsgolint: 0.20.0 p-limit@3.1.0: @@ -15622,13 +15631,13 @@ snapshots: react-draggable: 4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tslib: 2.6.2 - react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)): + react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)): dependencies: acorn-loose: 8.5.2 neo-async: 2.6.2 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - webpack: 5.105.4(uglify-js@3.19.3) + webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3) webpack-sources: 3.3.4 react-sortablejs@6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sortablejs@1.15.7): @@ -16306,14 +16315,15 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - terser-webpack-plugin@5.4.0(uglify-js@3.19.3)(webpack@5.105.4(uglify-js@3.19.3)): + terser-webpack-plugin@5.4.0(esbuild@0.27.2)(uglify-js@3.19.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.1 - webpack: 5.105.4(uglify-js@3.19.3) + webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3) optionalDependencies: + esbuild: 0.27.2 uglify-js: 3.19.3 terser@5.46.1: @@ -16627,21 +16637,21 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinext@0.0.41(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)(typescript@6.0.2): + vinext@0.0.41(453b4e184a832f83060410b31544dc36): dependencies: '@unpic/react': 1.0.2(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@vercel/og': 0.8.6 - '@vitejs/plugin-react': 6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + '@vitejs/plugin-react': 6.0.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) magic-string: 0.30.21 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plugin-commonjs: 0.10.4 - vite-tsconfig-paths: 6.1.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) + vite-tsconfig-paths: 6.1.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) optionalDependencies: '@mdx-js/rollup': 3.1.1(rollup@4.59.0) - '@vitejs/plugin-rsc': 0.5.24(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5) - react-server-dom-webpack: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)) + '@vitejs/plugin-rsc': 0.5.24(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.5) + react-server-dom-webpack: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) transitivePeerDependencies: - next - supports-color @@ -16660,9 +16670,9 @@ snapshots: fast-glob: 3.3.3 magic-string: 0.30.21 - vite-plugin-inspect@12.0.0-beta.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0): + vite-plugin-inspect@12.0.0-beta.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0): dependencies: - '@vitejs/devtools-kit': 0.1.11(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0) + '@vitejs/devtools-kit': 0.1.11(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0) ansis: 4.2.0 error-stack-parser-es: 1.0.5 obug: 2.1.1 @@ -16671,12 +16681,12 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - typescript - ws - vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2): + vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2): dependencies: '@next/env': 16.0.0 image-size: 2.0.2 @@ -16685,29 +16695,29 @@ snapshots: next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0) storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) transitivePeerDependencies: - supports-color - typescript - vite-plus@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3): + vite-plus@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3): dependencies: - '@oxc-project/types': 0.123.0 - '@voidzero-dev/vite-plus-core': 0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - '@voidzero-dev/vite-plus-test': 0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - oxfmt: 0.43.0 - oxlint: 1.58.0(oxlint-tsgolint@0.20.0) + '@oxc-project/types': 0.124.0 + '@voidzero-dev/vite-plus-core': 0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + '@voidzero-dev/vite-plus-test': 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + oxfmt: 0.45.0 + oxlint: 1.60.0(oxlint-tsgolint@0.20.0) oxlint-tsgolint: 0.20.0 optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.1.16 - '@voidzero-dev/vite-plus-darwin-x64': 0.1.16 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.16 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.16 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.16 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.16 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.16 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.16 + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.18 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.18 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.18 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.18 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.18 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.18 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.18 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.18 transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -16716,6 +16726,8 @@ snapshots: - '@tsdown/exe' - '@types/node' - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' - '@vitest/ui' - bufferutil - esbuild @@ -16736,36 +16748,36 @@ snapshots: - vite - yaml - vite-tsconfig-paths@5.1.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2): + vite-tsconfig-paths@5.1.4(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2): dependencies: debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.2) optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - supports-color - typescript - vite-tsconfig-paths@6.1.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2): + vite-tsconfig-paths@6.1.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2): dependencies: debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.2) - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' transitivePeerDependencies: - supports-color - typescript - vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): + vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - vitest-canvas-mock@1.1.4(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): + vitest-canvas-mock@1.1.4(@voidzero-dev/vite-plus-test@0.1.18): dependencies: cssfontparser: 1.2.1 moo-color: 1.0.3 - vitest: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitest: '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' void-elements@3.1.0: {} @@ -16813,7 +16825,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.105.4(uglify-js@3.19.3): + webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -16837,7 +16849,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.2 - terser-webpack-plugin: 5.4.0(uglify-js@3.19.3)(webpack@5.105.4(uglify-js@3.19.3)) + terser-webpack-plugin: 5.4.0(esbuild@0.27.2)(uglify-js@3.19.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) watchpack: 2.5.1 webpack-sources: 3.3.4 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1e142fc3b5..433bb467c8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 8e45367db4..4b1c05e7ae 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -258,6 +258,10 @@ const renderAppCard = (app?: Partial) => { return render() } +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() + }) }) }) }) diff --git a/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx b/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx index 5018709da1..5e18bbc343 100644 --- a/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx @@ -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 }) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})) +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 }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + } +}) vi.mock('../../base/app-icon', () => ({ default: ({ size, icon }: { size: string, icon: string }) => ( @@ -128,11 +151,11 @@ describe('AppSidebarDropdown', () => { const user = userEvent.setup() render() - 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', () => { diff --git a/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx index 1f3a5f9ad8..5060987cda 100644 --- a/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/dataset-sidebar-dropdown.spec.tsx @@ -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 }) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})) +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 }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + } +}) vi.mock('../../base/app-icon', () => ({ default: ({ size, icon }: { size: string, icon: string }) => ( @@ -173,10 +196,10 @@ describe('DatasetSidebarDropdown', () => { const user = userEvent.setup() render() - 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', () => { diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx index 2c5b133a74..461cedc20c 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-operations.spec.tsx @@ -30,17 +30,67 @@ vi.mock('../../../base/ui/button', () => ({ ), })) -vi.mock('../../../base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => ( -
{children}
- ), -})) +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 }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + onClick, + render, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + render?: React.ReactElement + }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + const handleClick = (e: React.MouseEvent) => { + onClick?.(e) + setOpen(!isOpen) + } + + if (render) + return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record, children) + + return + }, + DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => { + const { isOpen } = useDropdownMenuContext() + if (!isOpen) + return null + + return
{children}
+ }, + DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuSeparator: () =>
, + } +}) const createOperation = (id: string, title: string, type?: 'divider'): Operation => ({ id, @@ -169,7 +219,7 @@ describe('AppOperations', () => { render() - const trigger = screen.queryByTestId('portal-trigger') + const trigger = screen.queryByTestId('dropdown-trigger') if (trigger) await user.click(trigger) diff --git a/web/app/components/app-sidebar/app-info/app-operations.tsx b/web/app/components/app-sidebar/app-info/app-operations.tsx index a3e67c8a59..095fb31206 100644 --- a/web/app/components/app-sidebar/app-info/app-operations.tsx +++ b/web/app/components/app-sidebar/app-info/app-operations.tsx @@ -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([]) const [showMore, setShowMore] = useState(false) const navRef = useRef(null) - const handleTriggerMore = useCallback(() => { - setShowMore(true) - }, [setShowMore]) const primaryOps = useMemo(() => { if (operations) @@ -169,43 +172,44 @@ const AppOperations = ({ ))} {shouldShowMoreButton && ( - - - - - -
- {moreOperations.map(item => item.type === 'divider' - ? ( -
- ) - : ( -
- {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} - {item.title} -
- ))} -
- - + + + + {moreOperations.map(item => item.type === 'divider' + ? ( + + ) + : ( + + {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} + {item.title} + + ))} + + )}
diff --git a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx index 361fc94d69..617d14f426 100644 --- a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx @@ -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 ( <>
- - -
- - -
-
- + + + + + +
{ })}
- - + +
diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index 6ed10609e9..b514b6e095 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -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 }) =>
{children}
, - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) - describe('Dropdown callback coverage', () => { beforeEach(() => { vi.clearAllMocks() @@ -159,7 +151,7 @@ describe('Dropdown callback coverage', () => { const user = userEvent.setup() render() - 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() - 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() - 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() - 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() - 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() + + 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() + + 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() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByRole('button')) await user.click(screen.getByText('common.operation.delete')) await waitFor(() => { diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index bb85e00c14..e6d3f94e2a 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -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 = () => const createDataset = (overrides: Partial = {}): DataSet => ({ id: 'dataset-1', @@ -210,7 +210,7 @@ describe('MenuItem', () => { const user = userEvent.setup() const handleClick = vi.fn() // Arrange - render() + render() // Act await user.click(screen.getByText('Edit')) @@ -225,7 +225,7 @@ describe('MenuItem', () => { render(
- +
, ) @@ -236,7 +236,7 @@ describe('MenuItem', () => { }) it('should not crash when no click handler is provided', () => { - render() + render() const event = createEvent.click(screen.getByText('Edit')) fireEvent(screen.getByText('Edit'), event) diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index 9abebfcc88..e69b3d7e32 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -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 ( - - - - + }> + + - - + + - + {showRenameModal && ( - + ) } diff --git a/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx b/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx index de2563b377..3968a0df6f 100644 --- a/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx @@ -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 ( <>
- - -
- - -
-
- + + + + + +
@@ -155,8 +143,8 @@ const DatasetSidebarDropdown = ({ documentCount={dataset.document_count} />
- - + +
) diff --git a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx index 41e757d6d0..a4e2d98917 100644 --- a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx @@ -188,8 +188,7 @@ const renderComponent = ( } const openOperationsPopover = async (user: ReturnType) => { - 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) } diff --git a/web/app/components/app/annotation/header-opts/index.tsx b/web/app/components/app/annotation/header-opts/index.tsx index 101898b87e..0871a722f8 100644 --- a/web/app/components/app/annotation/header-opts/index.tsx +++ b/web/app/components/app/annotation/header-opts/index.tsx @@ -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 = ({ + list, + onClose, + onBulkImport, + onClearAll, + onExportJsonl, +}) => { + const { t } = useTranslation() + const locale = useLocale() + const { CSVDownloader, Type } = useCSVDownloader() + const annotationUnavailable = list.length === 0 + + return ( +
+ + + + + {t('table.header.bulkExport', { ns: 'appAnnotation' })} + + + + + [item.question, item.answer]), + ]} + > + + + + + + + +
+ ) +} + const HeaderOptions: FC = ({ appId, onAdd, @@ -45,22 +156,7 @@ const HeaderOptions: FC = ({ }) => { const { t } = useTranslation() const locale = useLocale() - const { CSVDownloader, Type } = useCSVDownloader() const [list, setList] = useState([]) - 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 = ({ 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 = ({ setShowClearConfirm(false) } } - const Operations = () => { - return ( -
- - - - - {t('table.header.bulkExport', { ns: 'appAnnotation' })} - - - - - [item.question, item.answer]), - ]} - > - - - - - - - -
- ) - } const [showAddModal, setShowAddModal] = React.useState(false) return (
- } - position="br" - trigger="click" - btnElement={ - - } - btnClassName="btn btn-secondary btn-medium w-8 p-0" - className="z-20! h-fit w-[155px]!" - popupClassName="w-full! overflow-visible!" - manualClose - /> + + + + + + setIsOperationsMenuOpen(false)} + onBulkImport={handleShowBulkImportModal} + onClearAll={handleClearAll} + onExportJsonl={handleExportJsonl} + /> + + {showAddModal && ( - - - + + + + {t('operation.add', { ns: 'common' })} + + )} + /> {open && } - +
@@ -81,8 +87,8 @@ export default function AddMemberOrGroupDialog() { ) }
- - + + ) } diff --git a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx index f476d8b188..465252c6c4 100644 --- a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx @@ -22,24 +22,57 @@ vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({ default: ({ modelName }: { modelName: string }) => {modelName}, })) -vi.mock('@/app/components/base/portal-to-follow-elem', async () => { +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { const ReactModule = await vi.importActual('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 }) => ( - + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( +
{children}
), - PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => ( -
- {children} -
- ), - PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => { - const open = ReactModule.useContext(OpenContext) - return open ?
{children}
: 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, children) + } + + return + }, + DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => { + const context = useOpenContext() + return context.open ?
{children}
: null + }, + DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler }) => { + const { setOpen } = useOpenContext() + return ( + + ) }, } }) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 3205028e5c..c5e5fffaa8 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -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 ( <> - - - - - + + {t('common.publish', { ns: 'workflow' })} + + + )} + /> +
setShowAppAccessControl(true)} + onClick={() => { + handleOpenChange(false) + setShowAppAccessControl(true) + }} /> { setEmbeddingModalOpen(true) - handleTrigger() + handleOpenChange(false) + }} + handleOpenInExplore={() => { + handleOpenChange(false) + handleOpenInExplore() }} - handleOpenInExplore={handleOpenInExplore} handlePublish={handlePublish} hasHumanInputNode={hasHumanInputNode} hasTriggerNode={hasTriggerNode} @@ -478,7 +480,7 @@ const AppPublisher = ({ )}
-
+ {showAppAccessControl && { setShowAppAccessControl(false) }} />} -
+ = ({ } }) - const handleToggle = () => { - if (validModelConfigs.length) - setOpen(v => !v) - } - - const handleSelect = (item: ModelAndParameter) => { - onSelect(item) - setOpen(false) - } - return ( - - - - - -
-
- {t('publishAs', { ns: 'appDebug' })} -
- { - validModelConfigs.map((item, index) => ( -
handleSelect(item)} - > - - # - {index + 1} - - -
- {item.modelItem.label[language]} -
-
- )) - } + + + +
+ {t('publishAs', { ns: 'appDebug' })}
- - + { + validModelConfigs.map((item, index) => ( + onSelect(item)} + > + + # + {index + 1} + + +
+ {item.modelItem.label[language]} +
+
+ )) + } +
+ ) } diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx index 0740f0cde3..101cbab8fb 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx @@ -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 }) => ( -
- - {children} -
- ), - SelectTrigger: ({ children }: { children: ReactNode }) =>
{children}
, - SelectValue: () => select-value, - SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, - SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, -})) +vi.mock('@/app/components/base/ui/select', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => ( +
+ + {children} +
+ ), + SelectTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + SelectValue: () => select-value, + SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItemText: ({ children }: { children: ReactNode }) => {children}, + SelectItemIndicator: () => , + } +}) vi.mock('../field', () => ({ default: ({ children, title }: { children: ReactNode, title: string }) => ( diff --git a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx index fa318ae35d..279a9279cf 100644 --- a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx @@ -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 = ({ - {t('variableConfig.startChecked', { ns: 'appDebug' })} - {t('variableConfig.noDefaultSelected', { ns: 'appDebug' })} + + {t('variableConfig.startChecked', { ns: 'appDebug' })} + + + + {t('variableConfig.noDefaultSelected', { ns: 'appDebug' })} + + @@ -161,9 +169,15 @@ const ConfigModalFormFields: FC = ({ - {t('variableConfig.noDefaultValue', { ns: 'appDebug' })} + + {t('variableConfig.noDefaultValue', { ns: 'appDebug' })} + + {options.filter(option => option.trim() !== '').map(option => ( - {option} + + {option} + + ))} diff --git a/web/app/components/app/configuration/config-vision/param-config.tsx b/web/app/components/app/configuration/config-vision/param-config.tsx index ded130ba7a..3be4c60aa9 100644 --- a/web/app/components/app/configuration/config-vision/param-config.tsx +++ b/web/app/components/app/configuration/config-vision/param-config.tsx @@ -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 ( - - setOpen(v => !v)}> - - - + + +
{t('voice.settings', { ns: 'appDebug' })}
+ + )} + /> +
-
-
+ + ) } export default memo(ParamsConfig) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/debug-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/debug-item.spec.tsx index 19c5fbf3fa..bc433a8f9d 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/debug-item.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/debug-item.spec.tsx @@ -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 ( -
- {props.items.map(item => ( - - ))} - {props.secondItems?.map(item => ( - - ))} -
- ) - }, -})) - const createModelAndParameter = (overrides: Partial = {}): ModelAndParameter => ({ id: 'model-1', model: 'gpt-3.5-turbo', @@ -117,7 +83,6 @@ const renderComponent = (props: Partial = {}) => { 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, diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx index f7a3112225..4b21616d46 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx @@ -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 = ({ 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 (
= ({ - 2 - ? [ - { - value: 'remove', - text: t('operation.remove', { ns: 'common' }) as string, - }, - ] - : undefined - } - /> + + }> + + + + + + {showDuplicate && ( + + {t('duplicateModel', { ns: 'appDebug' })} + + )} + {showDebugAsSingleModel && ( + + {t('debugAsSingleModel', { ns: 'appDebug' })} + + )} + {showRemove && ( + <> + {(showDuplicate || showDebugAsSingleModel) && } + + {t('operation.remove', { ns: 'common' })} + + + )} + +
{ diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index 93d3cf4d24..68a1ee875d 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -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 = ({ {providers.map(option => ( - {option.name} + {option.name} + ))} diff --git a/web/app/components/app/log/__tests__/model-info.spec.tsx b/web/app/components/app/log/__tests__/model-info.spec.tsx index 3b62406758..f41aaf4c00 100644 --- a/web/app/components/app/log/__tests__/model-info.spec.tsx +++ b/web/app/components/app/log/__tests__/model-info.spec.tsx @@ -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 }) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})) +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 }) => ( + +
+ {children} +
+
+ ), + 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 + }, + PopoverContent: ({ children }: { children: React.ReactNode }) => { + const context = React.useContext(PopoverContext) + if (!context?.open) + return null + + return
{children}
+ }, + } +}) describe('ModelInfo', () => { const defaultModel = { @@ -92,42 +116,46 @@ describe('ModelInfo', () => { it('should be closed by default', () => { render() - 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() - 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() - 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() + fireEvent.click(screen.getByRole('button')) expect(screen.getByText('detail.modelParams')).toBeInTheDocument() }) it('should render temperature parameter', () => { render() + 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() + 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() + 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() + 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() + fireEvent.click(screen.getByRole('button')) expect(screen.getByText('Stop')).toBeInTheDocument() expect(screen.getByText('END')).toBeInTheDocument() @@ -171,6 +203,7 @@ describe('ModelInfo', () => { } render() + fireEvent.click(screen.getByRole('button')) const dashes = screen.getAllByText('-') expect(dashes.length).toBeGreaterThan(0) @@ -186,6 +219,7 @@ describe('ModelInfo', () => { } render() + fireEvent.click(screen.getByRole('button')) const stopValues = screen.getAllByText('-') expect(stopValues.length).toBeGreaterThan(0) @@ -201,6 +235,7 @@ describe('ModelInfo', () => { } render() + fireEvent.click(screen.getByRole('button')) expect(screen.getByText('END,STOP,DONE')).toBeInTheDocument() }) diff --git a/web/app/components/app/log/model-info.tsx b/web/app/components/app/log/model-info.tsx index 5fe0eb590b..e768f30a50 100644 --- a/web/app/components/app/log/model-info.tsx +++ b/web/app/components/app/log/model-info.tsx @@ -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 = ({ showMode />
-
- setOpen(v => !v)} - className="block" - > -
+
+ +
+ )} - > - -
-
- + /> +
{t('detail.modelParams', { ns: 'appLog' })}
@@ -101,9 +100,9 @@ const ModelInfo: FC = ({ })}
-
+
-
+
) } diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index f3ae07eb79..84f8b87fa7 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -133,6 +133,7 @@ vi.mock('@/utils/time', () => ({ // Mock dynamic imports vi.mock('@/next/dynamic', () => ({ default: (importFn: () => Promise) => { + 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(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 + }) => ( + +
+ {children} +
+
+ ), + DropdownMenuTrigger: ({ + children, + className, + onClick, + ...props + }: React.ButtonHTMLAttributes) => { + const { isOpen, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ + children, + className, + popupClassName, + }: { + children: React.ReactNode + className?: string + popupClassName?: string + }) => { + const { isOpen } = useDropdownMenuContext() + if (!isOpen) + return null + + return ( +
+ {children} +
+ ) + }, + DropdownMenuItem: ({ + children, + className, + onClick, + destructive, + }: { + children: React.ReactNode + className?: string + onClick?: React.MouseEventHandler + destructive?: boolean + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuSeparator: () =>
, } - 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() - // 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() - 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() + expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-modal', 'false') + }) + + it('should show edit option when dropdown menu is opened', async () => { render() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() + + 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() - 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() - 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() + + 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() - // 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() - 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() - 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() + + 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() - 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() + + 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() - 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() - 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() + + 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() - 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() - 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() - 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() - 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() - 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() - 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() + + 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() - 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() - 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() - 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() - 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() + + 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() - 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() - 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() - 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() + + 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() - 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() - 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() - 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() - 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() - 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() - // 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() - - // 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() - 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() - 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() - 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, options?: { onError?: (err: unknown) => void }) => { + options?.onError?.('Window failed') + }) + + render() + + 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() + + 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() + + 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() - 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() - 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 + ? ( +
+ + + {children} +
+ ) + : null + ), + AlertDialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogActions: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogCancelButton: ({ children, ...props }: React.ButtonHTMLAttributes) => , + AlertDialogConfirmButton: ({ children, ...props }: React.ButtonHTMLAttributes & { loading?: boolean }) => , + }) + + 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() + + 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() + + 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() + + 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 + }) + }) }) diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index d48372bdf0..ba85ce5178 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -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 = ({ + app, + shouldShowSwitchOption, + shouldShowOpenInExploreOption, + shouldShowAccessControlOption, + onEdit, + onDuplicate, + onExport, + onSwitch, + onDelete, + onAccessControl, +}) => { + const { t } = useTranslation() + const openAsyncWindow = useAsyncWindowOpen() + + const handleMenuAction = useCallback((e: React.MouseEvent, action: () => void) => { + e.stopPropagation() + e.preventDefault() + action() + }, []) + + const handleOpenInstalledApp = useCallback(async (e: React.MouseEvent) => { + 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 ( + <> + handleMenuAction(e, onEdit)}> + {t('editApp', { ns: 'app' })} + + + handleMenuAction(e, onDuplicate)}> + {t('duplicate', { ns: 'app' })} + + handleMenuAction(e, onExport)}> + {t('export', { ns: 'app' })} + + {shouldShowSwitchOption && ( + <> + + handleMenuAction(e, onSwitch)}> + {t('switch', { ns: 'app' })} + + + )} + {shouldShowOpenInExploreOption && ( + <> + + + {t('openInExplore', { ns: 'app' })} + + + )} + + {shouldShowAccessControlOption && ( + <> + handleMenuAction(e, onAccessControl)}> + {t('accessControl', { ns: 'app' })} + + + + )} + handleMenuAction(e, onDelete)} + > + + {t('operation.delete', { ns: 'common' })} + + + + ) +} + +type AppCardOperationsMenuContentProps = Omit + +const AppCardOperationsMenuContent: React.FC = (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 ( + + ) +} + 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([]) 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) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - setShowEditModal(true) - } - const onClickDuplicate = async (e: React.MouseEvent) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - setShowDuplicateModal(true) - } - const onClickExport = async (e: React.MouseEvent) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - exportCheck() - } - const onClickSwitch = async (e: React.MouseEvent) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - setShowSwitchModal(true) - } - const onClickDelete = async (e: React.MouseEvent) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - setShowConfirmDelete(true) - } - const onClickAccessControl = async (e: React.MouseEvent) => { - e.stopPropagation() - props.onClick?.() - e.preventDefault() - setShowAccessControl(true) - } - const onClickInstalledApp = async (e: React.MouseEvent) => { - 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 ( -
- - - - - {(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && ( - <> - - - - )} - { - !app.has_draft_trigger && ( - (!systemFeatures.webapp_auth.enabled) - ? ( - <> - - - - ) - : !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && ( - <> - - - - ) - ) - } - - { - systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor && ( - <> - - - - ) - } - -
- ) - } + 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(app.tags) useEffect(() => { @@ -414,28 +454,28 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
{app.access_mode === AccessMode.PUBLIC && ( - + )} {app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && ( - + )} {app.access_mode === AccessMode.ORGANIZATION && ( - + )} {app.access_mode === AccessMode.EXTERNAL_MEMBERS && ( - + )}
-
+
{ e.preventDefault() }} > -
+
{ />
-
-
- } - position="br" - trigger="click" - btnElement={( -
- {t('operation.more', { ns: 'common' })} - -
- )} - 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!', +
+
+ + + onClick={(e) => { + e.stopPropagation() + e.preventDefault() + }} + > +
+ {t('operation.more', { ns: 'common' })} + +
+
+ {isOperationsMenuOpen && ( + + {systemFeatures.webapp_auth.enabled + ? ( + + ) + : ( + + )} + + )} +
)} diff --git a/web/app/components/base/action-button/index.tsx b/web/app/components/base/action-button/index.tsx index ec84fa06d8..86c8933a8a 100644 --- a/web/app/components/base/action-button/index.tsx +++ b/web/app/components/base/action-button/index.tsx @@ -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 } diff --git a/web/app/components/base/chat/chat-with-history/header/__tests__/mobile-operation-dropdown.spec.tsx b/web/app/components/base/chat/chat-with-history/header/__tests__/mobile-operation-dropdown.spec.tsx index 295bebecac..525f9b89a5 100644 --- a/web/app/components/base/chat/chat-with-history/header/__tests__/mobile-operation-dropdown.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/header/__tests__/mobile-operation-dropdown.spec.tsx @@ -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() + + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('share.chat.resetChat')) + + await waitFor(() => { + expect(screen.queryByText('share.chat.resetChat')).not.toBeInTheDocument() + }) + }) }) diff --git a/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx index 454f20066e..294d5eebc5 100644 --- a/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/header/__tests__/operation.spec.tsx @@ -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 () => { diff --git a/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx b/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx index f8101e1b3a..5d80db7ac3 100644 --- a/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx +++ b/web/app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx @@ -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 ( - - setOpen(v => !v)} + } data-testid="mobile-more-btn" >
- - -
+ + handleMenuAction(handleResetChat)} > -
- {t('chat.resetChat', { ns: 'share' })} -
- {!hideViewChatSettings && ( -
- {t('chat.viewChatSettings', { ns: 'share' })} -
- )} -
-
- + {t('chat.resetChat', { ns: 'share' })} + + {!hideViewChatSettings && ( + handleMenuAction(handleViewChatSettings)} + > + {t('chat.viewChatSettings', { ns: 'share' })} + + )} + + ) } diff --git a/web/app/components/base/chat/chat-with-history/header/operation.tsx b/web/app/components/base/chat/chat-with-history/header/operation.tsx index d61af1f6a1..a6dd6a0a9e 100644 --- a/web/app/components/base/chat/chat-with-history/header/operation.tsx +++ b/web/app/components/base/chat/chat-with-history/header/operation.tsx @@ -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 = ({ }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) + const handleDeferredAction = useCallback((action: () => void) => { + setOpen(false) + queueMicrotask(action) + }, []) return ( - - setOpen(v => !v)} + } >
{title}
- +
-
- -
-
- {isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })} -
- {isShowRenameConversation && ( -
- {t('sidebar.action.rename', { ns: 'explore' })} -
- )} - {isShowDelete && ( -
- {t('sidebar.action.delete', { ns: 'explore' })} -
- )} -
-
-
+ + + + {isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })} + + {isShowRenameConversation && ( + onRenameConversation && handleDeferredAction(onRenameConversation)} + > + {t('sidebar.action.rename', { ns: 'explore' })} + + )} + {isShowDelete && ( + handleDeferredAction(onDelete)} + > + {t('sidebar.action.delete', { ns: 'explore' })} + + )} + + ) } export default React.memo(Operation) diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/operation.spec.tsx index e46b54872e..5aa8da7965 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/operation.spec.tsx @@ -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 }) =>
{children}
, - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) =>
{children}
, - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) - 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() + + await waitFor(() => { + expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument() + }) + }) + + it('should keep the trigger mounted while visually hidden', () => { + render() + + 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() + + 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( +
+ +
, + ) + + 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( +
+ +
, + ) + + await user.click(screen.getByRole('button')) + await user.click(screen.getByRole('menu')) + + expect(parentClick).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx index 18ff3e4d62..611d2bb1b9 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx @@ -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 = ({ }) => { 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 ( - - setOpen(v => !v)} + } + onClick={e => e.stopPropagation()} > = ({ : ActionButtonState.Default } > - + - - -
+ e.stopPropagation(), + }} + > + { e.stopPropagation() + togglePin() }} > -
- {isPinned && } - {!isPinned && } - {isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })} -
- {isShowRenameConversation && ( -
- - {t('sidebar.action.rename', { ns: 'explore' })} -
- )} - {isShowDelete && ( -
- - {t('sidebar.action.delete', { ns: 'explore' })} -
- )} -
-
-
+ {isPinned && } + {!isPinned && } + {isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })} + + {isShowRenameConversation && ( + { + e.stopPropagation() + handleDeferredAction(onRenameConversation) + }} + > + + {t('sidebar.action.rename', { ns: 'explore' })} + + )} + {isShowDelete && ( + { + e.stopPropagation() + handleDeferredAction(onDelete) + }} + > + + {t('sidebar.action.delete', { ns: 'explore' })} + + )} + + ) } export default React.memo(Operation) diff --git a/web/app/components/base/dropdown/__tests__/index.spec.tsx b/web/app/components/base/dropdown/__tests__/index.spec.tsx deleted file mode 100644 index 9820554e3d..0000000000 --- a/web/app/components/base/dropdown/__tests__/index.spec.tsx +++ /dev/null @@ -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( - , - ) - const trigger = container.querySelector('button') - expect(trigger).toBeInTheDocument() - }) - - it('renders custom trigger when provided', () => { - render( - } - />, - ) - 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( - , - ) - 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( - , - ) - 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( - , - ) - - 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( - , - ) - 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( - , - ) - - 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() - 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: JSX Content }, - ] - render( - , - ) - - 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( - , - ) - - 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( - , - ) - await act(async () => { - fireEvent.click(screen.getByRole('button')) - }) - expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument() - - await act(async () => { - rerender( - , - ) - }) - expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument() - }) - - it('renders nothing if both item lists are empty', async () => { - render() - 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( - , - ) - const trigger = screen.getByLabelText('dropdown-trigger') - expect(trigger).toBeDisabled() - expect(trigger).toHaveClass('custom-trigger-class') - }) -}) diff --git a/web/app/components/base/dropdown/index.stories.tsx b/web/app/components/base/dropdown/index.stories.tsx deleted file mode 100644 index 7cb7f820f6..0000000000 --- a/web/app/components/base/dropdown/index.stories.tsx +++ /dev/null @@ -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: Archive }, - { value: 'delete', text: Delete }, -] - -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 - -export default meta -type Story = StoryObj - -const DropdownDemo = (props: React.ComponentProps) => { - const [lastAction, setLastAction] = useState('None') - - return ( -
- { - setLastAction(String(item.value)) - props.onSelect?.(item) - }} - /> -
- Last action: - {' '} - {lastAction} -
-
- ) -} - -export const Playground: Story = { - render: args => , - args: { - items: PRIMARY_ITEMS, - secondItems: SECONDARY_ITEMS, - onSelect: fn(), - }, -} - -export const CustomTrigger: Story = { - render: args => ( - ( - - )} - /> - ), - args: { - items: PRIMARY_ITEMS, - onSelect: fn(), - }, -} diff --git a/web/app/components/base/dropdown/index.tsx b/web/app/components/base/dropdown/index.tsx deleted file mode 100644 index f9a19f34ea..0000000000 --- a/web/app/components/base/dropdown/index.tsx +++ /dev/null @@ -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 = ({ - items, - onSelect, - secondItems, - renderTrigger, - triggerProps, - popupClassName, - itemClassName, - secondItemClassName, -}) => { - const [open, setOpen] = useState(false) - - const handleSelect = (item: Item) => { - setOpen(false) - onSelect(item) - } - - return ( - - setOpen(v => !v)}> - { - renderTrigger - ? renderTrigger(open) - : ( - - - - ) - } - - -
- { - !!items.length && ( -
- { - items.map(item => ( -
handleSelect(item)} - > - {item.text} -
- )) - } -
- ) - } - { - (!!items.length && !!secondItems?.length) && ( -
- ) - } - { - !!secondItems?.length && ( -
- { - secondItems.map(item => ( -
handleSelect(item)} - > - {item.text} -
- )) - } -
- ) - } -
- - - ) -} - -export default Dropdown diff --git a/web/app/components/base/icons/src/vender/line/files/index.ts b/web/app/components/base/icons/src/vender/line/files/index.ts index afdc65cb24..cbeb3d0181 100644 --- a/web/app/components/base/icons/src/vender/line/files/index.ts +++ b/web/app/components/base/icons/src/vender/line/files/index.ts @@ -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' diff --git a/web/app/components/base/markdown-blocks/form.tsx b/web/app/components/base/markdown-blocks/form.tsx index 7e8dcac0b2..b7643c5cd5 100644 --- a/web/app/components/base/markdown-blocks/form.tsx +++ b/web/app/components/base/markdown-blocks/form.tsx @@ -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 }) => { {options.map(option => ( - {option} + + {option} + + ))} diff --git a/web/app/components/base/popover/__tests__/index.spec.tsx b/web/app/components/base/popover/__tests__/index.spec.tsx deleted file mode 100644 index 13c846edd5..0000000000 --- a/web/app/components/base/popover/__tests__/index.spec.tsx +++ /dev/null @@ -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 }) => ( - -) - -describe('CustomPopover', () => { - const defaultProps = { - btnElement: Trigger, - htmlContent:
Popover Content
, - } - - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - if (vi.isFakeTimers?.()) - vi.clearAllTimers() - vi.restoreAllMocks() - vi.useRealTimers() - }) - - describe('Rendering', () => { - it('should render the trigger element', () => { - render() - expect(screen.getByTestId('trigger')).toBeInTheDocument() - }) - - it('should render string as htmlContent', async () => { - render() - 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() - 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() - - 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() - - 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() - - 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() - - 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() - - 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( - } - 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() - 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( - , - ) - - 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( - 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( - , - ) - - 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( - , - ) - - await act(async () => { - fireEvent.click(screen.getByTestId('trigger')) - }) - - const panel = screen.getByTestId('content').closest('.absolute') - expect(panel).toHaveClass('right-0') - }) - }) -}) diff --git a/web/app/components/base/popover/index.stories.tsx b/web/app/components/base/popover/index.stories.tsx deleted file mode 100644 index 0076c1852b..0000000000 --- a/web/app/components/base/popover/index.stories.tsx +++ /dev/null @@ -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 ( -
-
- {title} -
-

{description}

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

{hoverHint}

-
- Popover trigger} - htmlContent={( - - )} - /> -
-
- ) -} - -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 - -export default meta -type Story = StoryObj - -export const HoverPopover: Story = {} - -export const ClickPopover: Story = { - args: { - trigger: 'click', - position: 'br', - }, -} - -export const DisabledState: Story = { - args: { - disabled: true, - }, -} diff --git a/web/app/components/base/popover/index.tsx b/web/app/components/base/popover/index.tsx deleted file mode 100644 index d07f8c9a41..0000000000 --- a/web/app/components/base/popover/index.tsx +++ /dev/null @@ -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(null) - const timeOutRef = useRef(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 ( - - {({ open }: { open: boolean }) => { - return ( - <> -
onMouseLeave(open), - onMouseEnter: () => onMouseEnter(open), - })} - > - - {btnElement} - - - onMouseLeave(open), - onMouseEnter: () => onMouseEnter(open), - }) - } - > - {({ close }) => ( -
onMouseLeave(open), - onMouseEnter: () => onMouseEnter(open), - }) - } - > - {isValidElement(htmlContent) - ? cloneElement(htmlContent as React.ReactElement, { - open, - onClose: close, - ...(manualClose - ? { - onClick: close, - } - : {}), - }) - : htmlContent} -
- )} -
-
-
- - ) - }} -
- ) -} diff --git a/web/app/components/base/tag-management/__tests__/selector.spec.tsx b/web/app/components/base/tag-management/__tests__/selector.spec.tsx index 3c74ac9fe6..6285164193 100644 --- a/web/app/components/base/tag-management/__tests__/selector.spec.tsx +++ b/web/app/components/base/tag-management/__tests__/selector.spec.tsx @@ -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, { - open: isOpen, - onClose: () => setIsOpen(false), - }) - : htmlContent - - return ( -
- - {isOpen && ( -
- {content} -
- )} -
- ) - } - - 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) diff --git a/web/app/components/base/tag-management/panel.tsx b/web/app/components/base/tag-management/panel.tsx index a705b70369..cceb09b4d7 100644 --- a/web/app/components/base/tag-management/panel.tsx +++ b/web/app/components/base/tag-management/panel.tsx @@ -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 diff --git a/web/app/components/base/tag-management/selector.tsx b/web/app/components/base/tag-management/selector.tsx index 9b478086ce..a6d4c04413 100644 --- a/web/app/components/base/tag-management/selector.tsx +++ b/web/app/components/base/tag-management/selector.tsx @@ -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 = ({ @@ -31,8 +36,10 @@ const TagSelector: FC = ({ 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 = ({ return [] }, [selectedTags, tagList]) - return ( - <> - {isPopover && ( - - )} - position={position} - trigger="click" - btnElement={} - 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 ( + + + + + + + + ) } diff --git a/web/app/components/base/ui/context-menu/index.tsx b/web/app/components/base/ui/context-menu/index.tsx index 663f09e96e..4331f095b3 100644 --- a/web/app/components/base/ui/context-menu/index.tsx +++ b/web/app/components/base/ui/context-menu/index.tsx @@ -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 ( {withBackdrop && ( - + )} ) @@ -142,7 +142,7 @@ export function ContextMenuLinkItem({ }: ContextMenuLinkItemProps) { return ( @@ -155,7 +155,7 @@ export function ContextMenuRadioItem({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -167,7 +167,7 @@ export function ContextMenuCheckboxItem({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -179,7 +179,7 @@ export function ContextMenuCheckboxItemIndicator({ }: Omit, 'children'>) { return ( @@ -193,7 +193,7 @@ export function ContextMenuRadioItemIndicator({ }: Omit, 'children'>) { return ( @@ -213,7 +213,7 @@ export function ContextMenuSubTrigger({ }: ContextMenuSubTriggerProps) { return ( {children} @@ -261,7 +261,7 @@ export function ContextMenuGroupLabel({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -273,7 +273,7 @@ export function ContextMenuSeparator({ }: React.ComponentPropsWithoutRef) { return ( ) diff --git a/web/app/components/base/ui/dropdown-menu/index.tsx b/web/app/components/base/ui/dropdown-menu/index.tsx index 83c34ecf27..ca73e8b003 100644 --- a/web/app/components/base/ui/dropdown-menu/index.tsx +++ b/web/app/components/base/ui/dropdown-menu/index.tsx @@ -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) { return ( ) @@ -38,7 +38,7 @@ export function DropdownMenuRadioItemIndicator({ }: Omit, 'children'>) { return ( @@ -52,7 +52,7 @@ export function DropdownMenuCheckboxItem({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -64,7 +64,7 @@ export function DropdownMenuCheckboxItemIndicator({ }: Omit, 'children'>) { return ( @@ -78,7 +78,7 @@ export function DropdownMenuGroupLabel({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -135,8 +135,8 @@ function renderDropdownMenuPopup({ > {children} @@ -235,7 +235,7 @@ export function DropdownMenuItem({ }: DropdownMenuItemProps) { return ( ) @@ -253,7 +253,7 @@ export function DropdownMenuLinkItem({ }: DropdownMenuLinkItemProps) { return ( @@ -266,7 +266,7 @@ export function DropdownMenuSeparator({ }: React.ComponentPropsWithoutRef) { return ( ) diff --git a/web/app/components/base/ui/menu-shared.ts b/web/app/components/base/ui/menu-shared.ts deleted file mode 100644 index b0c379dae2..0000000000 --- a/web/app/components/base/ui/menu-shared.ts +++ /dev/null @@ -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' diff --git a/web/app/components/base/ui/overlay-shared.ts b/web/app/components/base/ui/overlay-shared.ts new file mode 100644 index 0000000000..f21eab44ca --- /dev/null +++ b/web/app/components/base/ui/overlay-shared.ts @@ -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' diff --git a/web/app/components/base/ui/select/__tests__/index.spec.tsx b/web/app/components/base/ui/select/__tests__/index.spec.tsx index 124eb4d60e..e8083f04b2 100644 --- a/web/app/components/base/ui/select/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/select/__tests__/index.spec.tsx @@ -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} > - Seattle - New York + + Seattle + + + + New York + + , ) @@ -50,8 +56,14 @@ describe('Select wrappers', () => { - Seattle - New York + + Seattle + + + + New York + + , @@ -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, }} > - Seattle + + Seattle + + , ) @@ -330,9 +275,13 @@ describe('Select wrappers', () => { - Seattle + + Seattle + + - New York + New York + , @@ -342,5 +291,22 @@ describe('Select wrappers', () => { expect(onValueChange).not.toHaveBeenCalled() }) + + it('should support custom composition with SelectItemText without indicator', () => { + render( + , + ) + + expect(screen.getByRole('option', { name: 'Custom Item' })).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/base/ui/select/index.tsx b/web/app/components/base/ui/select/index.tsx index 1d50c36a0a..81514a9ad5 100644 --- a/web/app/components/base/ui/select/index.tsx +++ b/web/app/components/base/ui/select/index.tsx @@ -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 = { - small: 'px-[3px] py-1', - regular: 'p-1', - large: 'px-1.5 py-1', +const selectSizeClassName: Record = { + 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 & { - clearable?: boolean - onClear?: () => void - loading?: boolean -} & VariantProps + 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 = ( -
@@ -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} > - + {t('operation.delete', { ns: 'common' })}
diff --git a/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx index aa648e1df4..9c34552988 100644 --- a/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx @@ -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 => ({ currentLanguage: 'English', onSelect: vi.fn(), @@ -15,264 +14,163 @@ const createDefaultProps = (overrides?: Partial): 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() - render() - - 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() - render() - - 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() - const { container } = render() - - // 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() - - // 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() - - 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() - expect(screen.getByText('English')).toBeInTheDocument() - }) + // Dropdown behavior + describe('Dropdown behavior', () => { + it('should render all supported languages when the select is opened', async () => { + render() - it('should display Chinese Simplified when currentLanguage is Chinese Simplified', () => { - const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' }) - render() - 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() - expect(screen.getByText('Japanese')).toBeInTheDocument() + it('should only render supported languages in the dropdown', async () => { + render() + + 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() - expect(screen.getByText(language)).toBeInTheDocument() + it('should mark the selected language inside the opened list', async () => { + render() + + 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() + + 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() + + rerender() + + 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
{children}
}, - ) - }) + SelectTrigger: ({ children, ...props }: React.ButtonHTMLAttributes) => , + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItemText: ({ children }: { children: React.ReactNode }) => {children}, + 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() + render() - 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() - - 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).disabled - - render() - - 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() - - // 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() - - 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() - - // 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() + // Disabled state + describe('Disabled state', () => { + it('should disable the trigger when disabled is true', () => { + render() - 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() + it('should not open the listbox when disabled', () => { + render() - 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() - - // 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() - - 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() - - 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() - - // 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() + + 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 = (trackedProps) => { renderSpy() return @@ -282,224 +180,7 @@ describe('LanguageSelect', () => { const { rerender } = render() rerender() - // 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() - expect(screen.getByText('English')).toBeInTheDocument() - - rerender() - - expect(screen.getByText('French')).toBeInTheDocument() - }) - - it('should re-render when disabled changes', () => { - const props = createDefaultProps({ disabled: false }) - - const { rerender } = render() - expect(screen.getByRole('button')).not.toBeDisabled() - - rerender() - - 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() - - // 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() - - // 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() - - 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() - - // 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() - - 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() - - 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() - 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() - - 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() - - const button = screen.getByRole('button') - expect(button).toBeInTheDocument() - }) - - it('should have clickable language options', () => { - const props = createDefaultProps() - render() - - 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() - 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() - - 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() - - // 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() - - 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() - - 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() - - // Assert - Check for disabled text color on icon - const disabledIcon = container.querySelector('.text-components-button-tertiary-text-disabled') - expect(disabledIcon).toBeInTheDocument() - }) }) }) diff --git a/web/app/components/datasets/create/step-two/language-select/index.tsx b/web/app/components/datasets/create/step-two/language-select/index.tsx index 4a7683576f..4f13ff2752 100644 --- a/web/app/components/datasets/create/step-two/language-select/index.tsx +++ b/web/app/components/datasets/create/step-two/language-select/index.tsx @@ -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 = ({ onSelect, disabled, }) => { + const supportedLanguages = languages.filter(language => language.supported) + return ( - { + if (value == null) + return + onSelect(value) + }} disabled={disabled} - popupClassName="z-20" - htmlContent={( -
- {languages.filter(language => language.supported).map(({ prompt_name }) => ( -
onSelect(prompt_name)} - > - {prompt_name} - {(currentLanguage === prompt_name) && } -
- ))} -
- )} - btnElement={( -
- - {currentLanguage} - - -
- )} - 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!" - /> + > + + {currentLanguage ||  } + + + {supportedLanguages.map(({ prompt_name }) => ( + + {prompt_name} + + + ))} + + ) } export default React.memo(LanguageSelect) diff --git a/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx b/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx index 478302d983..97ae1c92a1 100644 --- a/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx @@ -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', () => { diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx index e2bd6f7c92..e7bbb03c94 100644 --- a/web/app/components/datasets/documents/components/operations.tsx +++ b/web/app/components/datasets/documents/components/operations.tsx @@ -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) => { + evt.preventDefault() + evt.stopPropagation() + evt.nativeEvent.stopImmediatePropagation?.() + closeOperationsMenu() + void handleDownload() + }, [closeOperationsMenu, handleDownload]) return (
e.stopPropagation()}> {isListScene && !embeddingAvailable && ()} @@ -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`)} > - + - + { + e.stopPropagation() + e.preventDefault() + }} + > +
+ +
+
+
{!archived && ( <> -
{ - handleShowRenameModal({ - id: detail.id, - name: detail.name, - }) - }} - > - +
+ {t('list.table.rename', { ns: 'datasetDocuments' })}
{data_source_type === DataSourceType.FILE && ( -
{ - evt.preventDefault() - evt.stopPropagation() - evt.nativeEvent.stopImmediatePropagation?.() - handleDownload() - }} - > - +
+ {t('list.action.download', { ns: 'datasetDocuments' })}
)} {['notion_import', DataSourceType.WEB].includes(data_source_type) && ( -
onOperate('sync')}> - +
handleMenuOperation('sync')}> + {t('list.action.sync', { ns: 'datasetDocuments' })}
)} {IS_CE_EDITION && ( -
onOperate('summary')}> - +
handleMenuOperation('summary')}> + {t('list.action.summary', { ns: 'datasetDocuments' })}
)} @@ -230,62 +283,44 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele )} {archived && data_source_type === DataSourceType.FILE && ( <> -
{ - evt.preventDefault() - evt.stopPropagation() - evt.nativeEvent.stopImmediatePropagation?.() - handleDownload() - }} - > - +
+ {t('list.action.download', { ns: 'datasetDocuments' })}
)} {!archived && display_status?.toLowerCase() === 'indexing' && ( -
onOperate('pause')}> - +
handleMenuOperation('pause')}> + {t('list.action.pause', { ns: 'datasetDocuments' })}
)} {!archived && display_status?.toLowerCase() === 'paused' && ( -
onOperate('resume')}> - +
handleMenuOperation('resume')}> + {t('list.action.resume', { ns: 'datasetDocuments' })}
)} {!archived && ( -
onOperate('archive')}> - +
handleMenuOperation('archive')}> + {t('list.action.archive', { ns: 'datasetDocuments' })}
)} {archived && ( -
onOperate('un_archive')}> - +
handleMenuOperation('un_archive')}> + {t('list.action.unarchive', { ns: 'datasetDocuments' })}
)} -
setShowModal(true)}> - +
+ {t('list.action.delete', { ns: 'datasetDocuments' })}
- )} - trigger="click" - position="br" - btnElement={( -
- -
- )} - 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}`} - /> + + )} !open && setShowModal(false)}> diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx index 0157d3cf79..aef07e9be7 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx @@ -31,10 +31,10 @@ describe('Dropdown', () => { const { container } = render() - // 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', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx index 7178b45b34..c6a90824ab 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx @@ -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 ( - - + }> - - + + - + / - + ) } diff --git a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx index 7f95e42bb7..35b62915da 100644 --- a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx @@ -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 }) => ( -
- -
{htmlContent}
-
- ), -})) - 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() - 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() - 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() - 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() - 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', () => { diff --git a/web/app/components/datasets/documents/detail/segment-add/index.tsx b/web/app/components/datasets/documents/detail/segment-add/index.tsx index cffeaa9263..db810b1d6b 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/index.tsx @@ -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 = ({ const { plan, enableBilling } = useProviderContext() const { type } = plan const canAdd = enableBilling ? type !== Plan.sandbox : true + const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false) + const batchMenuAnchorRef = useRef(null) const withNeedUpgradeCheck = useCallback((fn: () => void) => { return () => { @@ -72,14 +72,14 @@ const SegmentAdd: FC = ({ shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]" >
- + {t('list.batchModal.processing', { ns: 'datasetDocuments' })}
)} {importStatus === ProcessStatus.COMPLETED && (
- + {t('list.batchModal.completed', { ns: 'datasetDocuments' })}
@@ -91,7 +91,7 @@ const SegmentAdd: FC = ({ {importStatus === ProcessStatus.ERROR && (
- + {t('list.batchModal.error', { ns: 'datasetDocuments' })}
@@ -105,10 +105,12 @@ const SegmentAdd: FC = ({ } return ( -
- + +
+ +
+
+
- +
- )} - btnElement={( -
- -
- )} - 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} - /> +
+ {isShowPlanUpgradeModal && ( { - setOpen(!open) - } - return (
- - -
+
+ + {expand &&
{t('appMenus.apiAccess', { ns: 'common' })}
} + +
+ )} - > - - {expand &&
{t('appMenus.apiAccess', { ns: 'common' })}
} - -
-
- + /> + - -
+ +
) } diff --git a/web/app/components/datasets/extra-info/service-api/index.tsx b/web/app/components/datasets/extra-info/service-api/index.tsx index 33a6d32251..c4494f4ed7 100644 --- a/web/app/components/datasets/extra-info/service-api/index.tsx +++ b/web/app/components/datasets/extra-info/service-api/index.tsx @@ -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 (
- - -
+
+ +
{t('serviceApi.title', { ns: 'dataset' })}
+
+ )} - > - -
{t('serviceApi.title', { ns: 'dataset' })}
-
-
- + /> + - -
+ +
) } diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx index 29139da114..f6c7e1e93d 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx @@ -56,9 +56,9 @@ vi.mock('../components/dataset-card-modals', () => ({ default: () =>
, })) vi.mock('../components/tag-area', () => ({ - default: React.forwardRef void }>(({ onClick }, ref) => ( -
- )), + default: ({ onClick }: { onClick: (e: React.MouseEvent) => void, ref?: React.Ref }) => ( +
+ ), })) vi.mock('../components/operations-dropdown', () => ({ default: () =>
, diff --git a/web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx index 335f193146..7aadbb4a41 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx @@ -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() - const icon = container.querySelector('svg') + const icon = container.querySelector('.i-ri-edit-line') expect(icon).toBeInTheDocument() expect(icon).toHaveClass('size-4', 'text-text-tertiary') }) diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx index 690e01113f..2bb138e6dc 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx @@ -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' diff --git a/web/app/components/datasets/list/dataset-card/operation-item.tsx b/web/app/components/datasets/list/dataset-card/operation-item.tsx index afa0f174e8..668c9a3c53 100644 --- a/web/app/components/datasets/list/dataset-card/operation-item.tsx +++ b/web/app/components/datasets/list/dataset-card/operation-item.tsx @@ -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?.() }} > - + {name} diff --git a/web/app/components/datasets/list/dataset-card/operations.tsx b/web/app/components/datasets/list/dataset-card/operations.tsx index 0deed2c4c9..04a5b8aef7 100644 --- a/web/app/components/datasets/list/dataset-card/operations.tsx +++ b/web/app/components/datasets/list/dataset-card/operations.tsx @@ -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 ( <> - + {t('operation.edit', { ns: 'common' })} {showExportPipeline && ( - + {t('operations.exportPipeline', { ns: 'datasetPipeline' })} @@ -37,7 +54,7 @@ const Operations = ({ {showDelete && ( <> - + {t('operation.delete', { ns: 'common' })} diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx index 3a8ed6b909..1449ef8f60 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx @@ -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) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children, className }: ContentProps) => ( -
{children}
- ), -})) +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) => ( + +
{children}
+
+ ), + 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 + }, + PopoverContent: ({ children, className }: ContentProps) => { + const context = React.useContext(PopoverContext) + if (!context?.open) + return null + + return
{children}
+ }, + } +}) // 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( { />, ) - expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'true') + expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true') }) it('should handle different trigger elements', () => { diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx index 800ffc3586..2a31169b15 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx @@ -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) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: ContentProps) => ( -
{children}
- ), -})) +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) => ( + +
{children}
+
+ ), + 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 + }, + PopoverContent: ({ children }: ContentProps) => { + const context = React.useContext(PopoverContext) + if (!context?.open) + return null + + return
{children}
+ }, + } +}) // 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( { 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( { />, ) - expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + expect(screen.getByTestId('popover-root')).toBeInTheDocument() }) it('should handle empty trigger', () => { diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx b/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx index cf94bc5206..1e2e8fd9e8 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx @@ -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 = ({ popupLeft = 20, ...createContentProps }) => { + const triggerElement = React.isValidElement(trigger) + ? trigger + : + return ( - - setOpen(!open)} + + - {trigger} - - setOpen(false)} onBack={() => setOpen(false)} /> - - + + ) } diff --git a/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx b/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx index 70c0d67856..f33a85642e 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx @@ -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 = ({ const [open, setOpen] = useState(false) const [step, setStep] = useState(Step.select) + const triggerElement = React.isValidElement(trigger) + ? trigger + : + 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 ( - - setOpen(!open)} - className="block" + + - {trigger} - - {step === Step.select ? ( = ({ }} list={datasetMetaData?.doc_metadata || []} onNew={() => setStep(Step.create)} - onManage={onManage} + onManage={() => { + setOpen(false) + setStep(Step.select) + onManage() + }} /> ) : ( @@ -77,8 +87,8 @@ const SelectMetadataModal: FC = ({ onClose={() => setStep(Step.select)} /> )} - - + + ) } diff --git a/web/app/components/explore/item-operation/__tests__/index.spec.tsx b/web/app/components/explore/item-operation/__tests__/index.spec.tsx index f7f9b44a84..d54c644ab0 100644 --- a/web/app/components/explore/item-operation/__tests__/index.spec.tsx +++ b/web/app/components/explore/item-operation/__tests__/index.spec.tsx @@ -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 }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + onClick, + ...props + }: React.ButtonHTMLAttributes) => { + const { isOpen, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ + children, + popupProps, + }: { + children: React.ReactNode + popupProps?: React.HTMLAttributes + }) => { + const { isOpen } = useDropdownMenuContext() + if (!isOpen) + return null + + return
{children}
+ }, + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + className?: string + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) + 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( +
+ +
, + ) + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByTestId('dropdown-content')) + + expect(onParentClick).not.toHaveBeenCalled() + }) }) }) diff --git a/web/app/components/explore/item-operation/index.tsx b/web/app/components/explore/item-operation/index.tsx index 94eed731fa..72bd00fa6e 100644 --- a/web/app/components/explore/item-operation/index.tsx +++ b/web/app/components/explore/item-operation/index.tsx @@ -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 = ({ 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 ( - - setOpen(v => !v)} + { + e.stopPropagation() + }} > -
-
-
- {tCommon('operation.more')}
+ + e.stopPropagation(), + }} > -
{ e.stopPropagation() + togglePin() }} > -
- - {isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })} -
- {isShowRenameConversation && ( -
- - {t('sidebar.action.rename', { ns: 'explore' })} -
- )} - {isShowDelete && ( -
- - {t('sidebar.action.delete', { ns: 'explore' })} -
- )} -
- - + + {isPinned ? t('sidebar.action.unpin') : t('sidebar.action.pin')} + + {isShowRenameConversation && ( + { + e.stopPropagation() + onRenameConversation?.() + }} + > + + {t('sidebar.action.rename')} + + )} + {isShowDelete && ( + { + e.stopPropagation() + onDelete() + }} + > + + {t('sidebar.action.delete')} + + )} +
+ ) } export default React.memo(ItemOperation) diff --git a/web/app/components/header/account-dropdown/workplace-selector/__tests__/index.spec.tsx b/web/app/components/header/account-dropdown/workplace-selector/__tests__/index.spec.tsx index 797c984a26..c8224bf10d 100644 --- a/web/app/components/header/account-dropdown/workplace-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-dropdown/workplace-selector/__tests__/index.spec.tsx @@ -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() - 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() + + return { + ...actual, + Select: ({ + value, + onValueChange, + children, + }: { + value: string + onValueChange: (value: string | null) => void + children: ReactNode + }) => { + selectMocks.state = { value, onValueChange } + return
{children}
+ }, + SelectTrigger: ({ children }: { children: ReactNode }) => ( + + ), + SelectContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + SelectGroup: ({ children }: { children: ReactNode }) =>
{children}
, + SelectGroupLabel: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItem: ({ + children, + value, + }: { + children: ReactNode + value: string + }) => ( + + ), + SelectItemText: ({ children }: { children: ReactNode }) => {children}, + } +}) + 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( - <> - - , - ) - } + const renderComponent = () => render() 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() }) }) diff --git a/web/app/components/header/account-dropdown/workplace-selector/index.tsx b/web/app/components/header/account-dropdown/workplace-selector/index.tsx index 70d4384c45..aa49075621 100644 --- a/web/app/components/header/account-dropdown/workplace-selector/index.tsx +++ b/web/app/components/header/account-dropdown/workplace-selector/index.tsx @@ -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 ( - - {({ open }) => ( - <> - -
- {currentWorkspace?.name[0]?.toLocaleUpperCase()} -
-
-
{currentWorkspace?.name}
- -
-
- - -
-
- {t('userProfile.workspace', { ns: 'common' })} -
- {workspaces.map(workspace => ( -
handleSwitchWorkspace(workspace.id)}> -
- {workspace?.name[0]?.toLocaleUpperCase()} -
-
{workspace.name}
- -
- ))} + ) } export default WorkplaceSelector diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx index 0140626dd7..12042acfa1 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx @@ -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() - 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() - 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() // 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() // 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() // 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() // 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() // 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() // 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) + }) }) }) }) diff --git a/web/app/components/header/account-setting/data-source-page-new/operator.tsx b/web/app/components/header/account-setting/data-source-page-new/operator.tsx index 22303fb8f0..b8aea9fb1d 100644 --- a/web/app/components/header/account-setting/data-source-page-new/operator.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/operator.tsx @@ -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: ( -
- -
{t('auth.setDefault', { ns: 'plugin' })}
-
- ), - }, - ...( - type === CredentialTypeEnum.OAUTH2 - ? [ - { - value: 'rename', - text: ( -
- -
{t('operation.rename', { ns: 'common' })}
-
- ), - }, - ] - : [] - ), - ...( - type === CredentialTypeEnum.API_KEY - ? [ - { - value: 'edit', - text: ( -
- -
{t('operation.edit', { ns: 'common' })}
-
- ), - }, - ] - : [] - ), - ] - if (type === CredentialTypeEnum.OAUTH2) { - const oAuthItems = [ - { - value: 'change', - text: ( -
- -
{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}
-
- ), - }, - ] - commonItems.push(...oAuthItems) - } - return commonItems - }, [t, type]) - - const secondItems = useMemo(() => { - return [ - { - value: 'delete', - text: ( -
- -
- {t('operation.remove', { ns: 'common' })} -
-
- ), - }, - ] - }, []) - 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 ( - + + }> + + + + + + handleAction('setDefault')}> + +
{t('auth.setDefault', { ns: 'plugin' })}
+
+ {type === CredentialTypeEnum.OAUTH2 && ( + handleAction('rename')}> + +
{t('operation.rename', { ns: 'common' })}
+
+ )} + {type === CredentialTypeEnum.API_KEY && ( + handleAction('edit')}> + +
{t('operation.edit', { ns: 'common' })}
+
+ )} + {type === CredentialTypeEnum.OAUTH2 && ( + handleAction('change')}> + +
{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}
+
+ )} + + handleAction('delete')}> + +
+ {t('operation.remove', { ns: 'common' })} +
+
+
+
) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.select.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.select.spec.tsx index ff30f69a84..ae4a68bad7 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.select.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.select.spec.tsx @@ -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 }) => ( -
- - - {children} -
- ), - SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, - SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, - SelectTrigger: ({ children }: { children: ReactNode }) =>
{children}
, - SelectValue: () =>
SelectValue
, -})) +vi.mock('@/app/components/base/ui/select', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + Select: ({ children, onValueChange }: { children: ReactNode, onValueChange: (value: string | undefined) => void }) => ( +
+ + + {children} +
+ ), + SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, + SelectTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + SelectValue: () =>
SelectValue
, + SelectItemText: ({ children }: { children: ReactNode }) => {children}, + SelectItemIndicator: () => , + } +}) describe('ParameterItem select mode', () => { it('should propagate both explicit and empty select values', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx index 7732ba5a8c..4cda97031f 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx @@ -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({ {parameterRule.options!.map(option => ( - {option} + + {option} + + ))} diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx index 94ac5ab05a..c3f7f221b5 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx @@ -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 - }) => ( - -
{children}
-
- ), - SelectTrigger: ({ - children, - className, - 'data-testid': testId, - }: { - 'children': React.ReactNode - 'className'?: string - 'data-testid'?: string - }) => ( - - ), - SelectValue: () => { - const { value } = React.useContext(MockSelectContext) - return {value} - }, - SelectContent: ({ - children, - popupClassName, - }: { - children: React.ReactNode - popupClassName?: string - }) => ( -
- {children} -
- ), - SelectItem: ({ - children, - value, - }: { - children: React.ReactNode - value: string - }) => { - const { onValueChange } = React.useContext(MockSelectContext) - return ( - - ) - }, -})) + ), + SelectValue: () => { + const { value } = React.useContext(MockSelectContext) + return {value} + }, + SelectContent: ({ + children, + popupClassName, + }: { + children: React.ReactNode + popupClassName?: string + }) => ( +
+ {children} +
+ ), + SelectItem: ({ + children, + value, + }: { + children: React.ReactNode + value: string + }) => { + const { onValueChange } = React.useContext(MockSelectContext) + return ( + + ) + }, + SelectItemText: ({ + children, + className, + }: { + children: React.ReactNode + className?: string + }) => {children}, + SelectItemIndicator: ({ + className, + }: { + className?: string + }) => , + } +}) // ==================== Test Utilities ==================== diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx index 461b229602..1ab7ea2f11 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx @@ -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 ( <>
-
+
{t('voice.voiceSettings.language', { ns: 'appDebug' })}
-
+
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
-
- {installMethods.map(({ icon: Icon, text, action }) => ( -
{ - if (action === 'local') { - fileInputRef.current?.click() - } - else if (action === 'marketplace') { - onSwitchToMarketplaceTab() - setIsMenuOpen(false) - } - else { - setSelectedAction(action) - setIsMenuOpen(false) - } - }} - > - - {text} -
- ))} -
-
- + + + + + {t('installFrom', { ns: 'plugin' })} + + + {installMethods.map(({ icon: Icon, text, action }) => ( + handleInstallMethodSelect(action)} + > +
+ + {text} +
+
+ ))} +
{selectedAction === 'github' && ( (
handleUninstall(item.id)}>{item.name} 卸载
))} */} - + ) } diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx index 56992b377f..8c33894929 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx @@ -680,6 +680,26 @@ describe('PluginTasks Component', () => { }) }) + it('should close the menu after clearing the last non-running plugins', async () => { + setupMocks([ + createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }), + ]) + + render() + + fireEvent.click(document.getElementById('plugin-task-trigger')!) + + await waitFor(() => { + expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /task\.clearAll/i })) + + await waitFor(() => { + expect(document.querySelector('.w-\\[360px\\]')).not.toBeInTheDocument() + }) + }) + it('should clear only error plugins when onClearErrors is called', async () => { const { mockMutateAsync } = setupMocks([ createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }), @@ -792,6 +812,30 @@ describe('PluginTasks Component', () => { expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() }) + + it('should open for installing-with-success state', () => { + setupMocks([ + createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'running-1' }), + createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }), + ]) + + render() + fireEvent.click(document.getElementById('plugin-task-trigger')!) + + expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() + }) + + it('should open for installing-with-error state', () => { + setupMocks([ + createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'running-1' }), + createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'failed-1' }), + ]) + + render() + fireEvent.click(document.getElementById('plugin-task-trigger')!) + + expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx index b96b1ec07c..5acd193a82 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx @@ -5,10 +5,10 @@ import { } 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 useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' import PluginTaskList from './components/plugin-task-list' import TaskStatusIndicator from './components/task-status-indicator' @@ -96,16 +96,14 @@ const PluginTasks = () => { return (
- - + } + onClick={handleTriggerClick} + > { totalPluginsLength={totalPluginsLength} onClick={() => {}} /> - - + + { onClearErrors={handleClearErrors} onClearSingle={handleClearSingle} /> - - + +
) } diff --git a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx index 7ccd788cb0..edad871855 100644 --- a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx @@ -3,6 +3,27 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import MenuDropdown from '../menu-dropdown' +vi.mock('../info-modal', () => ({ + default: ({ + isShow, + onClose, + data, + }: { + isShow: boolean + onClose: () => void + data?: SiteInfo + }) => { + if (!isShow) + return null + return ( +
+ {data?.title} + +
+ ) + }, +})) + const mockReplace = vi.fn() const mockPathname = '/test-path' vi.mock('@/next/navigation', () => ({ @@ -191,6 +212,25 @@ describe('MenuDropdown', () => { expect(screen.getByText('Test App')).toBeInTheDocument() }) }) + + it('should close InfoModal when the close handler runs', async () => { + render() + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('common.userProfile.about')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('common.userProfile.about')) + await waitFor(() => { + expect(screen.getByTestId('info-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Close Info')) + await waitFor(() => { + expect(screen.queryByTestId('info-modal')).not.toBeInTheDocument() + }) + }) }) describe('forceClose prop', () => { diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx index bc8323676c..d50a0d77de 100644 --- a/web/app/components/share/text-generation/menu-dropdown.tsx +++ b/web/app/components/share/text-generation/menu-dropdown.tsx @@ -1,26 +1,25 @@ 'use client' -import type { Placement } from '@floating-ui/react' import type { FC } from 'react' +import type { Placement } from '@/app/components/base/ui/placement' import type { SiteInfo } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' -import { - RiEqualizer2Line, -} from '@remixicon/react' import * as React from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import ThemeSwitcher from '@/app/components/base/theme-switcher' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLinkItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' import { usePathname, useRouter } from '@/next/navigation' import { webAppLogout } from '@/service/webapp-auth' -import Divider from '../../base/divider' import InfoModal from './info-modal' type Props = { @@ -40,24 +39,22 @@ const MenuDropdown: FC = ({ const router = useRouter() const pathname = usePathname() const { t } = useTranslation() - 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 shareCode = useWebAppStore(s => s.shareCode) const handleLogout = useCallback(async () => { + setOpen(false) await webAppLogout(shareCode!) router.replace(`/webapp-signin?redirect_url=${pathname}`) - }, [router, pathname, webAppLogout, shareCode]) + }, [pathname, router, setOpen, shareCode]) const [show, setShow] = useState(false) + const handleOpenInfoModal = useCallback(() => { + setOpen(false) + queueMicrotask(() => { + setShow(true) + }) + }, []) useEffect(() => { if (forceClose) @@ -66,60 +63,56 @@ const MenuDropdown: FC = ({ return ( <> - - -
- - - -
-
- -
-
-
-
{t('theme.theme', { ns: 'common' })}
- -
+ } + aria-label={t('operation.more', { ns: 'common' })} + > + + + + + +
+
+
{t('theme.theme', { ns: 'common' })}
+
- -
- {data?.privacy_policy && ( - - {t('chat.privacyPolicyMiddle', { ns: 'share' })} - - )} -
{ - handleTrigger() - setShow(true) - }} - className="cursor-pointer rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover" - > - {t('userProfile.about', { ns: 'common' })} -
-
- {!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && ( -
-
- {t('userProfile.logout', { ns: 'common' })} -
-
- )}
- - + + {data?.privacy_policy && ( + + {t('chat.privacyPolicyMiddle', { ns: 'share' })} + + )} + + {t('userProfile.about', { ns: 'common' })} + + {!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && ( + + {t('userProfile.logout', { ns: 'common' })} + + )} +
+ {show && ( { + 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 { + DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + render, + onClick, + }: { + children: React.ReactNode + render?: React.ReactElement + onClick?: React.MouseEventHandler + }) => { + const { isOpen, setOpen } = useDropdownMenuContext() + const handleClick = (e: React.MouseEvent) => { + onClick?.(e) + setOpen(!isOpen) + } + + if (render) + return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record, children) + + return + }, + DropdownMenuContent: ({ + children, + className, + popupClassName, + }: { + children: React.ReactNode + className?: string + popupClassName?: string + }) => { + const { isOpen } = useDropdownMenuContext() + return isOpen ?
{children}
: null + }, + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + className?: string + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) + describe('OperationDropdown', () => { const defaultProps = { onEdit: vi.fn(), @@ -16,7 +92,7 @@ describe('OperationDropdown', () => { it('should render trigger button with more icon', () => { render() - const button = document.querySelector('button') + const button = screen.getByTestId('dropdown-trigger') expect(button).toBeInTheDocument() const svg = button?.querySelector('svg') expect(svg).toBeInTheDocument() @@ -39,37 +115,27 @@ describe('OperationDropdown', () => { it('should open dropdown when trigger is clicked', async () => { render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) + fireEvent.click(screen.getByTestId('dropdown-trigger')) - // Dropdown content should be rendered - expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument() - expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument() - } + expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument() + expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument() }) it('should call onOpenChange when opened', () => { const onOpenChange = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - expect(onOpenChange).toHaveBeenCalledWith(true) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + expect(onOpenChange).toHaveBeenCalledWith(true) }) it('should close dropdown when trigger is clicked again', async () => { const onOpenChange = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - fireEvent.click(trigger) - expect(onOpenChange).toHaveBeenLastCalledWith(false) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByTestId('dropdown-trigger')) + expect(onOpenChange).toHaveBeenLastCalledWith(false) }) }) @@ -78,62 +144,38 @@ describe('OperationDropdown', () => { const onEdit = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - const editOption = screen.getByText('tools.mcp.operation.edit') - fireEvent.click(editOption) - - expect(onEdit).toHaveBeenCalledTimes(1) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('tools.mcp.operation.edit')) + expect(onEdit).toHaveBeenCalledTimes(1) }) it('should call onRemove when remove option is clicked', () => { const onRemove = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - const removeOption = screen.getByText('tools.mcp.operation.remove') - fireEvent.click(removeOption) - - expect(onRemove).toHaveBeenCalledTimes(1) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('tools.mcp.operation.remove')) + expect(onRemove).toHaveBeenCalledTimes(1) }) it('should close dropdown after edit is clicked', () => { const onOpenChange = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - onOpenChange.mockClear() - - const editOption = screen.getByText('tools.mcp.operation.edit') - fireEvent.click(editOption) - - expect(onOpenChange).toHaveBeenCalledWith(false) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + onOpenChange.mockClear() + fireEvent.click(screen.getByText('tools.mcp.operation.edit')) + expect(onOpenChange).toHaveBeenCalledWith(false) }) it('should close dropdown after remove is clicked', () => { const onOpenChange = vi.fn() render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - onOpenChange.mockClear() - - const removeOption = screen.getByText('tools.mcp.operation.remove') - fireEvent.click(removeOption) - - expect(onOpenChange).toHaveBeenCalledWith(false) - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + onOpenChange.mockClear() + fireEvent.click(screen.getByText('tools.mcp.operation.remove')) + expect(onOpenChange).toHaveBeenCalledWith(false) }) }) @@ -141,39 +183,25 @@ describe('OperationDropdown', () => { it('should have correct dropdown width', () => { render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - const dropdown = document.querySelector('.w-\\[160px\\]') - expect(dropdown).toBeInTheDocument() - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + const dropdown = document.querySelector('.w-\\[160px\\]') + expect(dropdown).toBeInTheDocument() }) - it('should have rounded-xl on dropdown', () => { + it('should render dropdown content through the shared popup shell', () => { render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - const dropdown = document.querySelector('[class*="rounded-xl"][class*="border"]') - expect(dropdown).toBeInTheDocument() - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + expect(screen.getByTestId('dropdown-content')).toBeInTheDocument() }) - it('should show destructive hover style on remove option', () => { + it('should apply destructive highlighted styles on remove option', () => { render() - const trigger = document.querySelector('button') - if (trigger) { - fireEvent.click(trigger) - - // The text is in a div, and the hover style is on the parent div with group class - const removeOptionText = screen.getByText('tools.mcp.operation.remove') - const removeOptionContainer = removeOptionText.closest('.group') - expect(removeOptionContainer).toHaveClass('hover:bg-state-destructive-hover') - } + fireEvent.click(screen.getByTestId('dropdown-trigger')) + const removeOptionText = screen.getByText('tools.mcp.operation.remove') + const removeOptionContainer = removeOptionText.closest('button') + expect(removeOptionContainer).toHaveClass('data-highlighted:bg-state-destructive-hover') }) }) diff --git a/web/app/components/tools/mcp/detail/operation-dropdown.tsx b/web/app/components/tools/mcp/detail/operation-dropdown.tsx index 4f5468aebc..9a7ee67051 100644 --- a/web/app/components/tools/mcp/detail/operation-dropdown.tsx +++ b/web/app/components/tools/mcp/detail/operation-dropdown.tsx @@ -7,14 +7,15 @@ import { RiMoreFill, } from '@remixicon/react' import * as React from 'react' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' type Props = { inCard?: boolean @@ -30,60 +31,37 @@ const OperationDropdown: FC = ({ onRemove, }) => { const { t } = useTranslation() - const [open, doSetOpen] = useState(false) - const openRef = useRef(open) - const setOpen = useCallback((v: boolean) => { - doSetOpen(v) - openRef.current = v - onOpenChange?.(v) - }, [doSetOpen]) - - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) + const [open, setOpen] = useState(false) + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + onOpenChange?.(nextOpen) + }, [onOpenChange]) return ( - - -
- - - -
-
- -
-
{ - onEdit() - handleTrigger() - }} - > - -
{t('mcp.operation.edit', { ns: 'tools' })}
-
-
{ - onRemove() - handleTrigger() - }} - > - -
{t('mcp.operation.remove', { ns: 'tools' })}
-
-
-
-
+ + } + > + + + + + +
{t('mcp.operation.edit', { ns: 'tools' })}
+
+ + +
{t('mcp.operation.remove', { ns: 'tools' })}
+
+
+
) } export default React.memo(OperationDropdown) diff --git a/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx b/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx new file mode 100644 index 0000000000..1d845dd5fc --- /dev/null +++ b/web/app/components/workflow/block-selector/market-place-plugin/__tests__/action.spec.tsx @@ -0,0 +1,124 @@ +import type { ComponentProps } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useDownloadPlugin } from '@/service/use-plugins' +import OperationDropdown from '../action' + +const mockDownloadBlob = vi.fn() +const mockRemoveQueries = vi.fn() + +vi.mock('next-themes', () => ({ + useTheme: () => ({ + theme: 'light', + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useDownloadPlugin: vi.fn(), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +vi.mock('@/utils/var', () => ({ + getMarketplaceUrl: (path: string) => `https://marketplace.example${path}`, +})) + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const renderComponent = (props?: Partial>) => { + const queryClient = createQueryClient() + vi.spyOn(queryClient, 'removeQueries').mockImplementation(((...args) => { + return mockRemoveQueries(...args) + }) as typeof queryClient.removeQueries) + + return render( + + + , + ) +} + +describe('OperationDropdown', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useDownloadPlugin).mockImplementation((_, enabled) => ({ + data: enabled ? null : null, + isLoading: false, + }) as unknown as ReturnType) + }) + + it('should render download and view details actions when opened', async () => { + renderComponent({ open: true }) + + expect(screen.getByText('common.operation.download')).toBeInTheDocument() + expect(screen.getByText('common.operation.viewDetails')).toBeInTheDocument() + }) + + it('should request a download when download is clicked', async () => { + const onOpenChange = vi.fn() + renderComponent({ open: true, onOpenChange }) + + await userEvent.setup().click(screen.getByText('common.operation.download')) + + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(mockRemoveQueries).toHaveBeenCalled() + }) + + it('should skip download when already loading', async () => { + vi.mocked(useDownloadPlugin).mockReturnValue({ + data: null, + isLoading: true, + } as unknown as ReturnType) + + renderComponent({ open: true }) + + await userEvent.setup().click(screen.getByText('common.operation.download')) + + expect(mockRemoveQueries).not.toHaveBeenCalled() + }) + + it('should download the blob when the hook returns data', async () => { + vi.mocked(useDownloadPlugin).mockImplementation((_, enabled) => ({ + data: enabled ? new Blob(['plugin zip'], { type: 'application/zip' }) : null, + isLoading: false, + }) as unknown as ReturnType) + + renderComponent({ open: true }) + + await userEvent.setup().click(screen.getByText('common.operation.download')) + + await waitFor(() => { + expect(mockDownloadBlob).toHaveBeenCalledWith({ + data: expect.any(Blob), + fileName: 'langgenius-test-plugin_1.0.0.zip', + }) + }) + expect(mockRemoveQueries).toHaveBeenCalled() + }) + + it('should link to the marketplace detail page', () => { + renderComponent({ open: true }) + + expect(screen.getByRole('menuitem', { name: 'common.operation.viewDetails' })).toHaveAttribute( + 'href', + 'https://marketplace.example/plugins/langgenius/test-plugin', + ) + }) +}) diff --git a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx index 4ae623ffc1..a058e8c051 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx @@ -1,19 +1,19 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { RiMoreFill } from '@remixicon/react' import { useQueryClient } from '@tanstack/react-query' import { useTheme } from 'next-themes' import * as React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -// import { Button } from '@/app/components/base/ui/button' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLinkItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { useDownloadPlugin } from '@/service/use-plugins' import { downloadBlob } from '@/utils/download' import { getMarketplaceUrl } from '@/utils/var' @@ -36,16 +36,10 @@ const OperationDropdown: FC = ({ const { t } = useTranslation() const { theme } = useTheme() const queryClient = useQueryClient() - const openRef = useRef(open) - const setOpen = useCallback((v: boolean) => { - onOpenChange(v) - openRef.current = v + const setOpen = useCallback((value: boolean) => { + onOpenChange(value) }, [onOpenChange]) - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) - const [needDownload, setNeedDownload] = useState(false) const downloadInfo = useMemo(() => ({ organization: author, @@ -56,12 +50,13 @@ const OperationDropdown: FC = ({ const handleDownload = useCallback(() => { if (isLoading) return + setOpen(false) queryClient.removeQueries({ queryKey: ['plugins', 'downloadPlugin', downloadInfo], exact: true, }) setNeedDownload(true) - }, [downloadInfo, isLoading, queryClient]) + }, [downloadInfo, isLoading, queryClient, setOpen]) useEffect(() => { if (!needDownload || !blob) @@ -75,27 +70,33 @@ const OperationDropdown: FC = ({ }) }, [author, blob, downloadInfo, name, needDownload, queryClient, version]) return ( - - + }> - + - - -
-
{t('operation.download', { ns: 'common' })}
- {t('operation.viewDetails', { ns: 'common' })} -
-
-
+ + + + {t('operation.download', { ns: 'common' })} + + + {t('operation.viewDetails', { ns: 'common' })} + + + ) } export default React.memo(OperationDropdown) diff --git a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx index cce4c070a1..7df9cd091f 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx @@ -1,7 +1,7 @@ +import type * as React from 'react' import type { TriggerOption } from '../test-run-menu' import { fireEvent, render, renderHook, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' import { TriggerType } from '../test-run-menu' import { getNormalizedShortcutKey, @@ -10,6 +10,33 @@ import { useShortcutMenu, } from '../test-run-menu-helpers' +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: 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 }) => ( + +
{children}
+
+ ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode, onClick?: React.MouseEventHandler, className?: string }) => ( + + ), + } +}) + vi.mock('../shortcuts-name', () => ({ default: ({ keys }: { keys: string[] }) => {keys.join('+')}, })) diff --git a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx index 2e3384b61e..40387d1e0e 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx @@ -5,25 +5,61 @@ import { act } from 'react' import * as React from 'react' import TestRunMenu, { TriggerType } from '../test-run-menu' -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ - children, - }: { - children: React.ReactNode - }) =>
{children}
, - PortalToFollowElemTrigger: ({ - children, - onClick, - }: { - children: React.ReactNode - onClick?: () => void - }) =>
{children}
, - PortalToFollowElemContent: ({ - children, - }: { - children: React.ReactNode - }) =>
{children}
, -})) +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: 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 }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + render, + }: { + children: React.ReactNode + render?: React.ReactElement + }) => { + const { open, setOpen } = useDropdownMenuContext() + + if (render) + return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record, children) + + return + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuGroupLabel: ({ children, className }: { children: React.ReactNode, className?: string }) =>
{children}
, + DropdownMenuSeparator: ({ className }: { className?: string }) =>
, + DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode, onClick?: React.MouseEventHandler, className?: string }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) vi.mock('../shortcuts-name', () => ({ default: ({ keys }: { keys: string[] }) => {keys.join('+')}, @@ -95,10 +131,11 @@ describe('TestRunMenu', () => { act(() => { fireEvent.click(screen.getByRole('button', { name: 'Toggle via ref' })) }) + expect(screen.getByText('~')).toBeInTheDocument() + fireEvent.keyDown(window, { key: '0' }) expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'run-all' })) - expect(screen.getByText('~')).toBeInTheDocument() }) it('should ignore disabled options in the rendered menu', async () => { diff --git a/web/app/components/workflow/header/test-run-menu-helpers.tsx b/web/app/components/workflow/header/test-run-menu-helpers.tsx index dbe6b616a0..4a25cd87a6 100644 --- a/web/app/components/workflow/header/test-run-menu-helpers.tsx +++ b/web/app/components/workflow/header/test-run-menu-helpers.tsx @@ -6,6 +6,7 @@ import { isValidElement, useEffect, } from 'react' +import { DropdownMenuItem } from '@/app/components/base/ui/dropdown-menu' import ShortcutsName from '../shortcuts-name' export type ShortcutMapping = { @@ -27,9 +28,8 @@ export const OptionRow = ({ onSelect: (option: TriggerOption) => void }) => { return ( -
onSelect(option)} >
@@ -41,7 +41,7 @@ export const OptionRow = ({ {shortcutKey && ( )} -
+ ) } diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx index 1d496e4332..5b86c3c3f5 100644 --- a/web/app/components/workflow/header/test-run-menu.tsx +++ b/web/app/components/workflow/header/test-run-menu.tsx @@ -1,7 +1,7 @@ import type { ShortcutMapping } from './test-run-menu-helpers' import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuGroupLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu' import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers' export enum TriggerType { @@ -127,7 +127,7 @@ const TestRunMenu = forwardRef(({ }), [hasSingleEnabledOption, runSoleOption]) const renderOption = (option: TriggerOption) => { - return + return } const { hasUserInput, hasTriggers, hasRunAll } = useMemo(() => getMenuVisibility(options), [options]) @@ -141,27 +141,28 @@ const TestRunMenu = forwardRef(({ } return ( - - setOpen(!open)}> -
- {children} -
-
- -
-
+ }> + {children} + + + + {t('common.chooseStartNodeToRun', { ns: 'workflow' })} -
+
{hasUserInput && renderOption(options.userInput!)} {(hasTriggers || hasRunAll) && hasUserInput && ( -
+ )} {hasRunAll && renderOption(options.runAll!)} @@ -170,9 +171,9 @@ const TestRunMenu = forwardRef(({ .filter(trigger => trigger.enabled !== false) .map(trigger => renderOption(trigger))}
-
- - + + + ) }) diff --git a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx index 63813c8a46..f59de9e874 100644 --- a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx @@ -4,6 +4,74 @@ import { VarType } from '@/app/components/workflow/types' import { WriteMode } from '../../types' import OperationSelector from '../operation-selector' +vi.mock('@/app/components/base/ui/dropdown-menu', async () => { + const React = await import('react') + const DropdownMenuContext = React.createContext<{ open: 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 }) => ( + +
{children}
+
+ ), + DropdownMenuTrigger: ({ + children, + className, + disabled, + }: { + children: React.ReactNode + className?: string + disabled?: boolean + }) => { + const { open, setOpen } = useDropdownMenuContext() + return ( + + ) + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + const { open } = useDropdownMenuContext() + return open ?
{children}
: null + }, + DropdownMenuGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuGroupLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuItem: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: React.MouseEventHandler + }) => { + const { setOpen } = useDropdownMenuContext() + return ( + + ) + }, + } +}) + describe('assigner/operation-selector', () => { it('shows numeric write modes and emits the selected operation', async () => { const user = userEvent.setup() diff --git a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx index 8bce904b74..333aa5b2cd 100644 --- a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx +++ b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx @@ -9,12 +9,15 @@ import { } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Divider from '@/app/components/base/divider' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuGroupLabel, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { getOperationItems, isOperationItem } from '../utils' type OperationSelectorProps = { @@ -49,65 +52,57 @@ const OperationSelector: FC = ({ const selectedItem = items.find(item => item.value === value) return ( - - !disabled && setOpen(v => !v)} + -
-
- + - {selectedItem && isOperationItem(selectedItem) ? t(`nodes.assigner.operations.${selectedItem.name}`, { ns: 'workflow' }) : t('nodes.assigner.operations.title', { ns: 'workflow' })} - -
- + > + {selectedItem && isOperationItem(selectedItem) ? t(`nodes.assigner.operations.${selectedItem.name}`, { ns: 'workflow' }) : t('nodes.assigner.operations.title', { ns: 'workflow' })} +
-
+ + - -
-
-
-
{t('nodes.assigner.operations.title', { ns: 'workflow' })}
-
- {items.map(item => ( - !isOperationItem(item) - ? ( - - ) - : ( -
{ - onSelect(item) - setOpen(false) - }} - > -
- {t(`nodes.assigner.operations.${item.name}`, { ns: 'workflow' })} -
- {item.value === value && ( -
- -
- )} + + + {t('nodes.assigner.operations.title', { ns: 'workflow' })} + {items.map(item => ( + !isOperationItem(item) + ? ( + + ) + : ( + onSelect(item)} + > +
+ {t(`nodes.assigner.operations.${item.name}`, { ns: 'workflow' })}
- ) - ))} -
-
- - + {item.value === value && ( +
+ +
+ )} + + ) + ))} + + + ) } diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx new file mode 100644 index 0000000000..9344673a35 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-importer.spec.tsx @@ -0,0 +1,276 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { JSON_SCHEMA_MAX_DEPTH } from '@/config' +import JsonImporter from '../json-importer' + +const mockEmit = vi.fn() +const mockCheckJsonDepth = vi.fn() +const visualEditorState = { + advancedEditing: false, + isAddingNewField: false, +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('../visual-editor/context', () => ({ + useMittContext: () => ({ + emit: mockEmit, + }), +})) + +vi.mock('../visual-editor/store', () => ({ + useVisualEditorStore: (selector: (state: typeof visualEditorState) => unknown) => selector(visualEditorState), +})) + +vi.mock('../../../utils', () => ({ + checkJsonDepth: (...args: unknown[]) => mockCheckJsonDepth(...args), +})) + +vi.mock('../code-editor', () => ({ + default: ({ + value, + onUpdate, + }: { + value: string + onUpdate: (value: string) => void + }) => ( +