mirror of
https://github.com/langgenius/dify.git
synced 2026-03-18 05:47:03 +00:00
Compare commits
7 Commits
yanli/phas
...
webhook-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7d79f79f7 | ||
|
|
ac6d306ef8 | ||
|
|
076b297b18 | ||
|
|
da2049be76 | ||
|
|
7c6d0bedc0 | ||
|
|
3db1ba36e0 | ||
|
|
d1961c261e |
@@ -70,7 +70,14 @@ def handle_webhook(webhook_id: str):
|
||||
|
||||
@bp.route("/webhook-debug/<string:webhook_id>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
||||
def handle_webhook_debug(webhook_id: str):
|
||||
"""Handle webhook debug calls without triggering production workflow execution."""
|
||||
"""Handle webhook debug calls without triggering production workflow execution.
|
||||
|
||||
The debug webhook endpoint is only for draft inspection flows. It never enqueues
|
||||
Celery work for the published workflow; instead it dispatches an in-memory debug
|
||||
event to an active Variable Inspector listener. Returning a clear error when no
|
||||
listener is registered prevents a misleading 200 response for requests that are
|
||||
effectively dropped.
|
||||
"""
|
||||
try:
|
||||
webhook_trigger, _, node_config, webhook_data, error = _prepare_webhook_execution(webhook_id, is_debug=True)
|
||||
if error:
|
||||
@@ -94,11 +101,32 @@ def handle_webhook_debug(webhook_id: str):
|
||||
"method": webhook_data.get("method"),
|
||||
},
|
||||
)
|
||||
TriggerDebugEventBus.dispatch(
|
||||
dispatch_count = TriggerDebugEventBus.dispatch(
|
||||
tenant_id=webhook_trigger.tenant_id,
|
||||
event=event,
|
||||
pool_key=pool_key,
|
||||
)
|
||||
if dispatch_count == 0:
|
||||
logger.warning(
|
||||
"Webhook debug request dropped without an active listener for webhook %s (tenant=%s, app=%s, node=%s)",
|
||||
webhook_trigger.webhook_id,
|
||||
webhook_trigger.tenant_id,
|
||||
webhook_trigger.app_id,
|
||||
webhook_trigger.node_id,
|
||||
)
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "No active debug listener",
|
||||
"message": (
|
||||
"The webhook debug URL only works while the Variable Inspector is listening. "
|
||||
"Use the published webhook URL to execute the workflow in Celery."
|
||||
),
|
||||
"execution_url": webhook_trigger.webhook_url,
|
||||
}
|
||||
),
|
||||
409,
|
||||
)
|
||||
response_data, status_code = WebhookService.generate_webhook_response(node_config)
|
||||
return jsonify(response_data), status_code
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Literal, Union, overload
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, overload
|
||||
|
||||
from flask import Flask, current_app
|
||||
from pydantic import ValidationError
|
||||
@@ -47,6 +47,7 @@ from extensions.ext_database import db
|
||||
from factories import file_factory
|
||||
from libs.flask_utils import preserve_flask_contexts
|
||||
from models import Account, App, Conversation, EndUser, Message, Workflow, WorkflowNodeExecutionTriggeredFrom
|
||||
from models.base import Base
|
||||
from models.enums import WorkflowRunTriggeredFrom
|
||||
from services.conversation_service import ConversationService
|
||||
from services.workflow_draft_variable_service import (
|
||||
@@ -523,7 +524,6 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
|
||||
with Session(bind=db.engine, expire_on_commit=False) as session:
|
||||
workflow = _refresh_model(session, workflow)
|
||||
message = _refresh_model(session, message)
|
||||
assert message is not None
|
||||
# workflow_ = session.get(Workflow, workflow.id)
|
||||
# assert workflow_ is not None
|
||||
# workflow = workflow_
|
||||
@@ -690,20 +690,11 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
|
||||
raise e
|
||||
|
||||
|
||||
@overload
|
||||
def _refresh_model(session: Session, model: Workflow) -> Workflow: ...
|
||||
_T = TypeVar("_T", bound=Base)
|
||||
|
||||
|
||||
@overload
|
||||
def _refresh_model(session: Session, model: Message) -> Message: ...
|
||||
|
||||
|
||||
def _refresh_model(session: Session, model: Workflow | Message) -> Workflow | Message:
|
||||
if isinstance(model, Workflow):
|
||||
detached_workflow = session.get(Workflow, model.id)
|
||||
assert detached_workflow is not None
|
||||
return detached_workflow
|
||||
|
||||
detached_message = session.get(Message, model.id)
|
||||
assert detached_message is not None
|
||||
return detached_message
|
||||
def _refresh_model(session, model: _T) -> _T:
|
||||
with Session(bind=db.engine, expire_on_commit=False) as session:
|
||||
detach_model = session.get(type(model), model.id)
|
||||
assert detach_model is not None
|
||||
return detach_model
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
from collections.abc import Generator
|
||||
from typing import Any, cast
|
||||
|
||||
from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter
|
||||
@@ -56,7 +56,7 @@ class AdvancedChatAppGenerateResponseConverter(AppGenerateResponseConverter):
|
||||
|
||||
@classmethod
|
||||
def convert_stream_full_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, Any, None]:
|
||||
"""
|
||||
Convert stream full response.
|
||||
@@ -87,7 +87,7 @@ class AdvancedChatAppGenerateResponseConverter(AppGenerateResponseConverter):
|
||||
|
||||
@classmethod
|
||||
def convert_stream_simple_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, Any, None]:
|
||||
"""
|
||||
Convert stream simple response.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
from collections.abc import Generator
|
||||
from typing import cast
|
||||
|
||||
from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter
|
||||
@@ -55,7 +55,7 @@ class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter):
|
||||
|
||||
@classmethod
|
||||
def convert_stream_full_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, None, None]:
|
||||
"""
|
||||
Convert stream full response.
|
||||
@@ -86,7 +86,7 @@ class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter):
|
||||
|
||||
@classmethod
|
||||
def convert_stream_simple_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, None, None]:
|
||||
"""
|
||||
Convert stream simple response.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Generator, Iterator, Mapping
|
||||
from typing import Any
|
||||
from collections.abc import Generator, Mapping
|
||||
from typing import Any, Union
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.task_entities import AppBlockingResponse, AppStreamResponse
|
||||
@@ -16,26 +16,24 @@ class AppGenerateResponseConverter(ABC):
|
||||
|
||||
@classmethod
|
||||
def convert(
|
||||
cls, response: AppBlockingResponse | Iterator[AppStreamResponse], invoke_from: InvokeFrom
|
||||
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], None, None]:
|
||||
cls, response: Union[AppBlockingResponse, Generator[AppStreamResponse, Any, None]], invoke_from: InvokeFrom
|
||||
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], Any, None]:
|
||||
if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.SERVICE_API}:
|
||||
if isinstance(response, AppBlockingResponse):
|
||||
return cls.convert_blocking_full_response(response)
|
||||
else:
|
||||
stream_response = response
|
||||
|
||||
def _generate_full_response() -> Generator[dict[str, Any] | str, None, None]:
|
||||
yield from cls.convert_stream_full_response(stream_response)
|
||||
def _generate_full_response() -> Generator[dict | str, Any, None]:
|
||||
yield from cls.convert_stream_full_response(response)
|
||||
|
||||
return _generate_full_response()
|
||||
else:
|
||||
if isinstance(response, AppBlockingResponse):
|
||||
return cls.convert_blocking_simple_response(response)
|
||||
else:
|
||||
stream_response = response
|
||||
|
||||
def _generate_simple_response() -> Generator[dict[str, Any] | str, None, None]:
|
||||
yield from cls.convert_stream_simple_response(stream_response)
|
||||
def _generate_simple_response() -> Generator[dict | str, Any, None]:
|
||||
yield from cls.convert_stream_simple_response(response)
|
||||
|
||||
return _generate_simple_response()
|
||||
|
||||
@@ -52,14 +50,14 @@ class AppGenerateResponseConverter(ABC):
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def convert_stream_full_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, None, None]:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def convert_stream_simple_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, None, None]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -224,7 +224,6 @@ class BaseAppGenerator:
|
||||
def _get_draft_var_saver_factory(invoke_from: InvokeFrom, account: Account | EndUser) -> DraftVariableSaverFactory:
|
||||
if invoke_from == InvokeFrom.DEBUGGER:
|
||||
assert isinstance(account, Account)
|
||||
debug_account = account
|
||||
|
||||
def draft_var_saver_factory(
|
||||
session: Session,
|
||||
@@ -241,7 +240,7 @@ class BaseAppGenerator:
|
||||
node_type=node_type,
|
||||
node_execution_id=node_execution_id,
|
||||
enclosing_node_id=enclosing_node_id,
|
||||
user=debug_account,
|
||||
user=account,
|
||||
)
|
||||
else:
|
||||
|
||||
|
||||
@@ -166,19 +166,15 @@ class ChatAppGenerator(MessageBasedAppGenerator):
|
||||
|
||||
# init generate records
|
||||
(conversation, message) = self._init_generate_records(application_generate_entity, conversation)
|
||||
assert conversation is not None
|
||||
assert message is not None
|
||||
generated_conversation_id = str(conversation.id)
|
||||
generated_message_id = str(message.id)
|
||||
|
||||
# init queue manager
|
||||
queue_manager = MessageBasedAppQueueManager(
|
||||
task_id=application_generate_entity.task_id,
|
||||
user_id=application_generate_entity.user_id,
|
||||
invoke_from=application_generate_entity.invoke_from,
|
||||
conversation_id=generated_conversation_id,
|
||||
conversation_id=conversation.id,
|
||||
app_mode=conversation.mode,
|
||||
message_id=generated_message_id,
|
||||
message_id=message.id,
|
||||
)
|
||||
|
||||
# new thread with request context
|
||||
@@ -188,8 +184,8 @@ class ChatAppGenerator(MessageBasedAppGenerator):
|
||||
flask_app=current_app._get_current_object(), # type: ignore
|
||||
application_generate_entity=application_generate_entity,
|
||||
queue_manager=queue_manager,
|
||||
conversation_id=generated_conversation_id,
|
||||
message_id=generated_message_id,
|
||||
conversation_id=conversation.id,
|
||||
message_id=message.id,
|
||||
)
|
||||
|
||||
worker_thread = threading.Thread(target=worker_with_context)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
from collections.abc import Generator
|
||||
from typing import cast
|
||||
|
||||
from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter
|
||||
@@ -55,7 +55,7 @@ class ChatAppGenerateResponseConverter(AppGenerateResponseConverter):
|
||||
|
||||
@classmethod
|
||||
def convert_stream_full_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, None, None]:
|
||||
"""
|
||||
Convert stream full response.
|
||||
@@ -86,7 +86,7 @@ class ChatAppGenerateResponseConverter(AppGenerateResponseConverter):
|
||||
|
||||
@classmethod
|
||||
def convert_stream_simple_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, None, None]:
|
||||
"""
|
||||
Convert stream simple response.
|
||||
|
||||
@@ -149,8 +149,6 @@ class CompletionAppGenerator(MessageBasedAppGenerator):
|
||||
|
||||
# init generate records
|
||||
(conversation, message) = self._init_generate_records(application_generate_entity)
|
||||
assert conversation is not None
|
||||
assert message is not None
|
||||
|
||||
# init queue manager
|
||||
queue_manager = MessageBasedAppQueueManager(
|
||||
@@ -314,19 +312,15 @@ class CompletionAppGenerator(MessageBasedAppGenerator):
|
||||
|
||||
# init generate records
|
||||
(conversation, message) = self._init_generate_records(application_generate_entity)
|
||||
assert conversation is not None
|
||||
assert message is not None
|
||||
conversation_id = str(conversation.id)
|
||||
message_id = str(message.id)
|
||||
|
||||
# init queue manager
|
||||
queue_manager = MessageBasedAppQueueManager(
|
||||
task_id=application_generate_entity.task_id,
|
||||
user_id=application_generate_entity.user_id,
|
||||
invoke_from=application_generate_entity.invoke_from,
|
||||
conversation_id=conversation_id,
|
||||
conversation_id=conversation.id,
|
||||
app_mode=conversation.mode,
|
||||
message_id=message_id,
|
||||
message_id=message.id,
|
||||
)
|
||||
|
||||
# new thread with request context
|
||||
@@ -336,7 +330,7 @@ class CompletionAppGenerator(MessageBasedAppGenerator):
|
||||
flask_app=current_app._get_current_object(), # type: ignore
|
||||
application_generate_entity=application_generate_entity,
|
||||
queue_manager=queue_manager,
|
||||
message_id=message_id,
|
||||
message_id=message.id,
|
||||
)
|
||||
|
||||
worker_thread = threading.Thread(target=worker_with_context)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
from collections.abc import Generator
|
||||
from typing import cast
|
||||
|
||||
from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter
|
||||
@@ -54,7 +54,7 @@ class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter):
|
||||
|
||||
@classmethod
|
||||
def convert_stream_full_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, None, None]:
|
||||
"""
|
||||
Convert stream full response.
|
||||
@@ -84,7 +84,7 @@ class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter):
|
||||
|
||||
@classmethod
|
||||
def convert_stream_simple_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, None, None]:
|
||||
"""
|
||||
Convert stream simple response.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
from collections.abc import Generator
|
||||
from typing import cast
|
||||
|
||||
from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter
|
||||
@@ -36,7 +36,7 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter):
|
||||
|
||||
@classmethod
|
||||
def convert_stream_full_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, None, None]:
|
||||
"""
|
||||
Convert stream full response.
|
||||
@@ -65,7 +65,7 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter):
|
||||
|
||||
@classmethod
|
||||
def convert_stream_simple_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, None, None]:
|
||||
"""
|
||||
Convert stream simple response.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
from collections.abc import Generator
|
||||
from typing import cast
|
||||
|
||||
from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter
|
||||
@@ -36,7 +36,7 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter):
|
||||
|
||||
@classmethod
|
||||
def convert_stream_full_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, None, None]:
|
||||
"""
|
||||
Convert stream full response.
|
||||
@@ -65,7 +65,7 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter):
|
||||
|
||||
@classmethod
|
||||
def convert_stream_simple_response(
|
||||
cls, stream_response: Iterator[AppStreamResponse]
|
||||
cls, stream_response: Generator[AppStreamResponse, None, None]
|
||||
) -> Generator[dict | str, None, None]:
|
||||
"""
|
||||
Convert stream simple response.
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Protocol, TypeAlias
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.entities.agent_strategy import AgentStrategyInfo
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
InvokeFrom,
|
||||
UserFrom,
|
||||
build_dify_run_context,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context
|
||||
from core.app.entities.queue_entities import (
|
||||
AppQueueEvent,
|
||||
QueueAgentLogEvent,
|
||||
@@ -40,7 +36,7 @@ from core.rag.entities.citation_metadata import RetrievalSourceMetadata
|
||||
from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id, resolve_workflow_node_class
|
||||
from core.workflow.workflow_entry import WorkflowEntry
|
||||
from dify_graph.entities import GraphInitParams
|
||||
from dify_graph.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter
|
||||
from dify_graph.entities.graph_config import NodeConfigDictAdapter
|
||||
from dify_graph.entities.pause_reason import HumanInputRequired
|
||||
from dify_graph.graph import Graph
|
||||
from dify_graph.graph_engine.layers.base import GraphEngineLayer
|
||||
@@ -79,14 +75,6 @@ from tasks.mail_human_input_delivery_task import dispatch_human_input_email_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GraphConfigObject: TypeAlias = dict[str, object]
|
||||
GraphConfigMapping: TypeAlias = Mapping[str, object]
|
||||
|
||||
|
||||
class SingleNodeRunEntity(Protocol):
|
||||
node_id: str
|
||||
inputs: Mapping[str, object]
|
||||
|
||||
|
||||
class WorkflowBasedAppRunner:
|
||||
def __init__(
|
||||
@@ -110,7 +98,7 @@ class WorkflowBasedAppRunner:
|
||||
|
||||
def _init_graph(
|
||||
self,
|
||||
graph_config: GraphConfigMapping,
|
||||
graph_config: Mapping[str, Any],
|
||||
graph_runtime_state: GraphRuntimeState,
|
||||
user_from: UserFrom,
|
||||
invoke_from: InvokeFrom,
|
||||
@@ -166,8 +154,8 @@ class WorkflowBasedAppRunner:
|
||||
def _prepare_single_node_execution(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
single_iteration_run: SingleNodeRunEntity | None = None,
|
||||
single_loop_run: SingleNodeRunEntity | None = None,
|
||||
single_iteration_run: Any | None = None,
|
||||
single_loop_run: Any | None = None,
|
||||
) -> tuple[Graph, VariablePool, GraphRuntimeState]:
|
||||
"""
|
||||
Prepare graph, variable pool, and runtime state for single node execution
|
||||
@@ -220,88 +208,11 @@ class WorkflowBasedAppRunner:
|
||||
# This ensures all nodes in the graph reference the same GraphRuntimeState instance
|
||||
return graph, variable_pool, graph_runtime_state
|
||||
|
||||
@staticmethod
|
||||
def _get_graph_items(graph_config: GraphConfigMapping) -> tuple[list[GraphConfigMapping], list[GraphConfigMapping]]:
|
||||
nodes = graph_config.get("nodes")
|
||||
edges = graph_config.get("edges")
|
||||
if not isinstance(nodes, list):
|
||||
raise ValueError("nodes in workflow graph must be a list")
|
||||
if not isinstance(edges, list):
|
||||
raise ValueError("edges in workflow graph must be a list")
|
||||
|
||||
validated_nodes: list[GraphConfigMapping] = []
|
||||
for node in nodes:
|
||||
if not isinstance(node, Mapping):
|
||||
raise ValueError("nodes in workflow graph must be mappings")
|
||||
validated_nodes.append(node)
|
||||
|
||||
validated_edges: list[GraphConfigMapping] = []
|
||||
for edge in edges:
|
||||
if not isinstance(edge, Mapping):
|
||||
raise ValueError("edges in workflow graph must be mappings")
|
||||
validated_edges.append(edge)
|
||||
|
||||
return validated_nodes, validated_edges
|
||||
|
||||
@staticmethod
|
||||
def _extract_start_node_id(node_config: GraphConfigMapping | None) -> str | None:
|
||||
if node_config is None:
|
||||
return None
|
||||
node_data = node_config.get("data")
|
||||
if not isinstance(node_data, Mapping):
|
||||
return None
|
||||
start_node_id = node_data.get("start_node_id")
|
||||
return start_node_id if isinstance(start_node_id, str) else None
|
||||
|
||||
@classmethod
|
||||
def _build_single_node_graph_config(
|
||||
cls,
|
||||
*,
|
||||
graph_config: GraphConfigMapping,
|
||||
node_id: str,
|
||||
node_type_filter_key: str,
|
||||
) -> tuple[GraphConfigObject, NodeConfigDict]:
|
||||
node_configs, edge_configs = cls._get_graph_items(graph_config)
|
||||
main_node_config = next((node for node in node_configs if node.get("id") == node_id), None)
|
||||
start_node_id = cls._extract_start_node_id(main_node_config)
|
||||
|
||||
filtered_node_configs = [
|
||||
dict(node)
|
||||
for node in node_configs
|
||||
if node.get("id") == node_id
|
||||
or (isinstance(node_data := node.get("data"), Mapping) and node_data.get(node_type_filter_key) == node_id)
|
||||
or (start_node_id and node.get("id") == start_node_id)
|
||||
]
|
||||
if not filtered_node_configs:
|
||||
raise ValueError(f"node id {node_id} not found in workflow graph")
|
||||
|
||||
filtered_node_ids = {
|
||||
str(node_id_value) for node in filtered_node_configs if isinstance((node_id_value := node.get("id")), str)
|
||||
}
|
||||
filtered_edge_configs = [
|
||||
dict(edge)
|
||||
for edge in edge_configs
|
||||
if (edge.get("source") is None or edge.get("source") in filtered_node_ids)
|
||||
and (edge.get("target") is None or edge.get("target") in filtered_node_ids)
|
||||
]
|
||||
|
||||
target_node_config = next((node for node in filtered_node_configs if node.get("id") == node_id), None)
|
||||
if target_node_config is None:
|
||||
raise ValueError(f"node id {node_id} not found in workflow graph")
|
||||
|
||||
return (
|
||||
{
|
||||
"nodes": filtered_node_configs,
|
||||
"edges": filtered_edge_configs,
|
||||
},
|
||||
NodeConfigDictAdapter.validate_python(target_node_config),
|
||||
)
|
||||
|
||||
def _get_graph_and_variable_pool_for_single_node_run(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
node_id: str,
|
||||
user_inputs: Mapping[str, object],
|
||||
user_inputs: dict[str, Any],
|
||||
graph_runtime_state: GraphRuntimeState,
|
||||
node_type_filter_key: str, # 'iteration_id' or 'loop_id'
|
||||
node_type_label: str = "node", # 'iteration' or 'loop' for error messages
|
||||
@@ -325,14 +236,41 @@ class WorkflowBasedAppRunner:
|
||||
if not graph_config:
|
||||
raise ValueError("workflow graph not found")
|
||||
|
||||
graph_config = cast(dict[str, Any], graph_config)
|
||||
|
||||
if "nodes" not in graph_config or "edges" not in graph_config:
|
||||
raise ValueError("nodes or edges not found in workflow graph")
|
||||
|
||||
graph_config, target_node_config = self._build_single_node_graph_config(
|
||||
graph_config=graph_config,
|
||||
node_id=node_id,
|
||||
node_type_filter_key=node_type_filter_key,
|
||||
)
|
||||
if not isinstance(graph_config.get("nodes"), list):
|
||||
raise ValueError("nodes in workflow graph must be a list")
|
||||
|
||||
if not isinstance(graph_config.get("edges"), list):
|
||||
raise ValueError("edges in workflow graph must be a list")
|
||||
|
||||
# filter nodes only in the specified node type (iteration or loop)
|
||||
main_node_config = next((n for n in graph_config.get("nodes", []) if n.get("id") == node_id), None)
|
||||
start_node_id = main_node_config.get("data", {}).get("start_node_id") if main_node_config else None
|
||||
node_configs = [
|
||||
node
|
||||
for node in graph_config.get("nodes", [])
|
||||
if node.get("id") == node_id
|
||||
or node.get("data", {}).get(node_type_filter_key, "") == node_id
|
||||
or (start_node_id and node.get("id") == start_node_id)
|
||||
]
|
||||
|
||||
graph_config["nodes"] = node_configs
|
||||
|
||||
node_ids = [node.get("id") for node in node_configs]
|
||||
|
||||
# filter edges only in the specified node type
|
||||
edge_configs = [
|
||||
edge
|
||||
for edge in graph_config.get("edges", [])
|
||||
if (edge.get("source") is None or edge.get("source") in node_ids)
|
||||
and (edge.get("target") is None or edge.get("target") in node_ids)
|
||||
]
|
||||
|
||||
graph_config["edges"] = edge_configs
|
||||
|
||||
# Create required parameters for Graph.init
|
||||
graph_init_params = GraphInitParams(
|
||||
@@ -361,6 +299,18 @@ class WorkflowBasedAppRunner:
|
||||
if not graph:
|
||||
raise ValueError("graph not found in workflow")
|
||||
|
||||
# fetch node config from node id
|
||||
target_node_config = None
|
||||
for node in node_configs:
|
||||
if node.get("id") == node_id:
|
||||
target_node_config = node
|
||||
break
|
||||
|
||||
if not target_node_config:
|
||||
raise ValueError(f"{node_type_label} node id not found in workflow graph")
|
||||
|
||||
target_node_config = NodeConfigDictAdapter.validate_python(target_node_config)
|
||||
|
||||
# Get node class
|
||||
node_type = target_node_config["data"].type
|
||||
node_version = str(target_node_config["data"].version)
|
||||
|
||||
@@ -213,7 +213,7 @@ class AdvancedChatAppGenerateEntity(ConversationAppGenerateEntity):
|
||||
"""
|
||||
|
||||
node_id: str
|
||||
inputs: Mapping[str, object]
|
||||
inputs: Mapping
|
||||
|
||||
single_iteration_run: SingleIterationRunEntity | None = None
|
||||
|
||||
@@ -223,7 +223,7 @@ class AdvancedChatAppGenerateEntity(ConversationAppGenerateEntity):
|
||||
"""
|
||||
|
||||
node_id: str
|
||||
inputs: Mapping[str, object]
|
||||
inputs: Mapping
|
||||
|
||||
single_loop_run: SingleLoopRunEntity | None = None
|
||||
|
||||
@@ -243,7 +243,7 @@ class WorkflowAppGenerateEntity(AppGenerateEntity):
|
||||
"""
|
||||
|
||||
node_id: str
|
||||
inputs: Mapping[str, object]
|
||||
inputs: dict
|
||||
|
||||
single_iteration_run: SingleIterationRunEntity | None = None
|
||||
|
||||
@@ -253,7 +253,7 @@ class WorkflowAppGenerateEntity(AppGenerateEntity):
|
||||
"""
|
||||
|
||||
node_id: str
|
||||
inputs: Mapping[str, object]
|
||||
inputs: dict
|
||||
|
||||
single_loop_run: SingleLoopRunEntity | None = None
|
||||
|
||||
|
||||
@@ -1040,10 +1040,9 @@ class ToolManager:
|
||||
continue
|
||||
tool_input = ToolNodeData.ToolInput.model_validate(tool_configurations.get(parameter.name, {}))
|
||||
if tool_input.type == "variable":
|
||||
variable_selector = tool_input.require_variable_selector()
|
||||
variable = variable_pool.get(variable_selector)
|
||||
variable = variable_pool.get(tool_input.value)
|
||||
if variable is None:
|
||||
raise ToolParameterError(f"Variable {variable_selector} does not exist")
|
||||
raise ToolParameterError(f"Variable {tool_input.value} does not exist")
|
||||
parameter_value = variable.value
|
||||
elif tool_input.type == "constant":
|
||||
parameter_value = tool_input.value
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum, StrEnum, auto
|
||||
from typing import Literal, TypeAlias
|
||||
from typing import Any, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter, field_validator
|
||||
from pydantic_core.core_schema import ValidationInfo
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.prompt.entities.advanced_prompt_entities import MemoryConfig
|
||||
from core.tools.entities.tool_entities import ToolSelector
|
||||
from dify_graph.entities.base_node_data import BaseNodeData
|
||||
from dify_graph.enums import BuiltinNodeTypes, NodeType
|
||||
|
||||
AgentInputConstantValue: TypeAlias = (
|
||||
list[ToolSelector] | str | int | float | bool | dict[str, object] | list[object] | None
|
||||
)
|
||||
VariableSelector: TypeAlias = list[str]
|
||||
|
||||
_AGENT_INPUT_VALUE_ADAPTER: TypeAdapter[AgentInputConstantValue] = TypeAdapter(AgentInputConstantValue)
|
||||
_AGENT_VARIABLE_SELECTOR_ADAPTER: TypeAdapter[VariableSelector] = TypeAdapter(VariableSelector)
|
||||
|
||||
|
||||
class AgentNodeData(BaseNodeData):
|
||||
type: NodeType = BuiltinNodeTypes.AGENT
|
||||
@@ -32,20 +21,8 @@ class AgentNodeData(BaseNodeData):
|
||||
tool_node_version: str | None = None
|
||||
|
||||
class AgentInput(BaseModel):
|
||||
value: Union[list[str], list[ToolSelector], Any]
|
||||
type: Literal["mixed", "variable", "constant"]
|
||||
value: AgentInputConstantValue | VariableSelector
|
||||
|
||||
@field_validator("value", mode="before")
|
||||
@classmethod
|
||||
def validate_value(
|
||||
cls, value: object, validation_info: ValidationInfo
|
||||
) -> AgentInputConstantValue | VariableSelector:
|
||||
input_type = validation_info.data.get("type")
|
||||
if input_type == "variable":
|
||||
return _AGENT_VARIABLE_SELECTOR_ADAPTER.validate_python(value)
|
||||
if input_type in {"mixed", "constant"}:
|
||||
return _AGENT_INPUT_VALUE_ADAPTER.validate_python(value)
|
||||
raise ValueError(f"Unknown agent input type: {input_type}")
|
||||
|
||||
agent_parameters: dict[str, AgentInput]
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TypeAlias
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from packaging.version import Version
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.agent.entities import AgentToolEntity
|
||||
from core.agent.plugin_entities import AgentStrategyParameter
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.memory.token_buffer_memory import TokenBufferMemory
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from core.plugin.entities.request import InvokeCredentials
|
||||
@@ -29,14 +28,6 @@ from .entities import AgentNodeData, AgentOldVersionModelFeatures, ParamsAutoGen
|
||||
from .exceptions import AgentInputTypeError, AgentVariableNotFoundError
|
||||
from .strategy_protocols import ResolvedAgentStrategy
|
||||
|
||||
JsonObject: TypeAlias = dict[str, object]
|
||||
JsonObjectList: TypeAlias = list[JsonObject]
|
||||
VariableSelector: TypeAlias = list[str]
|
||||
|
||||
_JSON_OBJECT_ADAPTER = TypeAdapter(JsonObject)
|
||||
_JSON_OBJECT_LIST_ADAPTER = TypeAdapter(JsonObjectList)
|
||||
_VARIABLE_SELECTOR_ADAPTER = TypeAdapter(VariableSelector)
|
||||
|
||||
|
||||
class AgentRuntimeSupport:
|
||||
def build_parameters(
|
||||
@@ -48,12 +39,12 @@ class AgentRuntimeSupport:
|
||||
strategy: ResolvedAgentStrategy,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
invoke_from: InvokeFrom,
|
||||
invoke_from: Any,
|
||||
for_log: bool = False,
|
||||
) -> dict[str, object]:
|
||||
) -> dict[str, Any]:
|
||||
agent_parameters_dictionary = {parameter.name: parameter for parameter in agent_parameters}
|
||||
|
||||
result: dict[str, object] = {}
|
||||
result: dict[str, Any] = {}
|
||||
for parameter_name in node_data.agent_parameters:
|
||||
parameter = agent_parameters_dictionary.get(parameter_name)
|
||||
if not parameter:
|
||||
@@ -63,10 +54,9 @@ class AgentRuntimeSupport:
|
||||
agent_input = node_data.agent_parameters[parameter_name]
|
||||
match agent_input.type:
|
||||
case "variable":
|
||||
variable_selector = _VARIABLE_SELECTOR_ADAPTER.validate_python(agent_input.value)
|
||||
variable = variable_pool.get(variable_selector)
|
||||
variable = variable_pool.get(agent_input.value) # type: ignore[arg-type]
|
||||
if variable is None:
|
||||
raise AgentVariableNotFoundError(str(variable_selector))
|
||||
raise AgentVariableNotFoundError(str(agent_input.value))
|
||||
parameter_value = variable.value
|
||||
case "mixed" | "constant":
|
||||
try:
|
||||
@@ -89,38 +79,60 @@ class AgentRuntimeSupport:
|
||||
|
||||
value = parameter_value
|
||||
if parameter.type == "array[tools]":
|
||||
tool_payloads = _JSON_OBJECT_LIST_ADAPTER.validate_python(value)
|
||||
value = self._normalize_tool_payloads(
|
||||
strategy=strategy,
|
||||
tools=tool_payloads,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
value = cast(list[dict[str, Any]], value)
|
||||
value = [tool for tool in value if tool.get("enabled", False)]
|
||||
value = self._filter_mcp_type_tool(strategy, value)
|
||||
for tool in value:
|
||||
if "schemas" in tool:
|
||||
tool.pop("schemas")
|
||||
parameters = tool.get("parameters", {})
|
||||
if all(isinstance(v, dict) for _, v in parameters.items()):
|
||||
params = {}
|
||||
for key, param in parameters.items():
|
||||
if param.get("auto", ParamsAutoGenerated.OPEN) in (
|
||||
ParamsAutoGenerated.CLOSE,
|
||||
0,
|
||||
):
|
||||
value_param = param.get("value", {})
|
||||
if value_param and value_param.get("type", "") == "variable":
|
||||
variable_selector = value_param.get("value")
|
||||
if not variable_selector:
|
||||
raise ValueError("Variable selector is missing for a variable-type parameter.")
|
||||
|
||||
variable = variable_pool.get(variable_selector)
|
||||
if variable is None:
|
||||
raise AgentVariableNotFoundError(str(variable_selector))
|
||||
|
||||
params[key] = variable.value
|
||||
else:
|
||||
params[key] = value_param.get("value", "") if value_param is not None else None
|
||||
else:
|
||||
params[key] = None
|
||||
parameters = params
|
||||
tool["settings"] = {k: v.get("value", None) for k, v in tool.get("settings", {}).items()}
|
||||
tool["parameters"] = parameters
|
||||
|
||||
if not for_log:
|
||||
if parameter.type == "array[tools]":
|
||||
value = _JSON_OBJECT_LIST_ADAPTER.validate_python(value)
|
||||
value = cast(list[dict[str, Any]], value)
|
||||
tool_value = []
|
||||
for tool in value:
|
||||
provider_type = self._coerce_tool_provider_type(tool.get("type"))
|
||||
setting_params = self._coerce_json_object(tool.get("settings")) or {}
|
||||
parameters = self._coerce_json_object(tool.get("parameters")) or {}
|
||||
provider_type = ToolProviderType(tool.get("type", ToolProviderType.BUILT_IN))
|
||||
setting_params = tool.get("settings", {})
|
||||
parameters = tool.get("parameters", {})
|
||||
manual_input_params = [key for key, value in parameters.items() if value is not None]
|
||||
|
||||
parameters = {**parameters, **setting_params}
|
||||
provider_id = self._coerce_optional_string(tool.get("provider_name")) or ""
|
||||
tool_name = self._coerce_optional_string(tool.get("tool_name")) or ""
|
||||
plugin_unique_identifier = self._coerce_optional_string(tool.get("plugin_unique_identifier"))
|
||||
credential_id = self._coerce_optional_string(tool.get("credential_id"))
|
||||
entity = AgentToolEntity(
|
||||
provider_id=provider_id,
|
||||
provider_id=tool.get("provider_name", ""),
|
||||
provider_type=provider_type,
|
||||
tool_name=tool_name,
|
||||
tool_name=tool.get("tool_name", ""),
|
||||
tool_parameters=parameters,
|
||||
plugin_unique_identifier=plugin_unique_identifier,
|
||||
credential_id=credential_id,
|
||||
plugin_unique_identifier=tool.get("plugin_unique_identifier", None),
|
||||
credential_id=tool.get("credential_id", None),
|
||||
)
|
||||
|
||||
extra = self._coerce_json_object(tool.get("extra")) or {}
|
||||
extra = tool.get("extra", {})
|
||||
|
||||
runtime_variable_pool: VariablePool | None = None
|
||||
if node_data.version != "1" or node_data.tool_node_version is not None:
|
||||
@@ -133,9 +145,8 @@ class AgentRuntimeSupport:
|
||||
runtime_variable_pool,
|
||||
)
|
||||
if tool_runtime.entity.description:
|
||||
description_override = self._coerce_optional_string(extra.get("description"))
|
||||
tool_runtime.entity.description.llm = (
|
||||
description_override or tool_runtime.entity.description.llm
|
||||
extra.get("description", "") or tool_runtime.entity.description.llm
|
||||
)
|
||||
for tool_runtime_params in tool_runtime.entity.parameters:
|
||||
tool_runtime_params.form = (
|
||||
@@ -156,13 +167,13 @@ class AgentRuntimeSupport:
|
||||
{
|
||||
**tool_runtime.entity.model_dump(mode="json"),
|
||||
"runtime_parameters": runtime_parameters,
|
||||
"credential_id": credential_id,
|
||||
"credential_id": tool.get("credential_id", None),
|
||||
"provider_type": provider_type.value,
|
||||
}
|
||||
)
|
||||
value = tool_value
|
||||
if parameter.type == AgentStrategyParameter.AgentStrategyParameterType.MODEL_SELECTOR:
|
||||
value = _JSON_OBJECT_ADAPTER.validate_python(value)
|
||||
value = cast(dict[str, Any], value)
|
||||
model_instance, model_schema = self.fetch_model(tenant_id=tenant_id, value=value)
|
||||
history_prompt_messages = []
|
||||
if node_data.memory:
|
||||
@@ -188,27 +199,17 @@ class AgentRuntimeSupport:
|
||||
|
||||
return result
|
||||
|
||||
def build_credentials(self, *, parameters: Mapping[str, object]) -> InvokeCredentials:
|
||||
def build_credentials(self, *, parameters: dict[str, Any]) -> InvokeCredentials:
|
||||
credentials = InvokeCredentials()
|
||||
credentials.tool_credentials = {}
|
||||
tools = parameters.get("tools")
|
||||
if not isinstance(tools, list):
|
||||
return credentials
|
||||
|
||||
for raw_tool in tools:
|
||||
tool = self._coerce_json_object(raw_tool)
|
||||
if tool is None:
|
||||
continue
|
||||
for tool in parameters.get("tools", []):
|
||||
if not tool.get("credential_id"):
|
||||
continue
|
||||
try:
|
||||
identity = ToolIdentity.model_validate(tool.get("identity", {}))
|
||||
except ValidationError:
|
||||
continue
|
||||
credential_id = self._coerce_optional_string(tool.get("credential_id"))
|
||||
if credential_id is None:
|
||||
continue
|
||||
credentials.tool_credentials[identity.provider] = credential_id
|
||||
credentials.tool_credentials[identity.provider] = tool.get("credential_id", None)
|
||||
return credentials
|
||||
|
||||
def fetch_memory(
|
||||
@@ -231,14 +232,14 @@ class AgentRuntimeSupport:
|
||||
|
||||
return TokenBufferMemory(conversation=conversation, model_instance=model_instance)
|
||||
|
||||
def fetch_model(self, *, tenant_id: str, value: Mapping[str, object]) -> tuple[ModelInstance, AIModelEntity | None]:
|
||||
def fetch_model(self, *, tenant_id: str, value: dict[str, Any]) -> tuple[ModelInstance, AIModelEntity | None]:
|
||||
provider_manager = ProviderManager()
|
||||
provider_model_bundle = provider_manager.get_provider_model_bundle(
|
||||
tenant_id=tenant_id,
|
||||
provider=str(value.get("provider", "")),
|
||||
provider=value.get("provider", ""),
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
model_name = str(value.get("model", ""))
|
||||
model_name = value.get("model", "")
|
||||
model_credentials = provider_model_bundle.configuration.get_current_credentials(
|
||||
model_type=ModelType.LLM,
|
||||
model=model_name,
|
||||
@@ -248,7 +249,7 @@ class AgentRuntimeSupport:
|
||||
model_instance = ModelManager().get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider_name,
|
||||
model_type=ModelType(str(value.get("model_type", ""))),
|
||||
model_type=ModelType(value.get("model_type", "")),
|
||||
model=model_name,
|
||||
)
|
||||
model_schema = model_type_instance.get_model_schema(model_name, model_credentials)
|
||||
@@ -267,88 +268,9 @@ class AgentRuntimeSupport:
|
||||
@staticmethod
|
||||
def _filter_mcp_type_tool(
|
||||
strategy: ResolvedAgentStrategy,
|
||||
tools: JsonObjectList,
|
||||
) -> JsonObjectList:
|
||||
tools: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
meta_version = strategy.meta_version
|
||||
if meta_version and Version(meta_version) > Version("0.0.1"):
|
||||
return tools
|
||||
return [tool for tool in tools if tool.get("type") != ToolProviderType.MCP]
|
||||
|
||||
def _normalize_tool_payloads(
|
||||
self,
|
||||
*,
|
||||
strategy: ResolvedAgentStrategy,
|
||||
tools: JsonObjectList,
|
||||
variable_pool: VariablePool,
|
||||
) -> JsonObjectList:
|
||||
enabled_tools = [dict(tool) for tool in tools if bool(tool.get("enabled", False))]
|
||||
normalized_tools = self._filter_mcp_type_tool(strategy, enabled_tools)
|
||||
for tool in normalized_tools:
|
||||
tool.pop("schemas", None)
|
||||
tool["parameters"] = self._resolve_tool_parameters(tool=tool, variable_pool=variable_pool)
|
||||
tool["settings"] = self._resolve_tool_settings(tool)
|
||||
return normalized_tools
|
||||
|
||||
def _resolve_tool_parameters(self, *, tool: Mapping[str, object], variable_pool: VariablePool) -> JsonObject:
|
||||
parameter_configs = self._coerce_named_json_objects(tool.get("parameters"))
|
||||
if parameter_configs is None:
|
||||
raw_parameters = self._coerce_json_object(tool.get("parameters"))
|
||||
return raw_parameters or {}
|
||||
|
||||
resolved_parameters: JsonObject = {}
|
||||
for key, parameter_config in parameter_configs.items():
|
||||
if parameter_config.get("auto", ParamsAutoGenerated.OPEN) in (ParamsAutoGenerated.CLOSE, 0):
|
||||
value_param = self._coerce_json_object(parameter_config.get("value"))
|
||||
if value_param and value_param.get("type") == "variable":
|
||||
variable_selector = _VARIABLE_SELECTOR_ADAPTER.validate_python(value_param.get("value"))
|
||||
variable = variable_pool.get(variable_selector)
|
||||
if variable is None:
|
||||
raise AgentVariableNotFoundError(str(variable_selector))
|
||||
resolved_parameters[key] = variable.value
|
||||
else:
|
||||
resolved_parameters[key] = value_param.get("value", "") if value_param is not None else None
|
||||
else:
|
||||
resolved_parameters[key] = None
|
||||
|
||||
return resolved_parameters
|
||||
|
||||
@staticmethod
|
||||
def _resolve_tool_settings(tool: Mapping[str, object]) -> JsonObject:
|
||||
settings = AgentRuntimeSupport._coerce_named_json_objects(tool.get("settings"))
|
||||
if settings is None:
|
||||
return {}
|
||||
return {key: setting.get("value") for key, setting in settings.items()}
|
||||
|
||||
@staticmethod
|
||||
def _coerce_json_object(value: object) -> JsonObject | None:
|
||||
try:
|
||||
return _JSON_OBJECT_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _coerce_optional_string(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
@staticmethod
|
||||
def _coerce_tool_provider_type(value: object) -> ToolProviderType:
|
||||
if isinstance(value, ToolProviderType):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return ToolProviderType(value)
|
||||
return ToolProviderType.BUILT_IN
|
||||
|
||||
@classmethod
|
||||
def _coerce_named_json_objects(cls, value: object) -> dict[str, JsonObject] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
|
||||
coerced: dict[str, JsonObject] = {}
|
||||
for key, item in value.items():
|
||||
if not isinstance(key, str):
|
||||
return None
|
||||
json_object = cls._coerce_json_object(item)
|
||||
if json_object is None:
|
||||
return None
|
||||
coerced[key] = json_object
|
||||
return coerced
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import Any, TypeAlias, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
@@ -32,13 +32,6 @@ from models.workflow import Workflow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SpecialValueScalar: TypeAlias = str | int | float | bool | None
|
||||
SpecialValue: TypeAlias = SpecialValueScalar | File | Mapping[str, "SpecialValue"] | list["SpecialValue"]
|
||||
SerializedSpecialValue: TypeAlias = (
|
||||
SpecialValueScalar | dict[str, "SerializedSpecialValue"] | list["SerializedSpecialValue"]
|
||||
)
|
||||
SingleNodeGraphConfig: TypeAlias = dict[str, list[dict[str, object]]]
|
||||
|
||||
|
||||
class _WorkflowChildEngineBuilder:
|
||||
@staticmethod
|
||||
@@ -283,10 +276,10 @@ class WorkflowEntry:
|
||||
@staticmethod
|
||||
def _create_single_node_graph(
|
||||
node_id: str,
|
||||
node_data: Mapping[str, object],
|
||||
node_data: dict[str, Any],
|
||||
node_width: int = 114,
|
||||
node_height: int = 514,
|
||||
) -> SingleNodeGraphConfig:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a minimal graph structure for testing a single node in isolation.
|
||||
|
||||
@@ -296,14 +289,14 @@ class WorkflowEntry:
|
||||
:param node_height: height for UI layout (default: 100)
|
||||
:return: graph dictionary with start node and target node
|
||||
"""
|
||||
node_config: dict[str, object] = {
|
||||
node_config = {
|
||||
"id": node_id,
|
||||
"width": node_width,
|
||||
"height": node_height,
|
||||
"type": "custom",
|
||||
"data": dict(node_data),
|
||||
"data": node_data,
|
||||
}
|
||||
start_node_config: dict[str, object] = {
|
||||
start_node_config = {
|
||||
"id": "start",
|
||||
"width": node_width,
|
||||
"height": node_height,
|
||||
@@ -328,12 +321,7 @@ class WorkflowEntry:
|
||||
|
||||
@classmethod
|
||||
def run_free_node(
|
||||
cls,
|
||||
node_data: Mapping[str, object],
|
||||
node_id: str,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
user_inputs: Mapping[str, object],
|
||||
cls, node_data: dict[str, Any], node_id: str, tenant_id: str, user_id: str, user_inputs: dict[str, Any]
|
||||
) -> tuple[Node, Generator[GraphNodeEventBase, None, None]]:
|
||||
"""
|
||||
Run free node
|
||||
@@ -351,8 +339,6 @@ class WorkflowEntry:
|
||||
graph_dict = cls._create_single_node_graph(node_id, node_data)
|
||||
|
||||
node_type = node_data.get("type", "")
|
||||
if not isinstance(node_type, str):
|
||||
raise ValueError("Node type must be a string")
|
||||
if node_type not in {BuiltinNodeTypes.PARAMETER_EXTRACTOR, BuiltinNodeTypes.QUESTION_CLASSIFIER}:
|
||||
raise ValueError(f"Node type {node_type} not supported")
|
||||
|
||||
@@ -383,7 +369,7 @@ class WorkflowEntry:
|
||||
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
|
||||
|
||||
# init workflow run state
|
||||
node_config = NodeConfigDictAdapter.validate_python({"id": node_id, "data": dict(node_data)})
|
||||
node_config = NodeConfigDictAdapter.validate_python({"id": node_id, "data": node_data})
|
||||
node_factory = DifyNodeFactory(
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
@@ -419,34 +405,30 @@ class WorkflowEntry:
|
||||
raise WorkflowNodeRunFailedError(node=node, err_msg=str(e))
|
||||
|
||||
@staticmethod
|
||||
def handle_special_values(value: Mapping[str, SpecialValue] | None) -> dict[str, SerializedSpecialValue] | None:
|
||||
def handle_special_values(value: Mapping[str, Any] | None) -> Mapping[str, Any] | None:
|
||||
# NOTE(QuantumGhost): Avoid using this function in new code.
|
||||
# Keep values structured as long as possible and only convert to dict
|
||||
# immediately before serialization (e.g., JSON serialization) to maintain
|
||||
# data integrity and type information.
|
||||
result = WorkflowEntry._handle_special_values(value)
|
||||
if result is None:
|
||||
return None
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
raise TypeError("handle_special_values expects a mapping input")
|
||||
return result if isinstance(result, Mapping) or result is None else dict(result)
|
||||
|
||||
@staticmethod
|
||||
def _handle_special_values(value: SpecialValue) -> SerializedSpecialValue:
|
||||
def _handle_special_values(value: Any):
|
||||
if value is None:
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
res: dict[str, SerializedSpecialValue] = {}
|
||||
if isinstance(value, dict):
|
||||
res = {}
|
||||
for k, v in value.items():
|
||||
res[k] = WorkflowEntry._handle_special_values(v)
|
||||
return res
|
||||
if isinstance(value, list):
|
||||
res_list: list[SerializedSpecialValue] = []
|
||||
res_list = []
|
||||
for item in value:
|
||||
res_list.append(WorkflowEntry._handle_special_values(item))
|
||||
return res_list
|
||||
if isinstance(value, File):
|
||||
return dict(value.to_dict())
|
||||
return value.to_dict()
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -112,8 +112,6 @@ def _get_encoded_string(f: File, /) -> str:
|
||||
data = _download_file_content(f.storage_key)
|
||||
case FileTransferMethod.DATASOURCE_FILE:
|
||||
data = _download_file_content(f.storage_key)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported transfer method: {f.transfer_method}")
|
||||
|
||||
return base64.b64encode(data).decode("utf-8")
|
||||
|
||||
|
||||
@@ -133,8 +133,6 @@ class ExecutionLimitsLayer(GraphEngineLayer):
|
||||
elif limit_type == LimitType.TIME_LIMIT:
|
||||
elapsed_time = time.time() - self.start_time if self.start_time else 0
|
||||
reason = f"Maximum execution time exceeded: {elapsed_time:.2f}s > {self.max_time}s"
|
||||
else:
|
||||
return
|
||||
|
||||
self.logger.warning("Execution limit exceeded: %s", reason)
|
||||
|
||||
|
||||
@@ -336,7 +336,12 @@ class Node(Generic[NodeDataT]):
|
||||
|
||||
def _restore_execution_id_from_runtime_state(self) -> str | None:
|
||||
graph_execution = self.graph_runtime_state.graph_execution
|
||||
node_executions = graph_execution.node_executions
|
||||
try:
|
||||
node_executions = graph_execution.node_executions
|
||||
except AttributeError:
|
||||
return None
|
||||
if not isinstance(node_executions, dict):
|
||||
return None
|
||||
node_execution = node_executions.get(self._node_id)
|
||||
if node_execution is None:
|
||||
return None
|
||||
@@ -390,7 +395,8 @@ class Node(Generic[NodeDataT]):
|
||||
if isinstance(event, NodeEventBase): # pyright: ignore[reportUnnecessaryIsInstance]
|
||||
yield self._dispatch(event)
|
||||
elif isinstance(event, GraphNodeEventBase) and not event.in_iteration_id and not event.in_loop_id: # pyright: ignore[reportUnnecessaryIsInstance]
|
||||
yield event.model_copy(update={"id": self.execution_id})
|
||||
event.id = self.execution_id
|
||||
yield event
|
||||
else:
|
||||
yield event
|
||||
except Exception as e:
|
||||
|
||||
@@ -443,10 +443,7 @@ def _extract_text_from_docx(file_content: bytes) -> str:
|
||||
# Keep track of paragraph and table positions
|
||||
content_items: list[tuple[int, str, Table | Paragraph]] = []
|
||||
|
||||
doc_body = getattr(doc.element, "body", None)
|
||||
if doc_body is None:
|
||||
raise TextExtractionError("DOCX body not found")
|
||||
it = iter(doc_body)
|
||||
it = iter(doc.element.body)
|
||||
part = next(it, None)
|
||||
i = 0
|
||||
while part is not None:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Literal, NotRequired
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, CompletionModelPromptTemplate, MemoryConfig
|
||||
from dify_graph.entities.base_node_data import BaseNodeData
|
||||
@@ -11,17 +10,11 @@ from dify_graph.model_runtime.entities import ImagePromptMessageContent, LLMMode
|
||||
from dify_graph.nodes.base.entities import VariableSelector
|
||||
|
||||
|
||||
class StructuredOutputConfig(TypedDict):
|
||||
schema: Mapping[str, object]
|
||||
name: NotRequired[str]
|
||||
description: NotRequired[str]
|
||||
|
||||
|
||||
class ModelConfig(BaseModel):
|
||||
provider: str
|
||||
name: str
|
||||
mode: LLMMode
|
||||
completion_params: dict[str, object] = Field(default_factory=dict)
|
||||
completion_params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ContextConfig(BaseModel):
|
||||
@@ -40,7 +33,7 @@ class VisionConfig(BaseModel):
|
||||
|
||||
@field_validator("configs", mode="before")
|
||||
@classmethod
|
||||
def convert_none_configs(cls, v: object):
|
||||
def convert_none_configs(cls, v: Any):
|
||||
if v is None:
|
||||
return VisionConfigOptions()
|
||||
return v
|
||||
@@ -51,7 +44,7 @@ class PromptConfig(BaseModel):
|
||||
|
||||
@field_validator("jinja2_variables", mode="before")
|
||||
@classmethod
|
||||
def convert_none_jinja2_variables(cls, v: object):
|
||||
def convert_none_jinja2_variables(cls, v: Any):
|
||||
if v is None:
|
||||
return []
|
||||
return v
|
||||
@@ -74,7 +67,7 @@ class LLMNodeData(BaseNodeData):
|
||||
memory: MemoryConfig | None = None
|
||||
context: ContextConfig
|
||||
vision: VisionConfig = Field(default_factory=VisionConfig)
|
||||
structured_output: StructuredOutputConfig | None = None
|
||||
structured_output: Mapping[str, Any] | None = None
|
||||
# We used 'structured_output_enabled' in the past, but it's not a good name.
|
||||
structured_output_switch_on: bool = Field(False, alias="structured_output_enabled")
|
||||
reasoning_format: Literal["separated", "tagged"] = Field(
|
||||
@@ -97,7 +90,7 @@ class LLMNodeData(BaseNodeData):
|
||||
|
||||
@field_validator("prompt_config", mode="before")
|
||||
@classmethod
|
||||
def convert_none_prompt_config(cls, v: object):
|
||||
def convert_none_prompt_config(cls, v: Any):
|
||||
if v is None:
|
||||
return PromptConfig()
|
||||
return v
|
||||
|
||||
@@ -9,7 +9,6 @@ import time
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.llm_generator.output_parser.errors import OutputParserError
|
||||
@@ -75,7 +74,6 @@ from .entities import (
|
||||
LLMNodeChatModelMessage,
|
||||
LLMNodeCompletionModelPromptTemplate,
|
||||
LLMNodeData,
|
||||
StructuredOutputConfig,
|
||||
)
|
||||
from .exc import (
|
||||
InvalidContextStructureError,
|
||||
@@ -90,7 +88,6 @@ if TYPE_CHECKING:
|
||||
from dify_graph.runtime import GraphRuntimeState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_JSON_OBJECT_ADAPTER = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
class LLMNode(Node[LLMNodeData]):
|
||||
@@ -357,7 +354,7 @@ class LLMNode(Node[LLMNodeData]):
|
||||
stop: Sequence[str] | None = None,
|
||||
user_id: str,
|
||||
structured_output_enabled: bool,
|
||||
structured_output: StructuredOutputConfig | None = None,
|
||||
structured_output: Mapping[str, Any] | None = None,
|
||||
file_saver: LLMFileSaver,
|
||||
file_outputs: list[File],
|
||||
node_id: str,
|
||||
@@ -370,10 +367,8 @@ class LLMNode(Node[LLMNodeData]):
|
||||
model_schema = llm_utils.fetch_model_schema(model_instance=model_instance)
|
||||
|
||||
if structured_output_enabled:
|
||||
if structured_output is None:
|
||||
raise LLMNodeError("Please provide a valid structured output schema")
|
||||
output_schema = LLMNode.fetch_structured_output_schema(
|
||||
structured_output=structured_output,
|
||||
structured_output=structured_output or {},
|
||||
)
|
||||
request_start_time = time.perf_counter()
|
||||
|
||||
@@ -925,12 +920,6 @@ class LLMNode(Node[LLMNodeData]):
|
||||
# Extract clean text and reasoning from <think> tags
|
||||
clean_text, reasoning_content = LLMNode._split_reasoning(full_text, reasoning_format)
|
||||
|
||||
structured_output = (
|
||||
dict(invoke_result.structured_output)
|
||||
if isinstance(invoke_result, LLMResultWithStructuredOutput) and invoke_result.structured_output is not None
|
||||
else None
|
||||
)
|
||||
|
||||
event = ModelInvokeCompletedEvent(
|
||||
# Use clean_text for separated mode, full_text for tagged mode
|
||||
text=clean_text if reasoning_format == "separated" else full_text,
|
||||
@@ -939,7 +928,7 @@ class LLMNode(Node[LLMNodeData]):
|
||||
# Reasoning content for workflow variables and downstream nodes
|
||||
reasoning_content=reasoning_content,
|
||||
# Pass structured output if enabled
|
||||
structured_output=structured_output,
|
||||
structured_output=getattr(invoke_result, "structured_output", None),
|
||||
)
|
||||
if request_latency is not None:
|
||||
event.usage.latency = round(request_latency, 3)
|
||||
@@ -973,18 +962,27 @@ class LLMNode(Node[LLMNodeData]):
|
||||
@staticmethod
|
||||
def fetch_structured_output_schema(
|
||||
*,
|
||||
structured_output: StructuredOutputConfig,
|
||||
) -> dict[str, object]:
|
||||
structured_output: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Fetch the structured output schema from the node data.
|
||||
|
||||
Returns:
|
||||
dict[str, object]: The structured output schema
|
||||
dict[str, Any]: The structured output schema
|
||||
"""
|
||||
schema = structured_output.get("schema")
|
||||
if not schema:
|
||||
if not structured_output:
|
||||
raise LLMNodeError("Please provide a valid structured output schema")
|
||||
return _JSON_OBJECT_ADAPTER.validate_python(schema)
|
||||
structured_output_schema = json.dumps(structured_output.get("schema", {}), ensure_ascii=False)
|
||||
if not structured_output_schema:
|
||||
raise LLMNodeError("Please provide a valid structured output schema")
|
||||
|
||||
try:
|
||||
schema = json.loads(structured_output_schema)
|
||||
if not isinstance(schema, dict):
|
||||
raise LLMNodeError("structured_output_schema must be a JSON object")
|
||||
return schema
|
||||
except json.JSONDecodeError:
|
||||
raise LLMNodeError("structured_output_schema is not valid JSON format")
|
||||
|
||||
@staticmethod
|
||||
def _save_multimodal_output_and_convert_result_to_markdown(
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Literal, TypeAlias
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import AfterValidator, BaseModel, Field, TypeAdapter, field_validator
|
||||
from pydantic_core.core_schema import ValidationInfo
|
||||
from pydantic import AfterValidator, BaseModel, Field, field_validator
|
||||
|
||||
from dify_graph.entities.base_node_data import BaseNodeData
|
||||
from dify_graph.enums import BuiltinNodeTypes, NodeType
|
||||
@@ -12,14 +9,6 @@ from dify_graph.nodes.base import BaseLoopNodeData, BaseLoopState
|
||||
from dify_graph.utils.condition.entities import Condition
|
||||
from dify_graph.variables.types import SegmentType
|
||||
|
||||
LoopValue: TypeAlias = str | int | float | bool | None | dict[str, "LoopValue"] | list["LoopValue"]
|
||||
LoopValueMapping: TypeAlias = dict[str, LoopValue]
|
||||
VariableSelector: TypeAlias = list[str]
|
||||
|
||||
_LOOP_VALUE_ADAPTER: TypeAdapter[LoopValue] = TypeAdapter(LoopValue)
|
||||
_LOOP_VALUE_MAPPING_ADAPTER: TypeAdapter[LoopValueMapping] = TypeAdapter(LoopValueMapping)
|
||||
_VARIABLE_SELECTOR_ADAPTER: TypeAdapter[VariableSelector] = TypeAdapter(VariableSelector)
|
||||
|
||||
_VALID_VAR_TYPE = frozenset(
|
||||
[
|
||||
SegmentType.STRING,
|
||||
@@ -48,29 +37,7 @@ class LoopVariableData(BaseModel):
|
||||
label: str
|
||||
var_type: Annotated[SegmentType, AfterValidator(_is_valid_var_type)]
|
||||
value_type: Literal["variable", "constant"]
|
||||
value: LoopValue | VariableSelector | None = None
|
||||
|
||||
@field_validator("value", mode="before")
|
||||
@classmethod
|
||||
def validate_value(cls, value: object, validation_info: ValidationInfo) -> LoopValue | VariableSelector | None:
|
||||
value_type = validation_info.data.get("value_type")
|
||||
if value_type == "variable":
|
||||
if value is None:
|
||||
return None
|
||||
return _VARIABLE_SELECTOR_ADAPTER.validate_python(value)
|
||||
if value_type == "constant":
|
||||
return _LOOP_VALUE_ADAPTER.validate_python(value)
|
||||
raise ValueError(f"Unknown loop variable value type: {value_type}")
|
||||
|
||||
def require_variable_selector(self) -> VariableSelector:
|
||||
if self.value_type != "variable":
|
||||
raise ValueError(f"Expected variable loop input, got {self.value_type}")
|
||||
return _VARIABLE_SELECTOR_ADAPTER.validate_python(self.value)
|
||||
|
||||
def require_constant_value(self) -> LoopValue:
|
||||
if self.value_type != "constant":
|
||||
raise ValueError(f"Expected constant loop input, got {self.value_type}")
|
||||
return _LOOP_VALUE_ADAPTER.validate_python(self.value)
|
||||
value: Any | list[str] | None = None
|
||||
|
||||
|
||||
class LoopNodeData(BaseLoopNodeData):
|
||||
@@ -79,14 +46,14 @@ class LoopNodeData(BaseLoopNodeData):
|
||||
break_conditions: list[Condition] # Conditions to break the loop
|
||||
logical_operator: Literal["and", "or"]
|
||||
loop_variables: list[LoopVariableData] | None = Field(default_factory=list[LoopVariableData])
|
||||
outputs: LoopValueMapping = Field(default_factory=dict)
|
||||
outputs: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("outputs", mode="before")
|
||||
@classmethod
|
||||
def validate_outputs(cls, value: object) -> LoopValueMapping:
|
||||
if value is None:
|
||||
def validate_outputs(cls, v):
|
||||
if v is None:
|
||||
return {}
|
||||
return _LOOP_VALUE_MAPPING_ADAPTER.validate_python(value)
|
||||
return v
|
||||
|
||||
|
||||
class LoopStartNodeData(BaseNodeData):
|
||||
@@ -110,8 +77,8 @@ class LoopState(BaseLoopState):
|
||||
Loop State.
|
||||
"""
|
||||
|
||||
outputs: list[LoopValue] = Field(default_factory=list)
|
||||
current_output: LoopValue | None = None
|
||||
outputs: list[Any] = Field(default_factory=list)
|
||||
current_output: Any = None
|
||||
|
||||
class MetaData(BaseLoopState.MetaData):
|
||||
"""
|
||||
@@ -120,7 +87,7 @@ class LoopState(BaseLoopState):
|
||||
|
||||
loop_length: int
|
||||
|
||||
def get_last_output(self) -> LoopValue | None:
|
||||
def get_last_output(self) -> Any:
|
||||
"""
|
||||
Get last output.
|
||||
"""
|
||||
@@ -128,7 +95,7 @@ class LoopState(BaseLoopState):
|
||||
return self.outputs[-1]
|
||||
return None
|
||||
|
||||
def get_current_output(self) -> LoopValue | None:
|
||||
def get_current_output(self) -> Any:
|
||||
"""
|
||||
Get current output.
|
||||
"""
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import logging
|
||||
from collections.abc import Callable, Generator, Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from dify_graph.entities.graph_config import NodeConfigDictAdapter
|
||||
from dify_graph.enums import (
|
||||
@@ -29,7 +29,7 @@ from dify_graph.node_events import (
|
||||
)
|
||||
from dify_graph.nodes.base import LLMUsageTrackingMixin
|
||||
from dify_graph.nodes.base.node import Node
|
||||
from dify_graph.nodes.loop.entities import LoopCompletedReason, LoopNodeData, LoopValue, LoopVariableData
|
||||
from dify_graph.nodes.loop.entities import LoopCompletedReason, LoopNodeData, LoopVariableData
|
||||
from dify_graph.utils.condition.processor import ConditionProcessor
|
||||
from dify_graph.variables import Segment, SegmentType
|
||||
from factories.variable_factory import TypeMismatchError, build_segment_with_type, segment_to_variable
|
||||
@@ -60,7 +60,7 @@ class LoopNode(LLMUsageTrackingMixin, Node[LoopNodeData]):
|
||||
break_conditions = self.node_data.break_conditions
|
||||
logical_operator = self.node_data.logical_operator
|
||||
|
||||
inputs: dict[str, object] = {"loop_count": loop_count}
|
||||
inputs = {"loop_count": loop_count}
|
||||
|
||||
if not self.node_data.start_node_id:
|
||||
raise ValueError(f"field start_node_id in loop {self._node_id} not found")
|
||||
@@ -68,14 +68,12 @@ class LoopNode(LLMUsageTrackingMixin, Node[LoopNodeData]):
|
||||
root_node_id = self.node_data.start_node_id
|
||||
|
||||
# Initialize loop variables in the original variable pool
|
||||
loop_variable_selectors: dict[str, list[str]] = {}
|
||||
loop_variable_selectors = {}
|
||||
if self.node_data.loop_variables:
|
||||
value_processor: dict[Literal["constant", "variable"], Callable[[LoopVariableData], Segment | None]] = {
|
||||
"constant": lambda var: self._get_segment_for_constant(var.var_type, var.require_constant_value()),
|
||||
"constant": lambda var: self._get_segment_for_constant(var.var_type, var.value),
|
||||
"variable": lambda var: (
|
||||
self.graph_runtime_state.variable_pool.get(var.require_variable_selector())
|
||||
if var.value is not None
|
||||
else None
|
||||
self.graph_runtime_state.variable_pool.get(var.value) if isinstance(var.value, list) else None
|
||||
),
|
||||
}
|
||||
for loop_variable in self.node_data.loop_variables:
|
||||
@@ -97,7 +95,7 @@ class LoopNode(LLMUsageTrackingMixin, Node[LoopNodeData]):
|
||||
condition_processor = ConditionProcessor()
|
||||
|
||||
loop_duration_map: dict[str, float] = {}
|
||||
single_loop_variable_map: dict[str, dict[str, LoopValue]] = {} # single loop variable output
|
||||
single_loop_variable_map: dict[str, dict[str, Any]] = {} # single loop variable output
|
||||
loop_usage = LLMUsage.empty_usage()
|
||||
loop_node_ids = self._extract_loop_node_ids_from_config(self.graph_config, self._node_id)
|
||||
|
||||
@@ -148,7 +146,7 @@ class LoopNode(LLMUsageTrackingMixin, Node[LoopNodeData]):
|
||||
loop_usage = self._merge_usage(loop_usage, graph_engine.graph_runtime_state.llm_usage)
|
||||
|
||||
# Collect loop variable values after iteration
|
||||
single_loop_variable: dict[str, LoopValue] = {}
|
||||
single_loop_variable = {}
|
||||
for key, selector in loop_variable_selectors.items():
|
||||
segment = self.graph_runtime_state.variable_pool.get(selector)
|
||||
single_loop_variable[key] = segment.value if segment else None
|
||||
@@ -299,29 +297,20 @@ class LoopNode(LLMUsageTrackingMixin, Node[LoopNodeData]):
|
||||
def _extract_variable_selector_to_variable_mapping(
|
||||
cls,
|
||||
*,
|
||||
graph_config: Mapping[str, object],
|
||||
graph_config: Mapping[str, Any],
|
||||
node_id: str,
|
||||
node_data: LoopNodeData,
|
||||
) -> Mapping[str, Sequence[str]]:
|
||||
variable_mapping: dict[str, Sequence[str]] = {}
|
||||
variable_mapping = {}
|
||||
|
||||
# Extract loop node IDs statically from graph_config
|
||||
|
||||
loop_node_ids = cls._extract_loop_node_ids_from_config(graph_config, node_id)
|
||||
|
||||
# Get node configs from graph_config
|
||||
raw_nodes = graph_config.get("nodes")
|
||||
node_configs: dict[str, Mapping[str, object]] = {}
|
||||
if isinstance(raw_nodes, list):
|
||||
for raw_node in raw_nodes:
|
||||
if not isinstance(raw_node, dict):
|
||||
continue
|
||||
raw_node_id = raw_node.get("id")
|
||||
if isinstance(raw_node_id, str):
|
||||
node_configs[raw_node_id] = raw_node
|
||||
node_configs = {node["id"]: node for node in graph_config.get("nodes", []) if "id" in node}
|
||||
for sub_node_id, sub_node_config in node_configs.items():
|
||||
sub_node_data = sub_node_config.get("data")
|
||||
if not isinstance(sub_node_data, dict) or sub_node_data.get("loop_id") != node_id:
|
||||
if sub_node_config.get("data", {}).get("loop_id") != node_id:
|
||||
continue
|
||||
|
||||
# variable selector to variable mapping
|
||||
@@ -352,8 +341,9 @@ class LoopNode(LLMUsageTrackingMixin, Node[LoopNodeData]):
|
||||
|
||||
for loop_variable in node_data.loop_variables or []:
|
||||
if loop_variable.value_type == "variable":
|
||||
assert loop_variable.value is not None, "Loop variable value must be provided for variable type"
|
||||
# add loop variable to variable mapping
|
||||
selector = loop_variable.require_variable_selector()
|
||||
selector = loop_variable.value
|
||||
variable_mapping[f"{node_id}.{loop_variable.label}"] = selector
|
||||
|
||||
# remove variable out from loop
|
||||
@@ -362,7 +352,7 @@ class LoopNode(LLMUsageTrackingMixin, Node[LoopNodeData]):
|
||||
return variable_mapping
|
||||
|
||||
@classmethod
|
||||
def _extract_loop_node_ids_from_config(cls, graph_config: Mapping[str, object], loop_node_id: str) -> set[str]:
|
||||
def _extract_loop_node_ids_from_config(cls, graph_config: Mapping[str, Any], loop_node_id: str) -> set[str]:
|
||||
"""
|
||||
Extract node IDs that belong to a specific loop from graph configuration.
|
||||
|
||||
@@ -373,19 +363,12 @@ class LoopNode(LLMUsageTrackingMixin, Node[LoopNodeData]):
|
||||
:param loop_node_id: the ID of the loop node
|
||||
:return: set of node IDs that belong to the loop
|
||||
"""
|
||||
loop_node_ids: set[str] = set()
|
||||
loop_node_ids = set()
|
||||
|
||||
# Find all nodes that belong to this loop
|
||||
raw_nodes = graph_config.get("nodes")
|
||||
if not isinstance(raw_nodes, list):
|
||||
return loop_node_ids
|
||||
|
||||
for node in raw_nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_data = node.get("data")
|
||||
if not isinstance(node_data, dict):
|
||||
continue
|
||||
nodes = graph_config.get("nodes", [])
|
||||
for node in nodes:
|
||||
node_data = node.get("data", {})
|
||||
if node_data.get("loop_id") == loop_node_id:
|
||||
node_id = node.get("id")
|
||||
if node_id:
|
||||
@@ -394,7 +377,7 @@ class LoopNode(LLMUsageTrackingMixin, Node[LoopNodeData]):
|
||||
return loop_node_ids
|
||||
|
||||
@staticmethod
|
||||
def _get_segment_for_constant(var_type: SegmentType, original_value: LoopValue | None) -> Segment:
|
||||
def _get_segment_for_constant(var_type: SegmentType, original_value: Any) -> Segment:
|
||||
"""Get the appropriate segment type for a constant value."""
|
||||
# TODO: Refactor for maintainability:
|
||||
# 1. Ensure type handling logic stays synchronized with _VALID_VAR_TYPE (entities.py)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Annotated, Literal
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
@@ -6,7 +6,6 @@ from pydantic import (
|
||||
Field,
|
||||
field_validator,
|
||||
)
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from core.prompt.entities.advanced_prompt_entities import MemoryConfig
|
||||
from dify_graph.entities.base_node_data import BaseNodeData
|
||||
@@ -56,7 +55,7 @@ class ParameterConfig(BaseModel):
|
||||
|
||||
@field_validator("name", mode="before")
|
||||
@classmethod
|
||||
def validate_name(cls, value: object) -> str:
|
||||
def validate_name(cls, value) -> str:
|
||||
if not value:
|
||||
raise ValueError("Parameter name is required")
|
||||
if value in {"__reason", "__is_success"}:
|
||||
@@ -80,23 +79,6 @@ class ParameterConfig(BaseModel):
|
||||
return element_type
|
||||
|
||||
|
||||
class JsonSchemaArrayItems(TypedDict):
|
||||
type: str
|
||||
|
||||
|
||||
class ParameterJsonSchemaProperty(TypedDict, total=False):
|
||||
description: str
|
||||
type: str
|
||||
items: JsonSchemaArrayItems
|
||||
enum: list[str]
|
||||
|
||||
|
||||
class ParameterJsonSchema(TypedDict):
|
||||
type: Literal["object"]
|
||||
properties: dict[str, ParameterJsonSchemaProperty]
|
||||
required: list[str]
|
||||
|
||||
|
||||
class ParameterExtractorNodeData(BaseNodeData):
|
||||
"""
|
||||
Parameter Extractor Node Data.
|
||||
@@ -113,19 +95,19 @@ class ParameterExtractorNodeData(BaseNodeData):
|
||||
|
||||
@field_validator("reasoning_mode", mode="before")
|
||||
@classmethod
|
||||
def set_reasoning_mode(cls, v: object) -> str:
|
||||
return str(v) if v else "function_call"
|
||||
def set_reasoning_mode(cls, v) -> str:
|
||||
return v or "function_call"
|
||||
|
||||
def get_parameter_json_schema(self) -> ParameterJsonSchema:
|
||||
def get_parameter_json_schema(self):
|
||||
"""
|
||||
Get parameter json schema.
|
||||
|
||||
:return: parameter json schema
|
||||
"""
|
||||
parameters: ParameterJsonSchema = {"type": "object", "properties": {}, "required": []}
|
||||
parameters: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
for parameter in self.parameters:
|
||||
parameter_schema: ParameterJsonSchemaProperty = {"description": parameter.description}
|
||||
parameter_schema: dict[str, Any] = {"description": parameter.description}
|
||||
|
||||
if parameter.type == SegmentType.STRING:
|
||||
parameter_schema["type"] = "string"
|
||||
@@ -136,7 +118,7 @@ class ParameterExtractorNodeData(BaseNodeData):
|
||||
raise AssertionError("element type should not be None.")
|
||||
parameter_schema["items"] = {"type": element_type.value}
|
||||
else:
|
||||
parameter_schema["type"] = parameter.type.value
|
||||
parameter_schema["type"] = parameter.type
|
||||
|
||||
if parameter.options:
|
||||
parameter_schema["enum"] = parameter.options
|
||||
|
||||
@@ -5,8 +5,6 @@ import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from core.model_manager import ModelInstance
|
||||
from core.prompt.advanced_prompt_transform import AdvancedPromptTransform
|
||||
from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, CompletionModelPromptTemplate
|
||||
@@ -65,7 +63,6 @@ from .prompts import (
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_JSON_OBJECT_ADAPTER = TypeAdapter(dict[str, object])
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dify_graph.entities import GraphInitParams
|
||||
@@ -73,7 +70,7 @@ if TYPE_CHECKING:
|
||||
from dify_graph.runtime import GraphRuntimeState
|
||||
|
||||
|
||||
def extract_json(text: str) -> str | None:
|
||||
def extract_json(text):
|
||||
"""
|
||||
From a given JSON started from '{' or '[' extract the complete JSON object.
|
||||
"""
|
||||
@@ -395,15 +392,10 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]):
|
||||
)
|
||||
|
||||
# generate tool
|
||||
parameter_schema = node_data.get_parameter_json_schema()
|
||||
tool = PromptMessageTool(
|
||||
name=FUNCTION_CALLING_EXTRACTOR_NAME,
|
||||
description="Extract parameters from the natural language text",
|
||||
parameters={
|
||||
"type": parameter_schema["type"],
|
||||
"properties": dict(parameter_schema["properties"]),
|
||||
"required": list(parameter_schema["required"]),
|
||||
},
|
||||
parameters=node_data.get_parameter_json_schema(),
|
||||
)
|
||||
|
||||
return prompt_messages, [tool]
|
||||
@@ -610,21 +602,19 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]):
|
||||
else:
|
||||
return None
|
||||
|
||||
def _transform_result(self, data: ParameterExtractorNodeData, result: Mapping[str, object]) -> dict[str, object]:
|
||||
def _transform_result(self, data: ParameterExtractorNodeData, result: dict):
|
||||
"""
|
||||
Transform result into standard format.
|
||||
"""
|
||||
transformed_result: dict[str, object] = {}
|
||||
transformed_result: dict[str, Any] = {}
|
||||
for parameter in data.parameters:
|
||||
if parameter.name in result:
|
||||
param_value = result[parameter.name]
|
||||
# transform value
|
||||
if parameter.type == SegmentType.NUMBER:
|
||||
if isinstance(param_value, (bool, int, float, str)):
|
||||
numeric_value: bool | int | float | str = param_value
|
||||
transformed = self._transform_number(numeric_value)
|
||||
if transformed is not None:
|
||||
transformed_result[parameter.name] = transformed
|
||||
transformed = self._transform_number(param_value)
|
||||
if transformed is not None:
|
||||
transformed_result[parameter.name] = transformed
|
||||
elif parameter.type == SegmentType.BOOLEAN:
|
||||
if isinstance(result[parameter.name], (bool, int)):
|
||||
transformed_result[parameter.name] = bool(result[parameter.name])
|
||||
@@ -671,7 +661,7 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]):
|
||||
|
||||
return transformed_result
|
||||
|
||||
def _extract_complete_json_response(self, result: str) -> dict[str, object] | None:
|
||||
def _extract_complete_json_response(self, result: str) -> dict | None:
|
||||
"""
|
||||
Extract complete json response.
|
||||
"""
|
||||
@@ -682,11 +672,11 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]):
|
||||
json_str = extract_json(result[idx:])
|
||||
if json_str:
|
||||
with contextlib.suppress(Exception):
|
||||
return _JSON_OBJECT_ADAPTER.validate_python(json.loads(json_str))
|
||||
return cast(dict, json.loads(json_str))
|
||||
logger.info("extra error: %s", result)
|
||||
return None
|
||||
|
||||
def _extract_json_from_tool_call(self, tool_call: AssistantPromptMessage.ToolCall) -> dict[str, object] | None:
|
||||
def _extract_json_from_tool_call(self, tool_call: AssistantPromptMessage.ToolCall) -> dict | None:
|
||||
"""
|
||||
Extract json from tool call.
|
||||
"""
|
||||
@@ -700,16 +690,16 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]):
|
||||
json_str = extract_json(result[idx:])
|
||||
if json_str:
|
||||
with contextlib.suppress(Exception):
|
||||
return _JSON_OBJECT_ADAPTER.validate_python(json.loads(json_str))
|
||||
return cast(dict, json.loads(json_str))
|
||||
|
||||
logger.info("extra error: %s", result)
|
||||
return None
|
||||
|
||||
def _generate_default_result(self, data: ParameterExtractorNodeData) -> dict[str, object]:
|
||||
def _generate_default_result(self, data: ParameterExtractorNodeData):
|
||||
"""
|
||||
Generate default result.
|
||||
"""
|
||||
result: dict[str, object] = {}
|
||||
result: dict[str, Any] = {}
|
||||
for parameter in data.parameters:
|
||||
if parameter.type == "number":
|
||||
result[parameter.name] = 0
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any, Literal, Union
|
||||
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter, field_validator
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic_core.core_schema import ValidationInfo
|
||||
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from dify_graph.entities.base_node_data import BaseNodeData
|
||||
from dify_graph.enums import BuiltinNodeTypes, NodeType
|
||||
|
||||
ToolConfigurationValue: TypeAlias = str | int | float | bool
|
||||
ToolConfigurations: TypeAlias = dict[str, ToolConfigurationValue]
|
||||
ToolInputConstantValue: TypeAlias = str | int | float | bool | dict[str, object] | list[object] | None
|
||||
VariableSelector: TypeAlias = list[str]
|
||||
|
||||
_TOOL_CONFIGURATIONS_ADAPTER: TypeAdapter[ToolConfigurations] = TypeAdapter(ToolConfigurations)
|
||||
_TOOL_INPUT_MIXED_ADAPTER: TypeAdapter[str] = TypeAdapter(str)
|
||||
_TOOL_INPUT_CONSTANT_ADAPTER: TypeAdapter[ToolInputConstantValue] = TypeAdapter(ToolInputConstantValue)
|
||||
_VARIABLE_SELECTOR_ADAPTER: TypeAdapter[VariableSelector] = TypeAdapter(VariableSelector)
|
||||
|
||||
|
||||
class ToolEntity(BaseModel):
|
||||
provider_id: str
|
||||
@@ -26,41 +14,52 @@ class ToolEntity(BaseModel):
|
||||
provider_name: str # redundancy
|
||||
tool_name: str
|
||||
tool_label: str # redundancy
|
||||
tool_configurations: ToolConfigurations
|
||||
tool_configurations: dict[str, Any]
|
||||
credential_id: str | None = None
|
||||
plugin_unique_identifier: str | None = None # redundancy
|
||||
|
||||
@field_validator("tool_configurations", mode="before")
|
||||
@classmethod
|
||||
def validate_tool_configurations(cls, value: object, _validation_info: ValidationInfo) -> ToolConfigurations:
|
||||
return _TOOL_CONFIGURATIONS_ADAPTER.validate_python(value)
|
||||
def validate_tool_configurations(cls, value, values: ValidationInfo):
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("tool_configurations must be a dictionary")
|
||||
|
||||
for key in values.data.get("tool_configurations", {}):
|
||||
value = values.data.get("tool_configurations", {}).get(key)
|
||||
if not isinstance(value, str | int | float | bool):
|
||||
raise ValueError(f"{key} must be a string")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class ToolNodeData(BaseNodeData, ToolEntity):
|
||||
type: NodeType = BuiltinNodeTypes.TOOL
|
||||
|
||||
class ToolInput(BaseModel):
|
||||
# TODO: check this type
|
||||
value: Union[Any, list[str]]
|
||||
type: Literal["mixed", "variable", "constant"]
|
||||
value: ToolInputConstantValue | VariableSelector
|
||||
|
||||
@field_validator("value", mode="before")
|
||||
@field_validator("type", mode="before")
|
||||
@classmethod
|
||||
def validate_value(
|
||||
cls, value: object, validation_info: ValidationInfo
|
||||
) -> ToolInputConstantValue | VariableSelector:
|
||||
input_type = validation_info.data.get("type")
|
||||
if input_type == "mixed":
|
||||
return _TOOL_INPUT_MIXED_ADAPTER.validate_python(value)
|
||||
if input_type == "variable":
|
||||
return _VARIABLE_SELECTOR_ADAPTER.validate_python(value)
|
||||
if input_type == "constant":
|
||||
return _TOOL_INPUT_CONSTANT_ADAPTER.validate_python(value)
|
||||
raise ValueError(f"Unknown tool input type: {input_type}")
|
||||
def check_type(cls, value, validation_info: ValidationInfo):
|
||||
typ = value
|
||||
value = validation_info.data.get("value")
|
||||
|
||||
def require_variable_selector(self) -> VariableSelector:
|
||||
if self.type != "variable":
|
||||
raise ValueError(f"Expected variable tool input, got {self.type}")
|
||||
return _VARIABLE_SELECTOR_ADAPTER.validate_python(self.value)
|
||||
if value is None:
|
||||
return typ
|
||||
|
||||
if typ == "mixed" and not isinstance(value, str):
|
||||
raise ValueError("value must be a string")
|
||||
elif typ == "variable":
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("value must be a list")
|
||||
for val in value:
|
||||
if not isinstance(val, str):
|
||||
raise ValueError("value must be a list of strings")
|
||||
elif typ == "constant" and not isinstance(value, (allowed_types := (str, int, float, bool, dict, list))):
|
||||
raise ValueError(f"value must be one of: {', '.join(t.__name__ for t in allowed_types)}")
|
||||
return typ
|
||||
|
||||
tool_parameters: dict[str, ToolInput]
|
||||
# The version of the tool parameter.
|
||||
@@ -70,7 +69,7 @@ class ToolNodeData(BaseNodeData, ToolEntity):
|
||||
|
||||
@field_validator("tool_parameters", mode="before")
|
||||
@classmethod
|
||||
def filter_none_tool_inputs(cls, value: object) -> object:
|
||||
def filter_none_tool_inputs(cls, value):
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
|
||||
@@ -81,10 +80,8 @@ class ToolNodeData(BaseNodeData, ToolEntity):
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _has_valid_value(tool_input: object) -> bool:
|
||||
def _has_valid_value(tool_input):
|
||||
"""Check if the value is valid"""
|
||||
if isinstance(tool_input, dict):
|
||||
return tool_input.get("value") is not None
|
||||
if isinstance(tool_input, ToolNodeData.ToolInput):
|
||||
return tool_input.value is not None
|
||||
return False
|
||||
return getattr(tool_input, "value", None) is not None
|
||||
|
||||
@@ -225,11 +225,10 @@ class ToolNode(Node[ToolNodeData]):
|
||||
continue
|
||||
tool_input = node_data.tool_parameters[parameter_name]
|
||||
if tool_input.type == "variable":
|
||||
variable_selector = tool_input.require_variable_selector()
|
||||
variable = variable_pool.get(variable_selector)
|
||||
variable = variable_pool.get(tool_input.value)
|
||||
if variable is None:
|
||||
if parameter.required:
|
||||
raise ToolParameterError(f"Variable {variable_selector} does not exist")
|
||||
raise ToolParameterError(f"Variable {tool_input.value} does not exist")
|
||||
continue
|
||||
parameter_value = variable.value
|
||||
elif tool_input.type in {"mixed", "constant"}:
|
||||
@@ -511,9 +510,8 @@ class ToolNode(Node[ToolNodeData]):
|
||||
for selector in selectors:
|
||||
result[selector.variable] = selector.value_selector
|
||||
case "variable":
|
||||
variable_selector = input.require_variable_selector()
|
||||
selector_key = ".".join(variable_selector)
|
||||
result[f"#{selector_key}#"] = variable_selector
|
||||
selector_key = ".".join(input.value)
|
||||
result[f"#{selector_key}#"] = input.value
|
||||
case "constant":
|
||||
pass
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from dify_graph.node_events import NodeRunResult
|
||||
from dify_graph.nodes.base.node import Node
|
||||
from dify_graph.nodes.variable_assigner.common import helpers as common_helpers
|
||||
from dify_graph.nodes.variable_assigner.common.exc import VariableOperatorNodeError
|
||||
from dify_graph.variables import Segment, SegmentType, VariableBase
|
||||
from dify_graph.variables import SegmentType, VariableBase
|
||||
|
||||
from .node_data import VariableAssignerData, WriteMode
|
||||
|
||||
@@ -74,29 +74,23 @@ class VariableAssignerNode(Node[VariableAssignerData]):
|
||||
if not isinstance(original_variable, VariableBase):
|
||||
raise VariableOperatorNodeError("assigned variable not found")
|
||||
|
||||
income_value: Segment
|
||||
updated_variable: VariableBase
|
||||
match self.node_data.write_mode:
|
||||
case WriteMode.OVER_WRITE:
|
||||
input_value = self.graph_runtime_state.variable_pool.get(self.node_data.input_variable_selector)
|
||||
if input_value is None:
|
||||
income_value = self.graph_runtime_state.variable_pool.get(self.node_data.input_variable_selector)
|
||||
if not income_value:
|
||||
raise VariableOperatorNodeError("input value not found")
|
||||
income_value = input_value
|
||||
updated_variable = original_variable.model_copy(update={"value": income_value.value})
|
||||
|
||||
case WriteMode.APPEND:
|
||||
input_value = self.graph_runtime_state.variable_pool.get(self.node_data.input_variable_selector)
|
||||
if input_value is None:
|
||||
income_value = self.graph_runtime_state.variable_pool.get(self.node_data.input_variable_selector)
|
||||
if not income_value:
|
||||
raise VariableOperatorNodeError("input value not found")
|
||||
income_value = input_value
|
||||
updated_value = original_variable.value + [income_value.value]
|
||||
updated_variable = original_variable.model_copy(update={"value": updated_value})
|
||||
|
||||
case WriteMode.CLEAR:
|
||||
income_value = SegmentType.get_zero_value(original_variable.value_type)
|
||||
updated_variable = original_variable.model_copy(update={"value": income_value.to_object()})
|
||||
case _:
|
||||
raise VariableOperatorNodeError(f"unsupported write mode: {self.node_data.write_mode}")
|
||||
|
||||
# Over write the variable.
|
||||
self.graph_runtime_state.variable_pool.add(assigned_variable_selector, updated_variable)
|
||||
|
||||
@@ -66,11 +66,6 @@ class GraphExecutionProtocol(Protocol):
|
||||
exceptions_count: int
|
||||
pause_reasons: list[PauseReason]
|
||||
|
||||
@property
|
||||
def node_executions(self) -> Mapping[str, NodeExecutionProtocol]:
|
||||
"""Return node execution state keyed by node id for resume support."""
|
||||
...
|
||||
|
||||
def start(self) -> None:
|
||||
"""Transition execution into the running state."""
|
||||
...
|
||||
@@ -96,12 +91,6 @@ class GraphExecutionProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
class NodeExecutionProtocol(Protocol):
|
||||
"""Structural interface for per-node execution state used during resume."""
|
||||
|
||||
execution_id: str | None
|
||||
|
||||
|
||||
class ResponseStreamCoordinatorProtocol(Protocol):
|
||||
"""Structural interface for response stream coordinator."""
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.13.0"
|
||||
version = "1.13.1"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@@ -13,6 +13,21 @@ controllers/console/workspace/trigger_providers.py
|
||||
controllers/service_api/app/annotation.py
|
||||
controllers/web/workflow_events.py
|
||||
core/agent/fc_agent_runner.py
|
||||
core/app/apps/advanced_chat/app_generator.py
|
||||
core/app/apps/advanced_chat/app_runner.py
|
||||
core/app/apps/advanced_chat/generate_task_pipeline.py
|
||||
core/app/apps/agent_chat/app_generator.py
|
||||
core/app/apps/base_app_generate_response_converter.py
|
||||
core/app/apps/base_app_generator.py
|
||||
core/app/apps/chat/app_generator.py
|
||||
core/app/apps/common/workflow_response_converter.py
|
||||
core/app/apps/completion/app_generator.py
|
||||
core/app/apps/pipeline/pipeline_generator.py
|
||||
core/app/apps/pipeline/pipeline_runner.py
|
||||
core/app/apps/workflow/app_generator.py
|
||||
core/app/apps/workflow/app_runner.py
|
||||
core/app/apps/workflow/generate_task_pipeline.py
|
||||
core/app/apps/workflow_app_runner.py
|
||||
core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py
|
||||
core/datasource/datasource_manager.py
|
||||
core/external_data_tool/api/api.py
|
||||
@@ -93,6 +108,35 @@ core/tools/workflow_as_tool/provider.py
|
||||
core/trigger/debug/event_selectors.py
|
||||
core/trigger/entities/entities.py
|
||||
core/trigger/provider.py
|
||||
core/workflow/workflow_entry.py
|
||||
dify_graph/entities/workflow_execution.py
|
||||
dify_graph/file/file_manager.py
|
||||
dify_graph/graph_engine/error_handler.py
|
||||
dify_graph/graph_engine/layers/execution_limits.py
|
||||
dify_graph/nodes/agent/agent_node.py
|
||||
dify_graph/nodes/base/node.py
|
||||
dify_graph/nodes/code/code_node.py
|
||||
dify_graph/nodes/datasource/datasource_node.py
|
||||
dify_graph/nodes/document_extractor/node.py
|
||||
dify_graph/nodes/human_input/human_input_node.py
|
||||
dify_graph/nodes/if_else/if_else_node.py
|
||||
dify_graph/nodes/iteration/iteration_node.py
|
||||
dify_graph/nodes/knowledge_index/knowledge_index_node.py
|
||||
core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py
|
||||
dify_graph/nodes/list_operator/node.py
|
||||
dify_graph/nodes/llm/node.py
|
||||
dify_graph/nodes/loop/loop_node.py
|
||||
dify_graph/nodes/parameter_extractor/parameter_extractor_node.py
|
||||
dify_graph/nodes/question_classifier/question_classifier_node.py
|
||||
dify_graph/nodes/start/start_node.py
|
||||
dify_graph/nodes/template_transform/template_transform_node.py
|
||||
dify_graph/nodes/tool/tool_node.py
|
||||
dify_graph/nodes/trigger_plugin/trigger_event_node.py
|
||||
dify_graph/nodes/trigger_schedule/trigger_schedule_node.py
|
||||
dify_graph/nodes/trigger_webhook/node.py
|
||||
dify_graph/nodes/variable_aggregator/variable_aggregator_node.py
|
||||
dify_graph/nodes/variable_assigner/v1/node.py
|
||||
dify_graph/nodes/variable_assigner/v2/node.py
|
||||
extensions/logstore/repositories/logstore_api_workflow_run_repository.py
|
||||
extensions/otel/instrumentation.py
|
||||
extensions/otel/runtime.py
|
||||
|
||||
@@ -23,6 +23,7 @@ def mock_jsonify():
|
||||
|
||||
class DummyWebhookTrigger:
|
||||
webhook_id = "wh-1"
|
||||
webhook_url = "http://localhost:5001/triggers/webhook/wh-1"
|
||||
tenant_id = "tenant-1"
|
||||
app_id = "app-1"
|
||||
node_id = "node-1"
|
||||
@@ -104,7 +105,32 @@ class TestHandleWebhookDebug:
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
|
||||
@patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1})
|
||||
@patch.object(module.TriggerDebugEventBus, "dispatch")
|
||||
@patch.object(module.TriggerDebugEventBus, "dispatch", return_value=0)
|
||||
def test_debug_requires_active_listener(
|
||||
self,
|
||||
mock_dispatch,
|
||||
mock_build_inputs,
|
||||
mock_extract,
|
||||
mock_get,
|
||||
):
|
||||
mock_get.return_value = (DummyWebhookTrigger(), None, "node_config")
|
||||
mock_extract.return_value = {"method": "POST"}
|
||||
|
||||
response, status = module.handle_webhook_debug("wh-1")
|
||||
|
||||
assert status == 409
|
||||
assert response["error"] == "No active debug listener"
|
||||
assert response["message"] == (
|
||||
"The webhook debug URL only works while the Variable Inspector is listening. "
|
||||
"Use the published webhook URL to execute the workflow in Celery."
|
||||
)
|
||||
assert response["execution_url"] == DummyWebhookTrigger.webhook_url
|
||||
mock_dispatch.assert_called_once()
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
|
||||
@patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1})
|
||||
@patch.object(module.TriggerDebugEventBus, "dispatch", return_value=1)
|
||||
@patch.object(module.WebhookService, "generate_webhook_response")
|
||||
def test_debug_success(
|
||||
self,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -33,8 +33,8 @@ def _make_graph_state():
|
||||
],
|
||||
)
|
||||
def test_run_uses_single_node_execution_branch(
|
||||
single_iteration_run: WorkflowAppGenerateEntity.SingleIterationRunEntity | None,
|
||||
single_loop_run: WorkflowAppGenerateEntity.SingleLoopRunEntity | None,
|
||||
single_iteration_run: Any,
|
||||
single_loop_run: Any,
|
||||
) -> None:
|
||||
app_config = MagicMock()
|
||||
app_config.app_id = "app"
|
||||
@@ -130,23 +130,10 @@ def test_single_node_run_validates_target_node_config(monkeypatch) -> None:
|
||||
"break_conditions": [],
|
||||
"logical_operator": "and",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "other-node",
|
||||
"data": {
|
||||
"type": "answer",
|
||||
"title": "Answer",
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"source": "other-node",
|
||||
"target": "loop-node",
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
original_graph_dict = deepcopy(workflow.graph_dict)
|
||||
|
||||
_, _, graph_runtime_state = _make_graph_state()
|
||||
seen_configs: list[object] = []
|
||||
@@ -156,19 +143,13 @@ def test_single_node_run_validates_target_node_config(monkeypatch) -> None:
|
||||
seen_configs.append(value)
|
||||
return original_validate_python(value)
|
||||
|
||||
class FakeNodeClass:
|
||||
@staticmethod
|
||||
def extract_variable_selector_to_variable_mapping(**_kwargs):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(NodeConfigDictAdapter, "validate_python", record_validate_python)
|
||||
|
||||
with (
|
||||
patch("core.app.apps.workflow_app_runner.DifyNodeFactory"),
|
||||
patch("core.app.apps.workflow_app_runner.Graph.init", return_value=MagicMock()) as graph_init,
|
||||
patch("core.app.apps.workflow_app_runner.Graph.init", return_value=MagicMock()),
|
||||
patch("core.app.apps.workflow_app_runner.load_into_variable_pool"),
|
||||
patch("core.app.apps.workflow_app_runner.WorkflowEntry.mapping_user_inputs_to_variable_pool"),
|
||||
patch("core.app.apps.workflow_app_runner.resolve_workflow_node_class", return_value=FakeNodeClass),
|
||||
):
|
||||
runner._get_graph_and_variable_pool_for_single_node_run(
|
||||
workflow=workflow,
|
||||
@@ -180,8 +161,3 @@ def test_single_node_run_validates_target_node_config(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert seen_configs == [workflow.graph_dict["nodes"][0]]
|
||||
assert workflow.graph_dict == original_graph_dict
|
||||
graph_config = graph_init.call_args.kwargs["graph_config"]
|
||||
assert graph_config is not workflow.graph_dict
|
||||
assert graph_config["nodes"] == [workflow.graph_dict["nodes"][0]]
|
||||
assert graph_config["edges"] == []
|
||||
|
||||
2
api/uv.lock
generated
2
api/uv.lock
generated
@@ -1533,7 +1533,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.13.0"
|
||||
version = "1.13.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.0
|
||||
image: langgenius/dify-api:1.13.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -63,7 +63,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.0
|
||||
image: langgenius/dify-api:1.13.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -102,7 +102,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.0
|
||||
image: langgenius/dify-api:1.13.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.0
|
||||
image: langgenius/dify-web:1.13.1
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
@@ -728,7 +728,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.0
|
||||
image: langgenius/dify-api:1.13.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -770,7 +770,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.0
|
||||
image: langgenius/dify-api:1.13.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -809,7 +809,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.0
|
||||
image: langgenius/dify-api:1.13.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -839,7 +839,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.0
|
||||
image: langgenius/dify-web:1.13.1
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('dataset-config/params-config', () => {
|
||||
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
|
||||
const dialogScope = within(dialog)
|
||||
|
||||
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
|
||||
const incrementButtons = dialogScope.getAllByRole('button', { name: /increment/i })
|
||||
await user.click(incrementButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -213,7 +213,7 @@ describe('dataset-config/params-config', () => {
|
||||
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
|
||||
const dialogScope = within(dialog)
|
||||
|
||||
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
|
||||
const incrementButtons = dialogScope.getAllByRole('button', { name: /increment/i })
|
||||
await user.click(incrementButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -212,7 +212,7 @@ describe('RetrievalSection', () => {
|
||||
currentDataset={dataset}
|
||||
/>,
|
||||
)
|
||||
const [topKIncrement] = screen.getAllByLabelText('increment')
|
||||
const [topKIncrement] = screen.getAllByRole('button', { name: /increment/i })
|
||||
await userEvent.click(topKIncrement)
|
||||
|
||||
// Assert
|
||||
@@ -267,7 +267,7 @@ describe('RetrievalSection', () => {
|
||||
docLink={path => path || ''}
|
||||
/>,
|
||||
)
|
||||
const [topKIncrement] = screen.getAllByLabelText('increment')
|
||||
const [topKIncrement] = screen.getAllByRole('button', { name: /increment/i })
|
||||
await userEvent.click(topKIncrement)
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -27,7 +27,21 @@ describe('NumberInputField', () => {
|
||||
|
||||
it('should update value when users click increment', () => {
|
||||
render(<NumberInputField label="Count" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'increment' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.increment' }))
|
||||
expect(mockField.handleChange).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('should reset field value when users clear the input', () => {
|
||||
render(<NumberInputField label="Count" />)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } })
|
||||
expect(mockField.handleChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should clamp out-of-range edits before updating field state', () => {
|
||||
render(<NumberInputField label="Count" min={0} max={10} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '12' } })
|
||||
|
||||
expect(mockField.handleChange).toHaveBeenLastCalledWith(10)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,52 @@
|
||||
import type { InputNumberProps } from '../../../input-number'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { NumberFieldInputProps, NumberFieldRootProps, NumberFieldSize } from '../../../ui/number-field'
|
||||
import type { LabelProps } from '../label'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useFieldContext } from '../..'
|
||||
import { InputNumber } from '../../../input-number'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
NumberFieldUnit,
|
||||
} from '../../../ui/number-field'
|
||||
import Label from '../label'
|
||||
|
||||
type TextFieldProps = {
|
||||
type NumberInputFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
} & Omit<InputNumberProps, 'id' | 'value' | 'onChange' | 'onBlur'>
|
||||
inputClassName?: string
|
||||
unit?: ReactNode
|
||||
size?: NumberFieldSize
|
||||
} & Omit<NumberFieldRootProps, 'children' | 'className' | 'id' | 'value' | 'defaultValue' | 'onValueChange'> & Omit<NumberFieldInputProps, 'children' | 'size' | 'onBlur' | 'className' | 'onChange'>
|
||||
|
||||
const NumberInputField = ({
|
||||
label,
|
||||
labelOptions,
|
||||
className,
|
||||
...inputProps
|
||||
}: TextFieldProps) => {
|
||||
inputClassName,
|
||||
unit,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberInputFieldProps) => {
|
||||
const field = useFieldContext<number>()
|
||||
const {
|
||||
value: _value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
disabled,
|
||||
readOnly,
|
||||
required,
|
||||
name: _name,
|
||||
id: _id,
|
||||
...inputProps
|
||||
} = props
|
||||
const emptyValue = min ?? 0
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
@@ -27,13 +55,36 @@ const NumberInputField = ({
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<InputNumber
|
||||
<NumberField
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onChange={value => field.handleChange(value)}
|
||||
onBlur={field.handleBlur}
|
||||
{...inputProps}
|
||||
/>
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
onValueChange={value => field.handleChange(value ?? emptyValue)}
|
||||
>
|
||||
<NumberFieldGroup size={size}>
|
||||
<NumberFieldInput
|
||||
{...inputProps}
|
||||
size={size}
|
||||
className={inputClassName}
|
||||
onBlur={field.handleBlur}
|
||||
/>
|
||||
{Boolean(unit) && (
|
||||
<NumberFieldUnit size={size}>
|
||||
{unit}
|
||||
</NumberFieldUnit>
|
||||
)}
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size={size} />
|
||||
<NumberFieldDecrement size={size} />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InputNumber } from '../index'
|
||||
|
||||
describe('InputNumber Component', () => {
|
||||
const defaultProps = {
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders input with default values', () => {
|
||||
render(<InputNumber {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles increment button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={5} />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
await user.click(incrementBtn)
|
||||
expect(onChange).toHaveBeenCalledWith(6)
|
||||
})
|
||||
|
||||
it('handles decrement button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={5} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
await user.click(decrementBtn)
|
||||
expect(onChange).toHaveBeenCalledWith(4)
|
||||
})
|
||||
|
||||
it('respects max value constraint', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={10} max={10} />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
await user.click(incrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('respects min value constraint', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={0} min={0} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
await user.click(decrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles direct input changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '42' } })
|
||||
expect(onChange).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('handles empty input', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={1} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('does not call onChange when input is not parseable', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call onChange when direct input exceeds range', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} max={10} min={0} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '11' } })
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses default value when increment and decrement are clicked without value prop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} defaultValue={7} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /increment/i }))
|
||||
await user.click(screen.getByRole('button', { name: /decrement/i }))
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, 7)
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, 7)
|
||||
})
|
||||
|
||||
it('falls back to zero when controls are used without value and defaultValue', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /increment/i }))
|
||||
await user.click(screen.getByRole('button', { name: /decrement/i }))
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, 0)
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, 0)
|
||||
})
|
||||
|
||||
it('displays unit when provided', () => {
|
||||
const onChange = vi.fn()
|
||||
const unit = 'px'
|
||||
render(<InputNumber onChange={onChange} unit={unit} />)
|
||||
expect(screen.getByText(unit)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables controls when disabled prop is true', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} disabled />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
expect(input).toBeDisabled()
|
||||
expect(incrementBtn).toBeDisabled()
|
||||
expect(decrementBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not change value when disabled controls are clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const { getByRole } = render(<InputNumber onChange={onChange} disabled value={5} />)
|
||||
|
||||
const incrementBtn = getByRole('button', { name: /increment/i })
|
||||
const decrementBtn = getByRole('button', { name: /decrement/i })
|
||||
|
||||
expect(incrementBtn).toBeDisabled()
|
||||
expect(decrementBtn).toBeDisabled()
|
||||
|
||||
await user.click(incrementBtn)
|
||||
await user.click(decrementBtn)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps increment guard when disabled even if button is force-clickable', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} disabled value={5} />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
// Remove native disabled to force event dispatch and hit component-level guard.
|
||||
incrementBtn.removeAttribute('disabled')
|
||||
fireEvent.click(incrementBtn)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps decrement guard when disabled even if button is force-clickable', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} disabled value={5} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
// Remove native disabled to force event dispatch and hit component-level guard.
|
||||
decrementBtn.removeAttribute('disabled')
|
||||
fireEvent.click(decrementBtn)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies large-size classes for control buttons', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} size="large" />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
expect(incrementBtn).toHaveClass('pt-1.5')
|
||||
expect(decrementBtn).toHaveClass('pb-1.5')
|
||||
})
|
||||
|
||||
it('prevents increment beyond max with custom amount', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={8} max={10} amount={5} />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
await user.click(incrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses fallback step guard when step is any', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={10} max={10} step="any" />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
await user.click(incrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents decrement below min with custom amount', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={2} min={0} amount={5} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
await user.click(decrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('increments when value with custom amount stays within bounds', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={5} max={10} amount={3} />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
await user.click(incrementBtn)
|
||||
expect(onChange).toHaveBeenCalledWith(8)
|
||||
})
|
||||
|
||||
it('decrements when value with custom amount stays within bounds', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={5} min={0} amount={3} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
await user.click(decrementBtn)
|
||||
expect(onChange).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('validates input against max constraint', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} max={10} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '15' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates input against min constraint', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={5} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '2' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts input within min and max constraints', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={0} max={100} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '50' } })
|
||||
expect(onChange).toHaveBeenCalledWith(50)
|
||||
})
|
||||
|
||||
it('handles negative min and max values', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={-10} max={10} value={0} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
await user.click(decrementBtn)
|
||||
expect(onChange).toHaveBeenCalledWith(-1)
|
||||
})
|
||||
|
||||
it('prevents decrement below negative min', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={-10} value={-10} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
await user.click(decrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies wrapClassName to outer div', () => {
|
||||
const onChange = vi.fn()
|
||||
const wrapClassName = 'custom-wrap-class'
|
||||
render(<InputNumber onChange={onChange} wrapClassName={wrapClassName} />)
|
||||
const wrapper = screen.getByTestId('input-number-wrapper')
|
||||
expect(wrapper).toHaveClass(wrapClassName)
|
||||
})
|
||||
|
||||
it('applies wrapperClassName to outer div for Input compatibility', () => {
|
||||
const onChange = vi.fn()
|
||||
const wrapperClassName = 'custom-input-wrapper'
|
||||
render(<InputNumber onChange={onChange} wrapperClassName={wrapperClassName} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const wrapper = screen.getByTestId('input-number-wrapper')
|
||||
|
||||
expect(input).not.toHaveAttribute('wrapperClassName')
|
||||
expect(wrapper).toHaveClass(wrapperClassName)
|
||||
})
|
||||
|
||||
it('applies styleCss to the input element', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} styleCss={{ color: 'red' }} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveStyle({ color: 'rgb(255, 0, 0)' })
|
||||
})
|
||||
|
||||
it('applies controlWrapClassName to control buttons container', () => {
|
||||
const onChange = vi.fn()
|
||||
const controlWrapClassName = 'custom-control-wrap'
|
||||
render(<InputNumber onChange={onChange} controlWrapClassName={controlWrapClassName} />)
|
||||
const controlDiv = screen.getByTestId('input-number-controls')
|
||||
expect(controlDiv).toHaveClass(controlWrapClassName)
|
||||
})
|
||||
|
||||
it('applies controlClassName to individual control buttons', () => {
|
||||
const onChange = vi.fn()
|
||||
const controlClassName = 'custom-control'
|
||||
render(<InputNumber onChange={onChange} controlClassName={controlClassName} />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
expect(incrementBtn).toHaveClass(controlClassName)
|
||||
expect(decrementBtn).toHaveClass(controlClassName)
|
||||
})
|
||||
|
||||
it('applies regular-size classes for control buttons when size is regular', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} size="regular" />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
expect(incrementBtn).toHaveClass('pt-1')
|
||||
expect(decrementBtn).toHaveClass('pb-1')
|
||||
})
|
||||
|
||||
it('handles zero as a valid input', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={-5} max={5} value={1} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '0' } })
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('prevents exact max boundary increment', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={10} max={10} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /increment/i }))
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents exact min boundary decrement', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={0} min={0} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /decrement/i }))
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,479 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useState } from 'react'
|
||||
import { InputNumber } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/InputNumber',
|
||||
component: InputNumber,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Number input component with increment/decrement buttons. Supports min/max constraints, custom step amounts, and units display.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'number',
|
||||
description: 'Current value',
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['regular', 'large'],
|
||||
description: 'Input size',
|
||||
},
|
||||
min: {
|
||||
control: 'number',
|
||||
description: 'Minimum value',
|
||||
},
|
||||
max: {
|
||||
control: 'number',
|
||||
description: 'Maximum value',
|
||||
},
|
||||
amount: {
|
||||
control: 'number',
|
||||
description: 'Step amount for increment/decrement',
|
||||
},
|
||||
unit: {
|
||||
control: 'text',
|
||||
description: 'Unit text displayed (e.g., "px", "ms")',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
defaultValue: {
|
||||
control: 'number',
|
||||
description: 'Default value when undefined',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onChange: (value) => {
|
||||
console.log('Value changed:', value)
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof InputNumber>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const InputNumberDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value ?? 0)
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<InputNumber
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue)
|
||||
console.log('Value changed:', newValue)
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Current value:
|
||||
{' '}
|
||||
<span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 0,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Large size
|
||||
export const LargeSize: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 10,
|
||||
size: 'large',
|
||||
},
|
||||
}
|
||||
|
||||
// With min/max constraints
|
||||
export const WithMinMax: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 5,
|
||||
min: 0,
|
||||
max: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// With custom step amount
|
||||
export const CustomStepAmount: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
amount: 5,
|
||||
min: 0,
|
||||
max: 100,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// With unit
|
||||
export const WithUnit: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 100,
|
||||
unit: 'px',
|
||||
min: 0,
|
||||
max: 1000,
|
||||
amount: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 42,
|
||||
disabled: true,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Decimal values
|
||||
export const DecimalValues: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 2.5,
|
||||
amount: 0.5,
|
||||
min: 0,
|
||||
max: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Negative values allowed
|
||||
export const NegativeValues: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
amount: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Size comparison
|
||||
const SizeComparisonDemo = () => {
|
||||
const [regularValue, setRegularValue] = useState(10)
|
||||
const [largeValue, setLargeValue] = useState(20)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" style={{ width: '300px' }}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Regular Size</label>
|
||||
<InputNumber
|
||||
size="regular"
|
||||
value={regularValue}
|
||||
onChange={setRegularValue}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Large Size</label>
|
||||
<InputNumber
|
||||
size="large"
|
||||
value={largeValue}
|
||||
onChange={setLargeValue}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => <SizeComparisonDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Font size picker
|
||||
const FontSizePickerDemo = () => {
|
||||
const [fontSize, setFontSize] = useState(16)
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Font Size</label>
|
||||
<InputNumber
|
||||
value={fontSize}
|
||||
onChange={setFontSize}
|
||||
min={8}
|
||||
max={72}
|
||||
amount={2}
|
||||
unit="px"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<p style={{ fontSize: `${fontSize}px` }} className="text-gray-900">
|
||||
Preview Text
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FontSizePicker: Story = {
|
||||
render: () => <FontSizePickerDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Quantity selector
|
||||
const QuantitySelectorDemo = () => {
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const pricePerItem = 29.99
|
||||
const total = (quantity * pricePerItem).toFixed(2)
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Product Name</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
$
|
||||
{pricePerItem}
|
||||
{' '}
|
||||
each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Quantity</label>
|
||||
<InputNumber
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
min={1}
|
||||
max={99}
|
||||
amount={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Total</span>
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
$
|
||||
{total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const QuantitySelector: Story = {
|
||||
render: () => <QuantitySelectorDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Timer settings
|
||||
const TimerSettingsDemo = () => {
|
||||
const [hours, setHours] = useState(0)
|
||||
const [minutes, setMinutes] = useState(15)
|
||||
const [seconds, setSeconds] = useState(30)
|
||||
|
||||
const totalSeconds = hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Timer Configuration</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Hours</label>
|
||||
<InputNumber
|
||||
value={hours}
|
||||
onChange={setHours}
|
||||
min={0}
|
||||
max={23}
|
||||
unit="h"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Minutes</label>
|
||||
<InputNumber
|
||||
value={minutes}
|
||||
onChange={setMinutes}
|
||||
min={0}
|
||||
max={59}
|
||||
unit="m"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Seconds</label>
|
||||
<InputNumber
|
||||
value={seconds}
|
||||
onChange={setSeconds}
|
||||
min={0}
|
||||
max={59}
|
||||
unit="s"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-blue-50 p-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total duration:
|
||||
{' '}
|
||||
<span className="font-semibold">
|
||||
{totalSeconds}
|
||||
{' '}
|
||||
seconds
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TimerSettings: Story = {
|
||||
render: () => <TimerSettingsDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Animation settings
|
||||
const AnimationSettingsDemo = () => {
|
||||
const [duration, setDuration] = useState(300)
|
||||
const [delay, setDelay] = useState(0)
|
||||
const [iterations, setIterations] = useState(1)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Animation Properties</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Duration</label>
|
||||
<InputNumber
|
||||
value={duration}
|
||||
onChange={setDuration}
|
||||
min={0}
|
||||
max={5000}
|
||||
amount={50}
|
||||
unit="ms"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Delay</label>
|
||||
<InputNumber
|
||||
value={delay}
|
||||
onChange={setDelay}
|
||||
min={0}
|
||||
max={2000}
|
||||
amount={50}
|
||||
unit="ms"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Iterations</label>
|
||||
<InputNumber
|
||||
value={iterations}
|
||||
onChange={setIterations}
|
||||
min={1}
|
||||
max={10}
|
||||
amount={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-gray-50 p-4">
|
||||
<div className="font-mono text-xs text-gray-700">
|
||||
animation:
|
||||
{' '}
|
||||
{duration}
|
||||
ms
|
||||
{' '}
|
||||
{delay}
|
||||
ms
|
||||
{' '}
|
||||
{iterations}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AnimationSettings: Story = {
|
||||
render: () => <AnimationSettingsDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Temperature control
|
||||
const TemperatureControlDemo = () => {
|
||||
const [temperature, setTemperature] = useState(20)
|
||||
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Temperature Control</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Set Temperature</label>
|
||||
<InputNumber
|
||||
size="large"
|
||||
value={temperature}
|
||||
onChange={setTemperature}
|
||||
min={16}
|
||||
max={30}
|
||||
amount={0.5}
|
||||
unit="°C"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-lg bg-gray-50 p-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Celsius</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">
|
||||
{temperature}
|
||||
°C
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Fahrenheit</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">
|
||||
{fahrenheit}
|
||||
°F
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TemperatureControl: Story = {
|
||||
render: () => <TemperatureControlDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 10,
|
||||
size: 'regular',
|
||||
min: 0,
|
||||
max: 100,
|
||||
amount: 1,
|
||||
unit: '',
|
||||
disabled: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import type { NumberFieldRoot as BaseNumberFieldRoot } from '@base-ui/react/number-field'
|
||||
import type { CSSProperties, FC, InputHTMLAttributes } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
NumberFieldUnit,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type InputNumberInputProps = Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
'defaultValue' | 'max' | 'min' | 'onChange' | 'size' | 'type' | 'value'
|
||||
>
|
||||
|
||||
export type InputNumberProps = InputNumberInputProps & {
|
||||
unit?: string
|
||||
value?: number
|
||||
onChange: (value: number) => void
|
||||
amount?: number
|
||||
size?: 'regular' | 'large'
|
||||
max?: number
|
||||
min?: number
|
||||
step?: number | 'any'
|
||||
defaultValue?: number
|
||||
disabled?: boolean
|
||||
wrapClassName?: string
|
||||
wrapperClassName?: string
|
||||
styleCss?: CSSProperties
|
||||
controlWrapClassName?: string
|
||||
controlClassName?: string
|
||||
type?: 'number'
|
||||
}
|
||||
|
||||
const STEPPER_REASONS = new Set<BaseNumberFieldRoot.ChangeEventDetails['reason']>([
|
||||
'increment-press',
|
||||
'decrement-press',
|
||||
])
|
||||
|
||||
const isValueWithinBounds = (value: number, min?: number, max?: number) => {
|
||||
if (typeof min === 'number' && value < min)
|
||||
return false
|
||||
|
||||
if (typeof max === 'number' && value > max)
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const resolveStep = (amount?: number, step?: InputNumberProps['step']) => (
|
||||
amount ?? (step === 'any' || typeof step === 'number' ? step : undefined) ?? 1
|
||||
)
|
||||
|
||||
const exceedsStepBounds = ({
|
||||
value,
|
||||
reason,
|
||||
stepAmount,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
value?: number
|
||||
reason: BaseNumberFieldRoot.ChangeEventDetails['reason']
|
||||
stepAmount: number
|
||||
min?: number
|
||||
max?: number
|
||||
}) => {
|
||||
if (typeof value !== 'number')
|
||||
return false
|
||||
|
||||
if (reason === 'increment-press' && typeof max === 'number')
|
||||
return value + stepAmount > max
|
||||
|
||||
if (reason === 'decrement-press' && typeof min === 'number')
|
||||
return value - stepAmount < min
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const InputNumber: FC<InputNumberProps> = (props) => {
|
||||
const {
|
||||
unit,
|
||||
className,
|
||||
wrapperClassName,
|
||||
styleCss,
|
||||
onChange,
|
||||
amount,
|
||||
value,
|
||||
size = 'regular',
|
||||
max,
|
||||
min,
|
||||
defaultValue,
|
||||
wrapClassName,
|
||||
controlWrapClassName,
|
||||
controlClassName,
|
||||
disabled,
|
||||
step,
|
||||
id,
|
||||
name,
|
||||
readOnly,
|
||||
required,
|
||||
type: _type,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const resolvedStep = resolveStep(amount, step)
|
||||
const stepAmount = typeof resolvedStep === 'number' ? resolvedStep : 1
|
||||
|
||||
const handleValueChange = useCallback((
|
||||
nextValue: number | null,
|
||||
eventDetails: BaseNumberFieldRoot.ChangeEventDetails,
|
||||
) => {
|
||||
if (value === undefined && STEPPER_REASONS.has(eventDetails.reason)) {
|
||||
onChange(defaultValue ?? 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (nextValue === null) {
|
||||
onChange(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (exceedsStepBounds({
|
||||
value,
|
||||
reason: eventDetails.reason,
|
||||
stepAmount,
|
||||
min,
|
||||
max,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValueWithinBounds(nextValue, min, max))
|
||||
return
|
||||
|
||||
onChange(nextValue)
|
||||
}, [defaultValue, max, min, onChange, stepAmount, value])
|
||||
|
||||
return (
|
||||
<div data-testid="input-number-wrapper" className={cn('flex w-full min-w-0', wrapClassName, wrapperClassName)}>
|
||||
<NumberField
|
||||
className="min-w-0 grow"
|
||||
value={value ?? null}
|
||||
min={min}
|
||||
max={max}
|
||||
step={resolvedStep}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
id={id}
|
||||
name={name}
|
||||
allowOutOfRange
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<NumberFieldGroup size={size}>
|
||||
<NumberFieldInput
|
||||
{...rest}
|
||||
size={size}
|
||||
style={styleCss}
|
||||
className={className}
|
||||
/>
|
||||
{unit && (
|
||||
<NumberFieldUnit size={size}>
|
||||
{unit}
|
||||
</NumberFieldUnit>
|
||||
)}
|
||||
<NumberFieldControls
|
||||
data-testid="input-number-controls"
|
||||
className={controlWrapClassName}
|
||||
>
|
||||
<NumberFieldIncrement
|
||||
aria-label="increment"
|
||||
size={size}
|
||||
className={controlClassName}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-arrow-up-s-line size-3" />
|
||||
</NumberFieldIncrement>
|
||||
<NumberFieldDecrement
|
||||
aria-label="decrement"
|
||||
size={size}
|
||||
className={controlClassName}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-arrow-down-s-line size-3" />
|
||||
</NumberFieldDecrement>
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -112,6 +112,63 @@ describe('ParamItem', () => {
|
||||
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0.8)
|
||||
})
|
||||
|
||||
it('should reset the textbox and slider when users clear the input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const StatefulParamItem = () => {
|
||||
const [value, setValue] = useState(defaultProps.value)
|
||||
|
||||
return (
|
||||
<ParamItem
|
||||
{...defaultProps}
|
||||
value={value}
|
||||
onChange={(key: string, nextValue: number) => {
|
||||
defaultProps.onChange(key, nextValue)
|
||||
setValue(nextValue)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<StatefulParamItem />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0)
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0')
|
||||
|
||||
await user.tab()
|
||||
|
||||
expect(input).toHaveValue('0')
|
||||
})
|
||||
|
||||
it('should clamp out-of-range text edits before updating state', async () => {
|
||||
const user = userEvent.setup()
|
||||
const StatefulParamItem = () => {
|
||||
const [value, setValue] = useState(defaultProps.value)
|
||||
|
||||
return (
|
||||
<ParamItem
|
||||
{...defaultProps}
|
||||
value={value}
|
||||
onChange={(key: string, nextValue: number) => {
|
||||
defaultProps.onChange(key, nextValue)
|
||||
setValue(nextValue)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<StatefulParamItem />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
await user.type(input, '1.5')
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 1)
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '100')
|
||||
})
|
||||
|
||||
it('should pass scaled value to slider when max < 5', () => {
|
||||
render(<ParamItem {...defaultProps} value={0.5} />)
|
||||
const slider = screen.getByRole('slider')
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { FC } from 'react'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { InputNumber } from '../input-number'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '../ui/number-field'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
@@ -36,7 +43,7 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="system-sm-semibold mr-1 text-text-secondary">{name}</span>
|
||||
<span className="mr-1 text-text-secondary system-sm-semibold">{name}</span>
|
||||
{!noTooltip && (
|
||||
<Tooltip
|
||||
triggerClassName="w-4 h-4 shrink-0"
|
||||
@@ -47,20 +54,22 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
|
||||
</div>
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="mr-3 flex shrink-0 items-center">
|
||||
<InputNumber
|
||||
<NumberField
|
||||
disabled={!enable}
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
amount={step}
|
||||
size="regular"
|
||||
value={value}
|
||||
onChange={(value) => {
|
||||
onChange(id, value)
|
||||
}}
|
||||
className="w-[72px]"
|
||||
/>
|
||||
onValueChange={nextValue => onChange(id, nextValue ?? min)}
|
||||
>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput size="regular" className="w-[72px]" />
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size="regular" />
|
||||
<NumberFieldDecrement size="regular" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
<div className="flex grow items-center">
|
||||
<Slider
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('Toast', () => {
|
||||
)
|
||||
|
||||
const successToast = getToastElementByMessage('Success message')
|
||||
expect(successToast).toHaveClass('z-[1101]')
|
||||
const successIcon = within(successToast).getByTestId('toast-icon-success')
|
||||
expect(successIcon).toHaveClass('text-text-success')
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ const Toast = ({
|
||||
return (
|
||||
<div className={cn(
|
||||
className,
|
||||
'fixed z-[9999] mx-8 my-4 w-[360px] grow overflow-hidden rounded-xl',
|
||||
// Keep legacy toast above highPriority modals until overlay migration completes.
|
||||
'fixed z-[1101] mx-8 my-4 w-[360px] grow overflow-hidden rounded-xl',
|
||||
'border border-components-panel-border-subtle bg-components-panel-bg-blur shadow-sm',
|
||||
'top-0',
|
||||
'right-0',
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// During migration, z-[1002] is chosen to sit above all legacy overlays
|
||||
// (Modal z-[60], PortalToFollowElem callers up to z-[1001]).
|
||||
// Once all legacy overlays are migrated, this can be reduced back to z-50.
|
||||
// Toast — z-[9999], always on top (defined in toast component)
|
||||
// Toast uses z-[1101] during migration so it stays above legacy highPriority modals.
|
||||
|
||||
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
|
||||
import * as React from 'react'
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
NumberFieldButtonProps,
|
||||
NumberFieldControlsProps,
|
||||
NumberFieldGroupProps,
|
||||
NumberFieldInputProps,
|
||||
NumberFieldUnitProps,
|
||||
} from '../index'
|
||||
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
@@ -10,104 +18,258 @@ import {
|
||||
NumberFieldUnit,
|
||||
} from '../index'
|
||||
|
||||
type RenderNumberFieldOptions = {
|
||||
defaultValue?: number
|
||||
groupProps?: Partial<NumberFieldGroupProps>
|
||||
inputProps?: Partial<NumberFieldInputProps>
|
||||
unitProps?: Partial<NumberFieldUnitProps> & { children?: ReactNode }
|
||||
controlsProps?: Partial<NumberFieldControlsProps>
|
||||
incrementProps?: Partial<NumberFieldButtonProps>
|
||||
decrementProps?: Partial<NumberFieldButtonProps>
|
||||
}
|
||||
|
||||
const renderNumberField = ({
|
||||
defaultValue = 8,
|
||||
groupProps,
|
||||
inputProps,
|
||||
unitProps,
|
||||
controlsProps,
|
||||
incrementProps,
|
||||
decrementProps,
|
||||
}: RenderNumberFieldOptions = {}) => {
|
||||
const {
|
||||
children: unitChildren = 'ms',
|
||||
...restUnitProps
|
||||
} = unitProps ?? {}
|
||||
|
||||
return render(
|
||||
<NumberField defaultValue={defaultValue}>
|
||||
<NumberFieldGroup data-testid="group" {...groupProps}>
|
||||
<NumberFieldInput
|
||||
aria-label="Amount"
|
||||
data-testid="input"
|
||||
{...inputProps}
|
||||
/>
|
||||
{unitProps && (
|
||||
<NumberFieldUnit data-testid="unit" {...restUnitProps}>
|
||||
{unitChildren}
|
||||
</NumberFieldUnit>
|
||||
)}
|
||||
{(controlsProps || incrementProps || decrementProps) && (
|
||||
<NumberFieldControls data-testid="controls" {...controlsProps}>
|
||||
<NumberFieldIncrement data-testid="increment" {...incrementProps} />
|
||||
<NumberFieldDecrement data-testid="decrement" {...decrementProps} />
|
||||
</NumberFieldControls>
|
||||
)}
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('NumberField wrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Export mapping should stay aligned with the Base UI primitive.
|
||||
describe('Exports', () => {
|
||||
it('should map NumberField to the matching base primitive root', () => {
|
||||
expect(NumberField).toBe(BaseNumberField.Root)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variants', () => {
|
||||
it('should apply regular variant classes and forward className to group and input', () => {
|
||||
render(
|
||||
<NumberField defaultValue={12}>
|
||||
<NumberFieldGroup size="regular" className="custom-group" data-testid="group">
|
||||
<NumberFieldInput
|
||||
aria-label="Regular amount"
|
||||
placeholder="Regular placeholder"
|
||||
size="regular"
|
||||
className="custom-input"
|
||||
/>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
// Group and input wrappers should preserve the design-system variants and DOM defaults.
|
||||
describe('Group and input', () => {
|
||||
it('should apply regular group classes by default and merge custom className', () => {
|
||||
renderNumberField({
|
||||
groupProps: {
|
||||
className: 'custom-group',
|
||||
},
|
||||
})
|
||||
|
||||
const group = screen.getByTestId('group')
|
||||
const input = screen.getByRole('textbox', { name: 'Regular amount' })
|
||||
|
||||
expect(group).toHaveClass('radius-md')
|
||||
expect(group).toHaveClass('custom-group')
|
||||
expect(input).toHaveAttribute('placeholder', 'Regular placeholder')
|
||||
expect(input).toHaveClass('px-3')
|
||||
expect(input).toHaveClass('py-[7px]')
|
||||
expect(input).toHaveClass('custom-input')
|
||||
})
|
||||
|
||||
it('should apply large variant classes to grouped parts when large size is provided', () => {
|
||||
render(
|
||||
<NumberField defaultValue={24}>
|
||||
<NumberFieldGroup size="large" data-testid="group">
|
||||
<NumberFieldInput aria-label="Large amount" size="large" />
|
||||
<NumberFieldUnit size="large">ms</NumberFieldUnit>
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement aria-label="Increment amount" size="large" />
|
||||
<NumberFieldDecrement aria-label="Decrement amount" size="large" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
it('should apply large group and input classes when large size is provided', () => {
|
||||
renderNumberField({
|
||||
groupProps: {
|
||||
size: 'large',
|
||||
},
|
||||
inputProps: {
|
||||
size: 'large',
|
||||
},
|
||||
})
|
||||
|
||||
const group = screen.getByTestId('group')
|
||||
const input = screen.getByRole('textbox', { name: 'Large amount' })
|
||||
const unit = screen.getByText('ms')
|
||||
const increment = screen.getByRole('button', { name: 'Increment amount' })
|
||||
const decrement = screen.getByRole('button', { name: 'Decrement amount' })
|
||||
const input = screen.getByTestId('input')
|
||||
|
||||
expect(group).toHaveClass('radius-lg')
|
||||
expect(input).toHaveClass('px-4')
|
||||
expect(input).toHaveClass('py-2')
|
||||
expect(unit).toHaveClass('flex')
|
||||
expect(unit).toHaveClass('items-center')
|
||||
expect(unit).toHaveClass('pr-2.5')
|
||||
expect(increment).toHaveClass('pt-1.5')
|
||||
expect(decrement).toHaveClass('pb-1.5')
|
||||
})
|
||||
|
||||
it('should set input defaults and forward passthrough props', () => {
|
||||
renderNumberField({
|
||||
inputProps: {
|
||||
className: 'custom-input',
|
||||
placeholder: 'Regular placeholder',
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Amount' })
|
||||
|
||||
expect(input).toHaveAttribute('autoComplete', 'off')
|
||||
expect(input).toHaveAttribute('autoCorrect', 'off')
|
||||
expect(input).toHaveAttribute('placeholder', 'Regular placeholder')
|
||||
expect(input).toBeRequired()
|
||||
expect(input).toHaveClass('px-3')
|
||||
expect(input).toHaveClass('py-[7px]')
|
||||
expect(input).toHaveClass('system-sm-regular')
|
||||
expect(input).toHaveClass('custom-input')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Passthrough props', () => {
|
||||
it('should forward passthrough props and custom classes to controls and buttons', () => {
|
||||
render(
|
||||
<NumberField defaultValue={8}>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput aria-label="Amount" size="regular" />
|
||||
<NumberFieldControls className="custom-controls" data-testid="controls">
|
||||
<NumberFieldIncrement
|
||||
aria-label="Increment"
|
||||
size="regular"
|
||||
className="custom-increment"
|
||||
data-track-id="increment-track"
|
||||
/>
|
||||
<NumberFieldDecrement
|
||||
aria-label="Decrement"
|
||||
size="regular"
|
||||
className="custom-decrement"
|
||||
data-track-id="decrement-track"
|
||||
/>
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
// Unit and controls wrappers should preserve layout tokens and HTML passthrough props.
|
||||
describe('Unit and controls', () => {
|
||||
it.each([
|
||||
['regular', 'pr-2'],
|
||||
['large', 'pr-2.5'],
|
||||
] as const)('should apply the %s unit spacing variant', (size, spacingClass) => {
|
||||
renderNumberField({
|
||||
unitProps: {
|
||||
size,
|
||||
className: 'custom-unit',
|
||||
title: `unit-${size}`,
|
||||
},
|
||||
})
|
||||
|
||||
const unit = screen.getByTestId('unit')
|
||||
|
||||
expect(unit).toHaveTextContent('ms')
|
||||
expect(unit).toHaveAttribute('title', `unit-${size}`)
|
||||
expect(unit).toHaveClass('custom-unit')
|
||||
expect(unit).toHaveClass(spacingClass)
|
||||
})
|
||||
|
||||
it('should forward passthrough props to controls', () => {
|
||||
renderNumberField({
|
||||
controlsProps: {
|
||||
className: 'custom-controls',
|
||||
title: 'controls-title',
|
||||
},
|
||||
})
|
||||
|
||||
const controls = screen.getByTestId('controls')
|
||||
const increment = screen.getByRole('button', { name: 'Increment' })
|
||||
const decrement = screen.getByRole('button', { name: 'Decrement' })
|
||||
|
||||
expect(controls).toHaveClass('border-l')
|
||||
expect(controls).toHaveAttribute('title', 'controls-title')
|
||||
expect(controls).toHaveClass('custom-controls')
|
||||
})
|
||||
})
|
||||
|
||||
// Increment and decrement buttons should preserve accessible naming, icon fallbacks, and spacing variants.
|
||||
describe('Control buttons', () => {
|
||||
it('should provide localized aria labels and default icons when labels are not provided', () => {
|
||||
renderNumberField({
|
||||
controlsProps: {},
|
||||
})
|
||||
|
||||
const increment = screen.getByRole('button', { name: 'common.operation.increment' })
|
||||
const decrement = screen.getByRole('button', { name: 'common.operation.decrement' })
|
||||
|
||||
expect(increment.querySelector('.i-ri-arrow-up-s-line')).toBeInTheDocument()
|
||||
expect(decrement.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve explicit aria labels and custom children', () => {
|
||||
renderNumberField({
|
||||
controlsProps: {},
|
||||
incrementProps: {
|
||||
'aria-label': 'Increase amount',
|
||||
'children': <span data-testid="custom-increment-icon">+</span>,
|
||||
},
|
||||
decrementProps: {
|
||||
'aria-label': 'Decrease amount',
|
||||
'children': <span data-testid="custom-decrement-icon">-</span>,
|
||||
},
|
||||
})
|
||||
|
||||
const increment = screen.getByRole('button', { name: 'Increase amount' })
|
||||
const decrement = screen.getByRole('button', { name: 'Decrease amount' })
|
||||
|
||||
expect(increment).toContainElement(screen.getByTestId('custom-increment-icon'))
|
||||
expect(decrement).toContainElement(screen.getByTestId('custom-decrement-icon'))
|
||||
expect(increment.querySelector('.i-ri-arrow-up-s-line')).not.toBeInTheDocument()
|
||||
expect(decrement.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the fallback aria labels when aria-label is omitted in props', () => {
|
||||
renderNumberField({
|
||||
controlsProps: {},
|
||||
incrementProps: {
|
||||
'aria-label': undefined,
|
||||
},
|
||||
decrementProps: {
|
||||
'aria-label': undefined,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.increment' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.decrement' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should rely on aria-labelledby when provided instead of injecting a translated aria-label', () => {
|
||||
render(
|
||||
<>
|
||||
<span id="increment-label">Increment from label</span>
|
||||
<span id="decrement-label">Decrement from label</span>
|
||||
<NumberField defaultValue={8}>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput aria-label="Amount" size="regular" />
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement aria-labelledby="increment-label" size="regular" />
|
||||
<NumberFieldDecrement aria-labelledby="decrement-label" size="regular" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</>,
|
||||
)
|
||||
|
||||
const increment = screen.getByRole('button', { name: 'Increment from label' })
|
||||
const decrement = screen.getByRole('button', { name: 'Decrement from label' })
|
||||
|
||||
expect(increment).not.toHaveAttribute('aria-label')
|
||||
expect(decrement).not.toHaveAttribute('aria-label')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['regular', 'pt-1', 'pb-1'],
|
||||
['large', 'pt-1.5', 'pb-1.5'],
|
||||
] as const)('should apply the %s control button compound spacing classes', (size, incrementClass, decrementClass) => {
|
||||
renderNumberField({
|
||||
controlsProps: {},
|
||||
incrementProps: {
|
||||
size,
|
||||
className: 'custom-increment',
|
||||
},
|
||||
decrementProps: {
|
||||
size,
|
||||
className: 'custom-decrement',
|
||||
title: `decrement-${size}`,
|
||||
},
|
||||
})
|
||||
|
||||
const increment = screen.getByTestId('increment')
|
||||
const decrement = screen.getByTestId('decrement')
|
||||
|
||||
expect(increment).toHaveClass(incrementClass)
|
||||
expect(increment).toHaveClass('custom-increment')
|
||||
expect(increment).toHaveAttribute('data-track-id', 'increment-track')
|
||||
expect(decrement).toHaveClass(decrementClass)
|
||||
expect(decrement).toHaveClass('custom-decrement')
|
||||
expect(decrement).toHaveAttribute('data-track-id', 'decrement-track')
|
||||
expect(decrement).toHaveAttribute('title', `decrement-${size}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
285
web/app/components/base/ui/number-field/index.stories.tsx
Normal file
285
web/app/components/base/ui/number-field/index.stories.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useId, useState } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
NumberFieldUnit,
|
||||
} from '.'
|
||||
|
||||
type DemoFieldProps = {
|
||||
label: string
|
||||
helperText: string
|
||||
placeholder: string
|
||||
size: 'regular' | 'large'
|
||||
unit?: string
|
||||
defaultValue?: number | null
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
showCurrentValue?: boolean
|
||||
widthClassName?: string
|
||||
formatValue?: (value: number | null) => string
|
||||
}
|
||||
|
||||
const formatNumericValue = (value: number | null, unit?: string) => {
|
||||
if (value === null)
|
||||
return 'Empty'
|
||||
|
||||
if (!unit)
|
||||
return String(value)
|
||||
|
||||
return `${value} ${unit}`
|
||||
}
|
||||
|
||||
const FieldLabel = ({
|
||||
inputId,
|
||||
label,
|
||||
helperText,
|
||||
}: Pick<DemoFieldProps, 'label' | 'helperText'> & { inputId: string }) => (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={inputId} className="text-text-secondary system-sm-medium">
|
||||
{label}
|
||||
</label>
|
||||
<p className="text-text-tertiary system-xs-regular">{helperText}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DemoField = ({
|
||||
label,
|
||||
helperText,
|
||||
placeholder,
|
||||
size,
|
||||
unit,
|
||||
defaultValue,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
disabled,
|
||||
readOnly,
|
||||
showCurrentValue,
|
||||
widthClassName,
|
||||
formatValue,
|
||||
}: DemoFieldProps) => {
|
||||
const inputId = useId()
|
||||
const [value, setValue] = useState<number | null>(defaultValue ?? null)
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full max-w-80 flex-col gap-2', widthClassName)}>
|
||||
<FieldLabel inputId={inputId} label={label} helperText={helperText} />
|
||||
<NumberField
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
onValueChange={setValue}
|
||||
>
|
||||
<NumberFieldGroup size={size}>
|
||||
<NumberFieldInput
|
||||
id={inputId}
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
size={size}
|
||||
/>
|
||||
{unit && <NumberFieldUnit size={size}>{unit}</NumberFieldUnit>}
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size={size} />
|
||||
<NumberFieldDecrement size={size} />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
{showCurrentValue && (
|
||||
<p className="text-text-quaternary system-xs-regular">
|
||||
Current value:
|
||||
{' '}
|
||||
{formatValue ? formatValue(value) : formatNumericValue(value, unit)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/NumberField',
|
||||
component: NumberField,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound numeric input built on Base UI NumberField. Stories explicitly enumerate the shipped CVA variants, then cover realistic numeric-entry cases such as decimals, empty values, range limits, read-only, and disabled states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof NumberField>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const VariantMatrix: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-[720px] gap-6 md:grid-cols-2">
|
||||
<DemoField
|
||||
label="Top K"
|
||||
helperText="Regular size without suffix. Covers the regular group, input, and control button spacing."
|
||||
placeholder="Set top K"
|
||||
size="regular"
|
||||
defaultValue={3}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
/>
|
||||
<DemoField
|
||||
label="Score threshold"
|
||||
helperText="Regular size with a suffix so the regular unit variant is visible."
|
||||
placeholder="Set threshold"
|
||||
size="regular"
|
||||
unit="%"
|
||||
defaultValue={85}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
<DemoField
|
||||
label="Chunk overlap"
|
||||
helperText="Large size without suffix. Matches the larger dataset form treatment."
|
||||
placeholder="Set overlap"
|
||||
size="large"
|
||||
defaultValue={64}
|
||||
min={0}
|
||||
max={512}
|
||||
step={16}
|
||||
/>
|
||||
<DemoField
|
||||
label="Max segment length"
|
||||
helperText="Large size with suffix so the large unit variant is also enumerated."
|
||||
placeholder="Set length"
|
||||
size="large"
|
||||
unit="tokens"
|
||||
defaultValue={512}
|
||||
min={1}
|
||||
max={4000}
|
||||
step={32}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DecimalInputs: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-[720px] gap-6 md:grid-cols-2">
|
||||
<DemoField
|
||||
label="Score threshold"
|
||||
helperText="Two-decimal precision with a 0.01 step, like retrieval tuning fields."
|
||||
placeholder="0.00"
|
||||
size="regular"
|
||||
defaultValue={0.82}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
showCurrentValue
|
||||
formatValue={value => value === null ? 'Empty' : value.toFixed(2)}
|
||||
/>
|
||||
<DemoField
|
||||
label="Temperature"
|
||||
helperText="One-decimal stepping for generation parameters."
|
||||
placeholder="0.0"
|
||||
size="large"
|
||||
defaultValue={0.7}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
showCurrentValue
|
||||
formatValue={value => value === null ? 'Empty' : value.toFixed(1)}
|
||||
/>
|
||||
<DemoField
|
||||
label="Penalty"
|
||||
helperText="Starts empty so the placeholder and empty numeric state are both visible."
|
||||
placeholder="Optional"
|
||||
size="regular"
|
||||
defaultValue={null}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.05}
|
||||
showCurrentValue
|
||||
formatValue={value => value === null ? 'Empty' : value.toFixed(2)}
|
||||
/>
|
||||
<DemoField
|
||||
label="Latency budget"
|
||||
helperText="Decimal input with a unit suffix and larger spacing."
|
||||
placeholder="0.0"
|
||||
size="large"
|
||||
unit="s"
|
||||
defaultValue={1.5}
|
||||
min={0.5}
|
||||
max={10}
|
||||
step={0.5}
|
||||
showCurrentValue
|
||||
formatValue={value => value === null ? 'Empty' : `${value.toFixed(1)} s`}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const BoundariesAndStates: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-[720px] gap-6 md:grid-cols-2">
|
||||
<DemoField
|
||||
label="HTTP status code"
|
||||
helperText="Integer-only style usage with tighter bounds from 100 to 599."
|
||||
placeholder="200"
|
||||
size="regular"
|
||||
defaultValue={200}
|
||||
min={100}
|
||||
max={599}
|
||||
step={1}
|
||||
showCurrentValue
|
||||
/>
|
||||
<DemoField
|
||||
label="Request timeout"
|
||||
helperText="Bounded regular input with suffix, common in system settings."
|
||||
placeholder="Set timeout"
|
||||
size="regular"
|
||||
unit="ms"
|
||||
defaultValue={1200}
|
||||
min={100}
|
||||
max={10000}
|
||||
step={100}
|
||||
showCurrentValue
|
||||
/>
|
||||
<DemoField
|
||||
label="Retry count"
|
||||
helperText="Disabled state preserves the layout while switching to disabled tokens."
|
||||
placeholder="Retry count"
|
||||
size="large"
|
||||
defaultValue={5}
|
||||
min={0}
|
||||
max={10}
|
||||
step={1}
|
||||
disabled
|
||||
showCurrentValue
|
||||
/>
|
||||
<DemoField
|
||||
label="Archived score threshold"
|
||||
helperText="Read-only state keeps the same structure but removes interactive affordances."
|
||||
placeholder="0.00"
|
||||
size="large"
|
||||
unit="%"
|
||||
defaultValue={92}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
readOnly
|
||||
showCurrentValue
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import type { VariantProps } from 'class-variance-authority'
|
||||
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const NumberField = BaseNumberField.Root
|
||||
export type NumberFieldRootProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Root>
|
||||
|
||||
export const numberFieldGroupVariants = cva(
|
||||
[
|
||||
@@ -15,7 +17,7 @@ export const numberFieldGroupVariants = cva(
|
||||
'data-[focused]:border-components-input-border-active data-[focused]:bg-components-input-bg-active data-[focused]:shadow-xs',
|
||||
'data-[disabled]:cursor-not-allowed data-[disabled]:border-transparent data-[disabled]:bg-components-input-bg-disabled data-[disabled]:text-components-input-text-filled-disabled',
|
||||
'data-[disabled]:hover:border-transparent data-[disabled]:hover:bg-components-input-bg-disabled',
|
||||
'data-[readonly]:shadow-none motion-reduce:transition-none',
|
||||
'data-[readonly]:shadow-none data-[readonly]:hover:border-transparent data-[readonly]:hover:bg-components-input-bg-normal motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
@@ -29,8 +31,9 @@ export const numberFieldGroupVariants = cva(
|
||||
},
|
||||
},
|
||||
)
|
||||
export type NumberFieldSize = NonNullable<VariantProps<typeof numberFieldGroupVariants>['size']>
|
||||
|
||||
type NumberFieldGroupProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Group> & VariantProps<typeof numberFieldGroupVariants>
|
||||
export type NumberFieldGroupProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Group> & VariantProps<typeof numberFieldGroupVariants>
|
||||
|
||||
export function NumberFieldGroup({
|
||||
className,
|
||||
@@ -65,7 +68,7 @@ export const numberFieldInputVariants = cva(
|
||||
},
|
||||
)
|
||||
|
||||
type NumberFieldInputProps = Omit<React.ComponentPropsWithoutRef<typeof BaseNumberField.Input>, 'size'> & VariantProps<typeof numberFieldInputVariants>
|
||||
export type NumberFieldInputProps = Omit<React.ComponentPropsWithoutRef<typeof BaseNumberField.Input>, 'size'> & VariantProps<typeof numberFieldInputVariants>
|
||||
|
||||
export function NumberFieldInput({
|
||||
className,
|
||||
@@ -95,7 +98,7 @@ export const numberFieldUnitVariants = cva(
|
||||
},
|
||||
)
|
||||
|
||||
type NumberFieldUnitProps = React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof numberFieldUnitVariants>
|
||||
export type NumberFieldUnitProps = React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof numberFieldUnitVariants>
|
||||
|
||||
export function NumberFieldUnit({
|
||||
className,
|
||||
@@ -114,7 +117,7 @@ export const numberFieldControlsVariants = cva(
|
||||
'flex shrink-0 flex-col items-stretch border-l border-divider-subtle bg-transparent text-text-tertiary',
|
||||
)
|
||||
|
||||
type NumberFieldControlsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
export type NumberFieldControlsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function NumberFieldControls({
|
||||
className,
|
||||
@@ -130,11 +133,12 @@ export function NumberFieldControls({
|
||||
|
||||
export const numberFieldControlButtonVariants = cva(
|
||||
[
|
||||
'flex items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors',
|
||||
'flex touch-manipulation select-none items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors',
|
||||
'hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover',
|
||||
'disabled:cursor-not-allowed disabled:hover:bg-transparent',
|
||||
'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent',
|
||||
'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent',
|
||||
'focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
'disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:focus-visible:bg-transparent disabled:focus-visible:ring-0',
|
||||
'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent group-data-[disabled]/number-field:focus-visible:bg-transparent group-data-[disabled]/number-field:focus-visible:ring-0',
|
||||
'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent group-data-[readonly]/number-field:focus-visible:bg-transparent group-data-[readonly]/number-field:focus-visible:ring-0',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
@@ -182,30 +186,42 @@ type NumberFieldButtonVariantProps = Omit<
|
||||
'direction'
|
||||
>
|
||||
|
||||
type NumberFieldButtonProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Increment> & NumberFieldButtonVariantProps
|
||||
export type NumberFieldButtonProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Increment> & NumberFieldButtonVariantProps
|
||||
|
||||
export function NumberFieldIncrement({
|
||||
className,
|
||||
children,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<BaseNumberField.Increment
|
||||
className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : t('operation.increment', { ns: 'common' }))}
|
||||
className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)}
|
||||
>
|
||||
{children ?? <span aria-hidden="true" className="i-ri-arrow-up-s-line size-3" />}
|
||||
</BaseNumberField.Increment>
|
||||
)
|
||||
}
|
||||
|
||||
export function NumberFieldDecrement({
|
||||
className,
|
||||
children,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<BaseNumberField.Decrement
|
||||
className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : t('operation.decrement', { ns: 'common' }))}
|
||||
className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)}
|
||||
>
|
||||
{children ?? <span aria-hidden="true" className="i-ri-arrow-down-s-line size-3" />}
|
||||
</BaseNumberField.Decrement>
|
||||
)
|
||||
}
|
||||
|
||||
313
web/app/components/base/ui/toast/__tests__/index.spec.tsx
Normal file
313
web/app/components/base/ui/toast/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { toast, ToastHost } from '../index'
|
||||
|
||||
describe('base/ui/toast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
act(() => {
|
||||
toast.close()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
toast.close()
|
||||
vi.runOnlyPendingTimers()
|
||||
})
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Core host and manager integration.
|
||||
it('should render a toast when add is called', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Saved',
|
||||
description: 'Your changes are available now.',
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Saved')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your changes are available now.')).toBeInTheDocument()
|
||||
const viewport = screen.getByRole('region', { name: 'common.toast.notifications' })
|
||||
expect(viewport).toHaveAttribute('aria-live', 'polite')
|
||||
expect(viewport).toHaveClass('z-[1101]')
|
||||
expect(viewport.firstElementChild).toHaveClass('top-4')
|
||||
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
|
||||
expect(document.body.querySelector('button[aria-label="common.toast.close"][aria-hidden="true"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Collapsed stacks should keep multiple toast roots mounted for smooth stack animation.
|
||||
it('should keep multiple toast roots mounted in a collapsed stack', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'First toast',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('First toast')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Second toast',
|
||||
})
|
||||
toast.add({
|
||||
title: 'Third toast',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Third toast')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('dialog')).toHaveLength(3)
|
||||
expect(document.body.querySelectorAll('button[aria-label="common.toast.close"][aria-hidden="true"]')).toHaveLength(3)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByRole('region', { name: 'common.toast.notifications' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('button[aria-label="common.toast.close"][aria-hidden="true"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Base UI limit should cap the visible stack and mark overflow toasts as limited.
|
||||
it('should mark overflow toasts as limited when the stack exceeds the configured limit', async () => {
|
||||
render(<ToastHost limit={1} />)
|
||||
|
||||
act(() => {
|
||||
toast.add({ title: 'First toast' })
|
||||
toast.add({ title: 'Second toast' })
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Second toast')).toBeInTheDocument()
|
||||
expect(document.body.querySelector('[data-limited]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Closing should work through the public manager API.
|
||||
it('should close a toast when close(id) is called', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
let toastId = ''
|
||||
act(() => {
|
||||
toastId = toast.add({
|
||||
title: 'Closable',
|
||||
description: 'This toast can be removed.',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Closable')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
toast.close(toastId)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Closable')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User dismissal needs to remain accessible.
|
||||
it('should close a toast when the dismiss button is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<ToastHost />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Dismiss me',
|
||||
description: 'Manual dismissal path.',
|
||||
onClose,
|
||||
})
|
||||
})
|
||||
|
||||
fireEvent.mouseEnter(screen.getByRole('region', { name: 'common.toast.notifications' }))
|
||||
|
||||
const dismissButton = await screen.findByRole('button', { name: 'common.toast.close' })
|
||||
|
||||
act(() => {
|
||||
dismissButton.click()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Dismiss me')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Base UI default timeout should apply when no timeout is provided.
|
||||
it('should auto dismiss toasts with the Base UI default timeout', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Default timeout',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Default timeout')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4999)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Default timeout')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Default timeout')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Provider timeout should apply to all toasts when configured.
|
||||
it('should respect the host timeout configuration', async () => {
|
||||
render(<ToastHost timeout={3000} />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Configured timeout',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Configured timeout')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2999)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Configured timeout')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Configured timeout')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Callers must be able to override or disable timeout per toast.
|
||||
it('should respect custom timeout values including zero', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Custom timeout',
|
||||
timeout: 1000,
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Custom timeout')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Custom timeout')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Persistent',
|
||||
timeout: 0,
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Persistent')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10000)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Persistent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Updates should flow through the same manager state.
|
||||
it('should update an existing toast', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
let toastId = ''
|
||||
act(() => {
|
||||
toastId = toast.add({
|
||||
title: 'Loading',
|
||||
description: 'Preparing your data…',
|
||||
type: 'info',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Loading')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
toast.update(toastId, {
|
||||
title: 'Done',
|
||||
description: 'Your data is ready.',
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByText('Done')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your data is ready.')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Loading')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Action props should pass through to the Base UI action button.
|
||||
it('should render and invoke toast action props', async () => {
|
||||
const onAction = vi.fn()
|
||||
|
||||
render(<ToastHost />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Action toast',
|
||||
actionProps: {
|
||||
children: 'Undo',
|
||||
onClick: onAction,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const actionButton = await screen.findByRole('button', { name: 'Undo' })
|
||||
|
||||
act(() => {
|
||||
actionButton.click()
|
||||
})
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Promise helpers are part of the public API and need a regression test.
|
||||
it('should transition a promise toast from loading to success', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
let resolvePromise: ((value: string) => void) | undefined
|
||||
const promise = new Promise<string>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
|
||||
void act(() => toast.promise(promise, {
|
||||
loading: 'Saving…',
|
||||
success: result => ({
|
||||
title: 'Saved',
|
||||
description: result,
|
||||
type: 'success',
|
||||
}),
|
||||
error: 'Failed',
|
||||
}))
|
||||
|
||||
expect(await screen.findByText('Saving…')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise?.('Your changes are available now.')
|
||||
await promise
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Saved')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your changes are available now.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
332
web/app/components/base/ui/toast/index.stories.tsx
Normal file
332
web/app/components/base/ui/toast/index.stories.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import type { ReactNode } from 'react'
|
||||
import { toast, ToastHost } from '.'
|
||||
|
||||
const buttonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-2 text-sm text-text-secondary shadow-xs transition-colors hover:bg-state-base-hover'
|
||||
const cardClassName = 'flex min-h-[220px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6 shadow-sm shadow-shadow-shadow-3'
|
||||
|
||||
const ExampleCard = ({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
}) => {
|
||||
return (
|
||||
<section className={cardClassName}>
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold leading-6 text-text-primary">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm leading-6 text-text-secondary">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-auto flex flex-wrap gap-3">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const VariantExamples = () => {
|
||||
const createVariantToast = (type: 'success' | 'error' | 'warning' | 'info') => {
|
||||
const copy = {
|
||||
success: {
|
||||
title: 'Changes saved',
|
||||
description: 'Your draft is available to collaborators.',
|
||||
},
|
||||
error: {
|
||||
title: 'Sync failed',
|
||||
description: 'Check your network connection and try again.',
|
||||
},
|
||||
warning: {
|
||||
title: 'Storage almost full',
|
||||
description: 'You have less than 10% of workspace quota remaining.',
|
||||
},
|
||||
info: {
|
||||
title: 'Invitation sent',
|
||||
description: 'An email has been sent to the new teammate.',
|
||||
},
|
||||
} as const
|
||||
|
||||
toast.add({
|
||||
type,
|
||||
...copy[type],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ExampleCard
|
||||
eyebrow="Variants"
|
||||
title="Tone-specific notifications"
|
||||
description="Trigger the four supported tones from the shared viewport to validate iconography, gradient treatment, and copy density."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={() => createVariantToast('success')}>
|
||||
Success
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={() => createVariantToast('info')}>
|
||||
Info
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={() => createVariantToast('warning')}>
|
||||
Warning
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={() => createVariantToast('error')}>
|
||||
Error
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const StackExamples = () => {
|
||||
const createStack = () => {
|
||||
;[
|
||||
{
|
||||
type: 'info' as const,
|
||||
title: 'Generating preview',
|
||||
description: 'The first toast compresses behind the newest notification.',
|
||||
},
|
||||
{
|
||||
type: 'warning' as const,
|
||||
title: 'Review required',
|
||||
description: 'A second toast should deepen the stack without breaking spacing.',
|
||||
},
|
||||
{
|
||||
type: 'success' as const,
|
||||
title: 'Ready to publish',
|
||||
description: 'The newest toast stays frontmost while older items tuck behind it.',
|
||||
},
|
||||
].forEach(item => toast.add(item))
|
||||
}
|
||||
|
||||
const createBurst = () => {
|
||||
Array.from({ length: 5 }).forEach((_, index) => {
|
||||
toast.add({
|
||||
type: index % 2 === 0 ? 'info' : 'success',
|
||||
title: `Background task ${index + 1}`,
|
||||
description: 'Use this to inspect how the stack behaves near the host limit.',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ExampleCard
|
||||
eyebrow="Stack"
|
||||
title="Stacked viewport behavior"
|
||||
description="These examples mirror the Base UI docs pattern: repeated triggers should compress into a single shared stack at the top-right corner."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={createStack}>
|
||||
Create 3 stacked toasts
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={createBurst}>
|
||||
Stress the stack
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const PromiseExamples = () => {
|
||||
const createPromiseToast = () => {
|
||||
const request = new Promise<string>((resolve) => {
|
||||
window.setTimeout(() => resolve('The deployment is now available in production.'), 1400)
|
||||
})
|
||||
|
||||
void toast.promise(request, {
|
||||
loading: {
|
||||
type: 'info',
|
||||
title: 'Deploying workflow',
|
||||
description: 'Provisioning runtime and publishing the latest version.',
|
||||
},
|
||||
success: result => ({
|
||||
type: 'success',
|
||||
title: 'Deployment complete',
|
||||
description: result,
|
||||
}),
|
||||
error: () => ({
|
||||
type: 'error',
|
||||
title: 'Deployment failed',
|
||||
description: 'The release could not be completed.',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const createRejectingPromiseToast = () => {
|
||||
const request = new Promise<string>((_, reject) => {
|
||||
window.setTimeout(() => reject(new Error('intentional story failure')), 1200)
|
||||
})
|
||||
|
||||
void toast.promise(request, {
|
||||
loading: 'Validating model credentials…',
|
||||
success: 'Credentials verified',
|
||||
error: () => ({
|
||||
type: 'error',
|
||||
title: 'Credentials rejected',
|
||||
description: 'The model provider returned an authentication error.',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ExampleCard
|
||||
eyebrow="Promise"
|
||||
title="Async lifecycle"
|
||||
description="The promise helper should swap the same toast through loading, success, and error states instead of growing the stack unnecessarily."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={createPromiseToast}>
|
||||
Promise success
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={createRejectingPromiseToast}>
|
||||
Promise error
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const ActionExamples = () => {
|
||||
const createActionToast = () => {
|
||||
toast.add({
|
||||
type: 'warning',
|
||||
title: 'Project archived',
|
||||
description: 'You can restore it from workspace settings for the next 30 days.',
|
||||
actionProps: {
|
||||
children: 'Undo',
|
||||
onClick: () => {
|
||||
toast.add({
|
||||
type: 'success',
|
||||
title: 'Project restored',
|
||||
description: 'The workspace is active again.',
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const createLongCopyToast = () => {
|
||||
toast.add({
|
||||
type: 'info',
|
||||
title: 'Knowledge ingestion in progress',
|
||||
description: 'This longer example helps validate line wrapping, close button alignment, and action button placement when the content spans multiple rows.',
|
||||
actionProps: {
|
||||
children: 'View details',
|
||||
onClick: () => {
|
||||
toast.add({
|
||||
type: 'info',
|
||||
title: 'Job details opened',
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ExampleCard
|
||||
eyebrow="Action"
|
||||
title="Actionable toasts"
|
||||
description="Use these to verify the secondary action button, multi-line content, and the close affordance under tighter layouts."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={createActionToast}>
|
||||
Undo action
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={createLongCopyToast}>
|
||||
Long content
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const UpdateExamples = () => {
|
||||
const createUpdatableToast = () => {
|
||||
const toastId = toast.add({
|
||||
type: 'info',
|
||||
title: 'Import started',
|
||||
description: 'Preparing assets and metadata for processing.',
|
||||
timeout: 0,
|
||||
})
|
||||
|
||||
window.setTimeout(() => {
|
||||
toast.update(toastId, {
|
||||
type: 'success',
|
||||
title: 'Import finished',
|
||||
description: '128 records were imported successfully.',
|
||||
timeout: 5000,
|
||||
})
|
||||
}, 1400)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
toast.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<ExampleCard
|
||||
eyebrow="Update"
|
||||
title="Programmatic lifecycle"
|
||||
description="This example exercises manual updates on an existing toast and keeps a clear-all control nearby for repeated interaction during review."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={createUpdatableToast}>
|
||||
Add then update
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={clearAll}>
|
||||
Clear all
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const ToastDocsDemo = () => {
|
||||
return (
|
||||
<>
|
||||
<ToastHost timeout={5000} limit={5} />
|
||||
<div className="min-h-screen bg-background-default-subtle px-6 py-12">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
Base UI toast docs
|
||||
</div>
|
||||
<h2 className="text-[24px] font-semibold leading-8 text-text-primary">
|
||||
Shared stacked toast examples
|
||||
</h2>
|
||||
<p className="max-w-3xl text-sm leading-6 text-text-secondary">
|
||||
Each example card below triggers the same shared toast viewport in the top-right corner, so you can review stacking, state transitions, actions, and tone variants the same way the official Base UI documentation demonstrates toast behavior.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<VariantExamples />
|
||||
<StackExamples />
|
||||
<PromiseExamples />
|
||||
<ActionExamples />
|
||||
<UpdateExamples />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/UI Toast',
|
||||
component: ToastHost,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Dify toast host built on Base UI Toast. The story is organized as multiple example panels that all feed the same shared toast viewport, matching the way the Base UI documentation showcases toast behavior.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ToastHost>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const DocsPattern: Story = {
|
||||
render: () => <ToastDocsDemo />,
|
||||
}
|
||||
202
web/app/components/base/ui/toast/index.tsx
Normal file
202
web/app/components/base/ui/toast/index.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
ToastManagerAddOptions,
|
||||
ToastManagerPromiseOptions,
|
||||
ToastManagerUpdateOptions,
|
||||
ToastObject,
|
||||
} from '@base-ui/react/toast'
|
||||
import { Toast as BaseToast } from '@base-ui/react/toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type ToastData = Record<string, never>
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
type ToastAddOptions = Omit<ToastManagerAddOptions<ToastData>, 'data' | 'positionerProps' | 'type'> & {
|
||||
type?: ToastType
|
||||
}
|
||||
|
||||
type ToastUpdateOptions = Omit<ToastManagerUpdateOptions<ToastData>, 'data' | 'positionerProps' | 'type'> & {
|
||||
type?: ToastType
|
||||
}
|
||||
|
||||
type ToastPromiseOptions<Value> = {
|
||||
loading: string | ToastUpdateOptions
|
||||
success: string | ToastUpdateOptions | ((result: Value) => string | ToastUpdateOptions)
|
||||
error: string | ToastUpdateOptions | ((error: unknown) => string | ToastUpdateOptions)
|
||||
}
|
||||
|
||||
export type ToastHostProps = {
|
||||
timeout?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
const toastManager = BaseToast.createToastManager<ToastData>()
|
||||
|
||||
export const toast = {
|
||||
add(options: ToastAddOptions) {
|
||||
return toastManager.add(options)
|
||||
},
|
||||
close(toastId?: string) {
|
||||
toastManager.close(toastId)
|
||||
},
|
||||
update(toastId: string, options: ToastUpdateOptions) {
|
||||
toastManager.update(toastId, options)
|
||||
},
|
||||
promise<Value>(promiseValue: Promise<Value>, options: ToastPromiseOptions<Value>) {
|
||||
return toastManager.promise(promiseValue, options as ToastManagerPromiseOptions<Value, ToastData>)
|
||||
},
|
||||
}
|
||||
|
||||
function ToastIcon({ type }: { type?: string }) {
|
||||
if (type === 'success') {
|
||||
return <span aria-hidden="true" className="i-ri-checkbox-circle-fill h-5 w-5 text-text-success" />
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
return <span aria-hidden="true" className="i-ri-error-warning-fill h-5 w-5 text-text-destructive" />
|
||||
}
|
||||
|
||||
if (type === 'warning') {
|
||||
return <span aria-hidden="true" className="i-ri-alert-fill h-5 w-5 text-text-warning-secondary" />
|
||||
}
|
||||
|
||||
if (type === 'info') {
|
||||
return <span aria-hidden="true" className="i-ri-information-2-fill h-5 w-5 text-text-accent" />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getToneGradientClasses(type?: string) {
|
||||
if (type === 'success')
|
||||
return 'from-components-badge-status-light-success-halo to-background-gradient-mask-transparent'
|
||||
|
||||
if (type === 'error')
|
||||
return 'from-components-badge-status-light-error-halo to-background-gradient-mask-transparent'
|
||||
|
||||
if (type === 'warning')
|
||||
return 'from-components-badge-status-light-warning-halo to-background-gradient-mask-transparent'
|
||||
|
||||
if (type === 'info')
|
||||
return 'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent'
|
||||
|
||||
return 'from-background-default-subtle to-background-gradient-mask-transparent'
|
||||
}
|
||||
|
||||
function ToastCard({
|
||||
toast: toastItem,
|
||||
showHoverBridge = false,
|
||||
}: {
|
||||
toast: ToastObject<ToastData>
|
||||
showHoverBridge?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
return (
|
||||
<BaseToast.Root
|
||||
toast={toastItem}
|
||||
className={cn(
|
||||
'pointer-events-auto absolute right-0 top-0 w-[360px] max-w-[calc(100vw-2rem)] origin-top-right cursor-default select-none outline-none',
|
||||
'[--toast-current-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:8px] [--toast-peek:5px] [--toast-scale:calc(1-(var(--toast-index)*0.0225))] [--toast-shrink:calc(1-var(--toast-scale))]',
|
||||
'[height:var(--toast-current-height)] [z-index:calc(100-var(--toast-index))]',
|
||||
'[transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms] motion-reduce:transition-none',
|
||||
'translate-x-[var(--toast-swipe-movement-x)] translate-y-[calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-current-height)))] scale-[var(--toast-scale)]',
|
||||
'data-[expanded]:translate-x-[var(--toast-swipe-movement-x)] data-[expanded]:translate-y-[calc(var(--toast-offset-y)+var(--toast-swipe-movement-y)+(var(--toast-index)*8px))] data-[expanded]:scale-100 data-[expanded]:[height:var(--toast-height)]',
|
||||
'data-[limited]:pointer-events-none data-[ending-style]:translate-y-[calc(var(--toast-swipe-movement-y)-150%)] data-[starting-style]:-translate-y-[150%] data-[ending-style]:opacity-0 data-[limited]:opacity-0 data-[starting-style]:opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn('absolute inset-[-1px] bg-gradient-to-r opacity-40', getToneGradientClasses(toastItem.type))}
|
||||
/>
|
||||
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-[behind]:opacity-0 data-[expanded]:opacity-100">
|
||||
<div className="flex shrink-0 items-center justify-center p-0.5">
|
||||
<ToastIcon type={toastItem.type} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 p-1">
|
||||
<div className="flex w-full items-center gap-1">
|
||||
{toastItem.title != null && (
|
||||
<BaseToast.Title className="break-words text-text-primary system-sm-semibold">
|
||||
{toastItem.title}
|
||||
</BaseToast.Title>
|
||||
)}
|
||||
</div>
|
||||
{toastItem.description != null && (
|
||||
<BaseToast.Description className="mt-1 break-words text-text-secondary system-xs-regular">
|
||||
{toastItem.description}
|
||||
</BaseToast.Description>
|
||||
)}
|
||||
{toastItem.actionProps && (
|
||||
<div className="flex w-full items-start gap-1 pb-1 pt-2">
|
||||
<BaseToast.Action
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center overflow-hidden rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] system-sm-medium',
|
||||
'hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-center rounded-md p-0.5">
|
||||
<BaseToast.Close
|
||||
aria-label={t('toast.close')}
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-md hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-components-input-border-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</BaseToast.Close>
|
||||
</div>
|
||||
</BaseToast.Content>
|
||||
</div>
|
||||
{showHoverBridge && (
|
||||
<div aria-hidden="true" className="absolute inset-x-0 -bottom-2 h-2" />
|
||||
)}
|
||||
</BaseToast.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastViewport() {
|
||||
const { t } = useTranslation('common')
|
||||
const { toasts } = BaseToast.useToastManager<ToastData>()
|
||||
|
||||
return (
|
||||
<BaseToast.Viewport
|
||||
aria-label={t('toast.notifications')}
|
||||
className={cn(
|
||||
// During overlay migration, toast must stay above legacy highPriority modals (z-[1100]).
|
||||
'group/toast-viewport pointer-events-none fixed inset-0 z-[1101] overflow-visible',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-4 top-4 w-[360px] max-w-[calc(100vw-2rem)] sm:right-8',
|
||||
)}
|
||||
>
|
||||
{toasts.map((toastItem, index) => (
|
||||
<ToastCard
|
||||
key={toastItem.id}
|
||||
toast={toastItem}
|
||||
showHoverBridge={index < toasts.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</BaseToast.Viewport>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToastHost({
|
||||
timeout,
|
||||
limit,
|
||||
}: ToastHostProps) {
|
||||
return (
|
||||
<BaseToast.Provider toastManager={toastManager} timeout={timeout} limit={limit}>
|
||||
<BaseToast.Portal>
|
||||
<ToastViewport />
|
||||
</BaseToast.Portal>
|
||||
</BaseToast.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DelimiterInput, MaxLengthInput, OverlapInput } from '../inputs'
|
||||
|
||||
@@ -61,6 +61,21 @@ describe('MaxLengthInput', () => {
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset to the minimum when users clear the value', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<MaxLengthInput value={500} onChange={onChange} />)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } })
|
||||
expect(onChange).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should clamp out-of-range text edits before updating state', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<MaxLengthInput value={500} max={1000} onChange={onChange} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '1200' } })
|
||||
expect(onChange).toHaveBeenLastCalledWith(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OverlapInput', () => {
|
||||
@@ -89,4 +104,19 @@ describe('OverlapInput', () => {
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset to the minimum when users clear the value', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<OverlapInput value={50} onChange={onChange} />)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } })
|
||||
expect(onChange).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should clamp out-of-range text edits before updating state', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<OverlapInput value={50} max={100} onChange={onChange} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '150' } })
|
||||
expect(onChange).toHaveBeenLastCalledWith(100)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react'
|
||||
import type { InputProps } from '@/app/components/base/input'
|
||||
import type { InputNumberProps } from '@/app/components/base/input-number'
|
||||
import type { NumberFieldInputProps, NumberFieldRootProps, NumberFieldSize } from '@/app/components/base/ui/number-field'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
NumberFieldUnit,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import { env } from '@/env'
|
||||
|
||||
const TextLabel: FC<PropsWithChildren> = (props) => {
|
||||
@@ -25,7 +33,7 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = (props) =>
|
||||
return (
|
||||
<FormField label={(
|
||||
<div className="mb-1 flex items-center">
|
||||
<span className="system-sm-semibold mr-0.5">{t('stepTwo.separator', { ns: 'datasetCreation' })}</span>
|
||||
<span className="mr-0.5 system-sm-semibold">{t('stepTwo.separator', { ns: 'datasetCreation' })}</span>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="max-w-[200px]">
|
||||
@@ -46,19 +54,69 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = (props) =>
|
||||
)
|
||||
}
|
||||
|
||||
export const MaxLengthInput: FC<InputNumberProps> = (props) => {
|
||||
type CompoundNumberInputProps = Omit<NumberFieldRootProps, 'children' | 'className' | 'onValueChange'> & Omit<NumberFieldInputProps, 'children' | 'size' | 'onChange'> & {
|
||||
unit?: ReactNode
|
||||
size?: NumberFieldSize
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
function CompoundNumberInput({
|
||||
onChange,
|
||||
unit,
|
||||
size = 'large',
|
||||
className,
|
||||
...props
|
||||
}: CompoundNumberInputProps) {
|
||||
const { value, defaultValue, min, max, step, disabled, readOnly, required, id, name, onBlur, ...inputProps } = props
|
||||
const emptyValue = defaultValue ?? min ?? 0
|
||||
|
||||
return (
|
||||
<NumberField
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
id={id}
|
||||
name={name}
|
||||
onValueChange={value => onChange(value ?? emptyValue)}
|
||||
>
|
||||
<NumberFieldGroup size={size}>
|
||||
<NumberFieldInput
|
||||
{...inputProps}
|
||||
size={size}
|
||||
className={className}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{Boolean(unit) && (
|
||||
<NumberFieldUnit size={size}>
|
||||
{unit}
|
||||
</NumberFieldUnit>
|
||||
)}
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size={size} />
|
||||
<NumberFieldDecrement size={size} />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
)
|
||||
}
|
||||
|
||||
export const MaxLengthInput: FC<CompoundNumberInputProps> = (props) => {
|
||||
const maxValue = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH
|
||||
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<FormField label={(
|
||||
<div className="system-sm-semibold mb-1">
|
||||
<div className="mb-1 system-sm-semibold">
|
||||
{t('stepTwo.maxLength', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<InputNumber
|
||||
type="number"
|
||||
<CompoundNumberInput
|
||||
size="large"
|
||||
placeholder={`≤ ${maxValue}`}
|
||||
max={maxValue}
|
||||
@@ -69,7 +127,7 @@ export const MaxLengthInput: FC<InputNumberProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const OverlapInput: FC<InputNumberProps> = (props) => {
|
||||
export const OverlapInput: FC<CompoundNumberInputProps> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<FormField label={(
|
||||
@@ -85,8 +143,7 @@ export const OverlapInput: FC<InputNumberProps> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<InputNumber
|
||||
type="number"
|
||||
<CompoundNumberInput
|
||||
size="large"
|
||||
placeholder={t('stepTwo.overlap', { ns: 'datasetCreation' }) || ''}
|
||||
min={1}
|
||||
|
||||
@@ -103,6 +103,18 @@ describe('InputCombined', () => {
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset cleared number input to 0', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should display current value for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Datepicker from '../base/date-picker'
|
||||
import { DataType } from '../types'
|
||||
@@ -36,15 +43,23 @@ const InputCombined: FC<Props> = ({
|
||||
if (type === DataType.number) {
|
||||
return (
|
||||
<div className="grow text-[0]">
|
||||
<InputNumber
|
||||
className={cn(className, 'rounded-l-md')}
|
||||
<NumberField
|
||||
className="min-w-0"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
size="regular"
|
||||
controlWrapClassName="overflow-hidden"
|
||||
controlClassName="pt-0 pb-0"
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
onValueChange={value => onChange(value ?? 0)}
|
||||
>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput
|
||||
size="regular"
|
||||
className={cn(className, 'rounded-l-md')}
|
||||
/>
|
||||
<NumberFieldControls className="overflow-hidden">
|
||||
<NumberFieldIncrement size="regular" className="pb-0 pt-0" />
|
||||
<NumberFieldDecrement size="regular" className="pb-0 pt-0" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ describe('IndexMethod', () => {
|
||||
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle keywordNumber of 0', () => {
|
||||
it('should handle minimum keywordNumber', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('0')
|
||||
|
||||
@@ -24,9 +24,8 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
it('should render tooltip with question icon', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
// RiQuestionLine renders as an svg
|
||||
const container = screen.getByText(/form\.numberOfKeywords/).closest('div')?.parentElement
|
||||
const questionIcon = container?.querySelector('svg')
|
||||
const questionIcon = container?.querySelector('.i-ri-question-line')
|
||||
expect(questionIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -88,15 +87,22 @@ describe('KeyWordNumber', () => {
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onKeywordNumberChange with undefined value', () => {
|
||||
it('should reset to 0 when users clear the input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
// When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
expect(handleChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should clamp out-of-range edits before updating state', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '60' } })
|
||||
expect(handleChange).toHaveBeenLastCalledWith(50)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
|
||||
const MIN_KEYWORD_NUMBER = 0
|
||||
const MAX_KEYWORD_NUMBER = 50
|
||||
|
||||
type KeyWordNumberProps = {
|
||||
keywordNumber: number
|
||||
@@ -17,35 +26,44 @@ const KeyWordNumber = ({
|
||||
}: KeyWordNumberProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleInputChange = useCallback((value: number | undefined) => {
|
||||
if (value)
|
||||
onKeywordNumberChange(value)
|
||||
const handleInputChange = useCallback((value: number | null) => {
|
||||
onKeywordNumberChange(value ?? MIN_KEYWORD_NUMBER)
|
||||
}, [onKeywordNumberChange])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="flex grow items-center gap-x-0.5">
|
||||
<div className="system-xs-medium truncate text-text-secondary">
|
||||
<div className="truncate text-text-secondary system-xs-medium">
|
||||
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Tooltip
|
||||
popupContent="number of keywords"
|
||||
popupContent={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
|
||||
>
|
||||
<RiQuestionLine className="h-3.5 w-3.5 text-text-quaternary" />
|
||||
<span className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Slider
|
||||
className="mr-3 w-[206px] shrink-0"
|
||||
value={keywordNumber}
|
||||
max={50}
|
||||
min={MIN_KEYWORD_NUMBER}
|
||||
max={MAX_KEYWORD_NUMBER}
|
||||
onChange={onKeywordNumberChange}
|
||||
/>
|
||||
<InputNumber
|
||||
wrapperClassName="shrink-0 w-12"
|
||||
type="number"
|
||||
<NumberField
|
||||
className="w-12 shrink-0"
|
||||
min={MIN_KEYWORD_NUMBER}
|
||||
max={MAX_KEYWORD_NUMBER}
|
||||
value={keywordNumber}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
onValueChange={handleInputChange}
|
||||
>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput size="regular" />
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size="regular" />
|
||||
<NumberFieldDecrement size="regular" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
CredentialFormSchema,
|
||||
CredentialFormSchemaNumberInput,
|
||||
CredentialFormSchemaTextInput,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { AgentStrategy } from '../agent-strategy'
|
||||
|
||||
const createI18nLabel = (text: string) => ({ en_US: text, zh_Hans: text })
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useDefaultModel: () => ({ data: null }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => () => '/docs',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (value: unknown) => {
|
||||
if (typeof value === 'string')
|
||||
return value
|
||||
if (value && typeof value === 'object' && 'en_US' in value)
|
||||
return value.en_US
|
||||
return 'label'
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../../store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setControlPromptEditorRerenderKey: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../agent-strategy-selector', () => ({
|
||||
AgentStrategySelector: () => <div data-testid="agent-strategy-selector" />,
|
||||
}))
|
||||
|
||||
vi.mock('../field', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../prompt/editor', () => ({
|
||||
default: ({ value }: { value: string }) => <div data-testid="agent-strategy-editor">{value}</div>,
|
||||
}))
|
||||
|
||||
type MockFormRenderProps = {
|
||||
value: Record<string, unknown>
|
||||
onChange: (value: Record<string, unknown>) => void
|
||||
nodeId?: string
|
||||
nodeOutputVars?: unknown[]
|
||||
availableNodes?: unknown[]
|
||||
}
|
||||
|
||||
type MockFormProps = {
|
||||
formSchemas: Array<{ variable: string }>
|
||||
value: Record<string, unknown>
|
||||
onChange: (value: Record<string, unknown>) => void
|
||||
override?: [unknown, (schema: unknown, props: MockFormRenderProps) => ReactNode]
|
||||
nodeId?: string
|
||||
nodeOutputVars?: unknown[]
|
||||
availableNodes?: unknown[]
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
|
||||
default: ({ formSchemas, value, onChange, override, nodeId, nodeOutputVars, availableNodes }: MockFormProps) => {
|
||||
const renderOverride = override?.[1]
|
||||
|
||||
return (
|
||||
<div data-testid="mock-form">
|
||||
{formSchemas.map(schema => (
|
||||
<div key={schema.variable}>
|
||||
{renderOverride?.(schema, {
|
||||
value,
|
||||
onChange,
|
||||
nodeId,
|
||||
nodeOutputVars,
|
||||
availableNodes,
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AgentStrategy', () => {
|
||||
const defaultProps = {
|
||||
strategy: {
|
||||
agent_strategy_provider_name: 'provider',
|
||||
agent_strategy_name: 'strategy',
|
||||
agent_strategy_label: 'Strategy',
|
||||
agent_output_schema: {},
|
||||
plugin_unique_identifier: 'plugin',
|
||||
},
|
||||
onStrategyChange: vi.fn(),
|
||||
formValue: {},
|
||||
onFormValueChange: vi.fn(),
|
||||
nodeOutputVars: [],
|
||||
availableNodes: [],
|
||||
nodeId: 'node-1',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const createTextNumberSchema = (overrides: Partial<CredentialFormSchemaNumberInput> = {}): CredentialFormSchema => ({
|
||||
name: 'count',
|
||||
variable: 'count',
|
||||
label: createI18nLabel('Count'),
|
||||
type: FormTypeEnum.textNumber,
|
||||
required: false,
|
||||
show_on: [],
|
||||
default: '1',
|
||||
...overrides,
|
||||
} as unknown as CredentialFormSchema)
|
||||
|
||||
const createTextInputSchema = (overrides: Partial<CredentialFormSchemaTextInput> = {}): CredentialFormSchema => ({
|
||||
name: 'prompt',
|
||||
variable: 'prompt',
|
||||
label: createI18nLabel('Prompt'),
|
||||
type: FormTypeEnum.textInput,
|
||||
required: false,
|
||||
show_on: [],
|
||||
default: 'hello',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('should render text-number schemas when min and max are zero', () => {
|
||||
render(
|
||||
<AgentStrategy
|
||||
{...defaultProps}
|
||||
formSchema={[createTextNumberSchema({
|
||||
min: 0,
|
||||
max: 0,
|
||||
default: '0',
|
||||
})]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should skip text-number schemas when min is missing', () => {
|
||||
render(
|
||||
<AgentStrategy
|
||||
{...defaultProps}
|
||||
formSchema={[createTextNumberSchema({
|
||||
max: 5,
|
||||
})]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should skip text-number schemas when max is missing', () => {
|
||||
render(
|
||||
<AgentStrategy
|
||||
{...defaultProps}
|
||||
formSchema={[createTextNumberSchema({
|
||||
min: 0,
|
||||
})]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text-input schemas through the editor override', () => {
|
||||
render(
|
||||
<AgentStrategy
|
||||
{...defaultProps}
|
||||
formSchema={[createTextInputSchema()]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('agent-strategy-editor')).toHaveTextContent('hello')
|
||||
})
|
||||
})
|
||||
@@ -9,9 +9,16 @@ import Link from 'next/link'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Agent } from '@/app/components/base/icons/src/vender/workflow'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import ListEmpty from '@/app/components/base/list-empty'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
|
||||
@@ -116,11 +123,11 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
|
||||
}
|
||||
case FormTypeEnum.textNumber: {
|
||||
const def = schema as CredentialFormSchemaNumberInput
|
||||
if (!def.max || !def.min)
|
||||
if (def.max == null || def.min == null)
|
||||
return false
|
||||
|
||||
const defaultValue = schema.default ? Number.parseInt(schema.default) : 1
|
||||
const value = props.value[schema.variable] || defaultValue
|
||||
const value = props.value[schema.variable] ?? defaultValue
|
||||
const onChange = (value: number) => {
|
||||
props.onChange({ ...props.value, [schema.variable]: value })
|
||||
}
|
||||
@@ -145,16 +152,20 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
/>
|
||||
<InputNumber
|
||||
<NumberField
|
||||
value={value}
|
||||
// TODO: maybe empty, handle this
|
||||
onChange={onChange as any}
|
||||
defaultValue={defaultValue}
|
||||
size="regular"
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
className="w-12"
|
||||
/>
|
||||
onValueChange={nextValue => onChange(nextValue ?? defaultValue)}
|
||||
>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput size="regular" className="w-12" />
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size="regular" />
|
||||
<NumberFieldDecrement size="regular" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
</Field>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import TopKAndScoreThreshold from '../top-k-and-score-threshold'
|
||||
|
||||
describe('TopKAndScoreThreshold', () => {
|
||||
const defaultProps = {
|
||||
topK: 3,
|
||||
onTopKChange: vi.fn(),
|
||||
scoreThreshold: 0.4,
|
||||
onScoreThresholdChange: vi.fn(),
|
||||
isScoreThresholdEnabled: true,
|
||||
onScoreThresholdEnabledChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should round top-k input values before notifying the parent', () => {
|
||||
render(<TopKAndScoreThreshold {...defaultProps} />)
|
||||
|
||||
const [topKInput] = screen.getAllByRole('textbox')
|
||||
fireEvent.change(topKInput, { target: { value: '3.7' } })
|
||||
|
||||
expect(defaultProps.onTopKChange).toHaveBeenCalledWith(4)
|
||||
})
|
||||
|
||||
it('should round score-threshold input values to two decimals', () => {
|
||||
render(<TopKAndScoreThreshold {...defaultProps} />)
|
||||
|
||||
const [, scoreThresholdInput] = screen.getAllByRole('textbox')
|
||||
fireEvent.change(scoreThresholdInput, { target: { value: '0.456' } })
|
||||
|
||||
expect(defaultProps.onScoreThresholdChange).toHaveBeenCalledWith(0.46)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,15 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import { env } from '@/env'
|
||||
|
||||
export type TopKAndScoreThresholdProps = {
|
||||
@@ -40,17 +47,11 @@ const TopKAndScoreThreshold = ({
|
||||
}: TopKAndScoreThresholdProps) => {
|
||||
const { t } = useTranslation()
|
||||
const handleTopKChange = useCallback((value: number) => {
|
||||
let notOutRangeValue = Number.parseInt(value.toFixed(0))
|
||||
notOutRangeValue = Math.max(TOP_K_VALUE_LIMIT.min, notOutRangeValue)
|
||||
notOutRangeValue = Math.min(TOP_K_VALUE_LIMIT.max, notOutRangeValue)
|
||||
onTopKChange?.(notOutRangeValue)
|
||||
onTopKChange?.(Number.parseInt(value.toFixed(0)))
|
||||
}, [onTopKChange])
|
||||
|
||||
const handleScoreThresholdChange = (value: number) => {
|
||||
let notOutRangeValue = Number.parseFloat(value.toFixed(2))
|
||||
notOutRangeValue = Math.max(SCORE_THRESHOLD_VALUE_LIMIT.min, notOutRangeValue)
|
||||
notOutRangeValue = Math.min(SCORE_THRESHOLD_VALUE_LIMIT.max, notOutRangeValue)
|
||||
onScoreThresholdChange?.(notOutRangeValue)
|
||||
onScoreThresholdChange?.(Number.parseFloat(value.toFixed(2)))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -63,14 +64,22 @@ const TopKAndScoreThreshold = ({
|
||||
popupContent={t('datasetConfig.top_kTip', { ns: 'appDebug' })}
|
||||
/>
|
||||
</div>
|
||||
<InputNumber
|
||||
<NumberField
|
||||
disabled={readonly}
|
||||
type="number"
|
||||
{...TOP_K_VALUE_LIMIT}
|
||||
size="regular"
|
||||
step={TOP_K_VALUE_LIMIT.amount}
|
||||
min={TOP_K_VALUE_LIMIT.min}
|
||||
max={TOP_K_VALUE_LIMIT.max}
|
||||
value={topK}
|
||||
onChange={handleTopKChange}
|
||||
/>
|
||||
onValueChange={value => handleTopKChange(value ?? 0)}
|
||||
>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput size="regular" />
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size="regular" />
|
||||
<NumberFieldDecrement size="regular" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
{
|
||||
!hiddenScoreThreshold && (
|
||||
@@ -90,14 +99,22 @@ const TopKAndScoreThreshold = ({
|
||||
popupContent={t('datasetConfig.score_thresholdTip', { ns: 'appDebug' })}
|
||||
/>
|
||||
</div>
|
||||
<InputNumber
|
||||
<NumberField
|
||||
disabled={readonly || !isScoreThresholdEnabled}
|
||||
type="number"
|
||||
{...SCORE_THRESHOLD_VALUE_LIMIT}
|
||||
size="regular"
|
||||
value={scoreThreshold}
|
||||
onChange={handleScoreThresholdChange}
|
||||
/>
|
||||
step={SCORE_THRESHOLD_VALUE_LIMIT.step}
|
||||
min={SCORE_THRESHOLD_VALUE_LIMIT.min}
|
||||
max={SCORE_THRESHOLD_VALUE_LIMIT.max}
|
||||
value={scoreThreshold ?? null}
|
||||
onValueChange={value => handleScoreThresholdChange(value ?? 0)}
|
||||
>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput size="regular" />
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size="regular" />
|
||||
<NumberFieldDecrement size="regular" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { WebhookTriggerNodeType } from '../types'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Panel from '../panel'
|
||||
|
||||
const {
|
||||
mockHandleStatusCodeChange,
|
||||
mockGenerateWebhookUrl,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleStatusCodeChange: vi.fn(),
|
||||
mockGenerateWebhookUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
DEFAULT_STATUS_CODE: 200,
|
||||
MAX_STATUS_CODE: 399,
|
||||
normalizeStatusCode: (statusCode: number) => Math.min(Math.max(statusCode, 200), 399),
|
||||
useConfig: () => ({
|
||||
readOnly: false,
|
||||
inputs: {
|
||||
method: 'POST',
|
||||
webhook_url: 'https://example.com/webhook',
|
||||
webhook_debug_url: '',
|
||||
content_type: 'application/json',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: [],
|
||||
status_code: 200,
|
||||
response_body: '',
|
||||
},
|
||||
handleMethodChange: vi.fn(),
|
||||
handleContentTypeChange: vi.fn(),
|
||||
handleHeadersChange: vi.fn(),
|
||||
handleParamsChange: vi.fn(),
|
||||
handleBodyChange: vi.fn(),
|
||||
handleStatusCodeChange: mockHandleStatusCodeChange,
|
||||
handleResponseBodyChange: vi.fn(),
|
||||
generateWebhookUrl: mockGenerateWebhookUrl,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/input-with-copy', () => ({
|
||||
default: () => <div data-testid="input-with-copy" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
SimpleSelect: () => <div data-testid="simple-select" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
|
||||
default: ({ title, children }: { title: React.ReactNode, children: React.ReactNode }) => (
|
||||
<section>
|
||||
<div>{title}</div>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
|
||||
default: () => <div data-testid="output-vars" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
|
||||
default: () => <div data-testid="split" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/header-table', () => ({
|
||||
default: () => <div data-testid="header-table" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/parameter-table', () => ({
|
||||
default: () => <div data-testid="parameter-table" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/paragraph-input', () => ({
|
||||
default: () => <div data-testid="paragraph-input" />,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/render-output-vars', () => ({
|
||||
OutputVariablesContent: () => <div data-testid="output-variables-content" />,
|
||||
}))
|
||||
|
||||
describe('WebhookTriggerPanel', () => {
|
||||
const panelProps: NodePanelProps<WebhookTriggerNodeType> = {
|
||||
id: 'node-1',
|
||||
data: {
|
||||
title: 'Webhook',
|
||||
desc: 'Webhook',
|
||||
type: BlockEnum.TriggerWebhook,
|
||||
method: 'POST',
|
||||
content_type: 'application/json',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: [],
|
||||
async_mode: false,
|
||||
status_code: 200,
|
||||
response_body: '',
|
||||
variables: [],
|
||||
},
|
||||
panelProps: {} as PanelProps,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should update the status code when users enter a parseable value', () => {
|
||||
render(<Panel {...panelProps} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '201' } })
|
||||
|
||||
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(201)
|
||||
})
|
||||
|
||||
it('should ignore clear changes until the value is committed', () => {
|
||||
render(<Panel {...panelProps} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
expect(mockHandleStatusCodeChange).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200)
|
||||
})
|
||||
})
|
||||
@@ -6,11 +6,18 @@ import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import InputWithCopy from '@/app/components/base/input-with-copy'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import OutputVars from '@/app/components/workflow/nodes/_base/components/output-vars'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
@@ -18,7 +25,7 @@ import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
|
||||
import HeaderTable from './components/header-table'
|
||||
import ParagraphInput from './components/paragraph-input'
|
||||
import ParameterTable from './components/parameter-table'
|
||||
import useConfig from './use-config'
|
||||
import { DEFAULT_STATUS_CODE, MAX_STATUS_CODE, normalizeStatusCode, useConfig } from './use-config'
|
||||
import { OutputVariablesContent } from './utils/render-output-vars'
|
||||
|
||||
const i18nPrefix = 'nodes.triggerWebhook'
|
||||
@@ -56,7 +63,6 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
|
||||
handleParamsChange,
|
||||
handleBodyChange,
|
||||
handleStatusCodeChange,
|
||||
handleStatusCodeBlur,
|
||||
handleResponseBodyChange,
|
||||
generateWebhookUrl,
|
||||
} = useConfig(id, data)
|
||||
@@ -134,7 +140,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
|
||||
</div>
|
||||
</Tooltip>
|
||||
{isPrivateOrLocalAddress(inputs.webhook_debug_url) && (
|
||||
<div className="system-xs-regular mt-1 px-0 py-[2px] text-text-warning">
|
||||
<div className="mt-1 px-0 py-[2px] text-text-warning system-xs-regular">
|
||||
{t(`${i18nPrefix}.debugUrlPrivateAddressWarning`, { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
@@ -192,25 +198,35 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
|
||||
<Field title={t(`${i18nPrefix}.responseConfiguration`, { ns: 'workflow' })}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="system-sm-medium text-text-tertiary">
|
||||
<label className="text-text-tertiary system-sm-medium">
|
||||
{t(`${i18nPrefix}.statusCode`, { ns: 'workflow' })}
|
||||
</label>
|
||||
<InputNumber
|
||||
value={inputs.status_code}
|
||||
onChange={(value) => {
|
||||
handleStatusCodeChange(value || 200)
|
||||
}}
|
||||
<NumberField
|
||||
className="w-[120px]"
|
||||
min={DEFAULT_STATUS_CODE}
|
||||
max={MAX_STATUS_CODE}
|
||||
value={inputs.status_code ?? DEFAULT_STATUS_CODE}
|
||||
disabled={readOnly}
|
||||
wrapClassName="w-[120px]"
|
||||
className="h-8"
|
||||
defaultValue={200}
|
||||
onBlur={() => {
|
||||
handleStatusCodeBlur(inputs.status_code)
|
||||
onValueChange={value => value !== null && handleStatusCodeChange(value)}
|
||||
onValueCommitted={(value, eventDetails) => {
|
||||
if (eventDetails.reason === 'input-blur' || eventDetails.reason === 'input-clear')
|
||||
handleStatusCodeChange(normalizeStatusCode(value ?? DEFAULT_STATUS_CODE))
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput
|
||||
size="regular"
|
||||
className="h-8"
|
||||
/>
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size="regular" />
|
||||
<NumberFieldDecrement size="regular" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
<div>
|
||||
<label className="system-sm-medium mb-2 block text-text-tertiary">
|
||||
<label className="mb-2 block text-text-tertiary system-sm-medium">
|
||||
{t(`${i18nPrefix}.responseBody`, { ns: 'workflow' })}
|
||||
</label>
|
||||
<ParagraphInput
|
||||
|
||||
@@ -13,7 +13,11 @@ import { fetchWebhookUrl } from '@/service/apps'
|
||||
import { checkKeys, hasDuplicateStr } from '@/utils/var'
|
||||
import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable'
|
||||
|
||||
const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
||||
export const DEFAULT_STATUS_CODE = 200
|
||||
export const MAX_STATUS_CODE = 399
|
||||
export const normalizeStatusCode = (statusCode: number) => Math.min(Math.max(statusCode, DEFAULT_STATUS_CODE), MAX_STATUS_CODE)
|
||||
|
||||
export const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
||||
const { t } = useTranslation()
|
||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||
const { inputs, setInputs } = useNodeCrud<WebhookTriggerNodeType>(id, payload)
|
||||
@@ -192,15 +196,6 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
||||
}))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleStatusCodeBlur = useCallback((statusCode: number) => {
|
||||
// Only clamp when user finishes editing (on blur)
|
||||
const clampedStatusCode = Math.min(Math.max(statusCode, 200), 399)
|
||||
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.status_code = clampedStatusCode
|
||||
}))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleResponseBodyChange = useCallback((responseBody: string) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.response_body = responseBody
|
||||
@@ -247,10 +242,7 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
||||
handleBodyChange,
|
||||
handleAsyncModeChange,
|
||||
handleStatusCodeChange,
|
||||
handleStatusCodeBlur,
|
||||
handleResponseBodyChange,
|
||||
generateWebhookUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfig
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { submitHumanInputForm } from '@/service/workflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Toast from '../../base/toast'
|
||||
import {
|
||||
useWorkflowInteractions,
|
||||
} from '../hooks'
|
||||
@@ -210,7 +210,7 @@ const WorkflowPreview = () => {
|
||||
copy(content)
|
||||
else
|
||||
copy(JSON.stringify(content))
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
toast.add({ type: 'success', title: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-clipboard-line h-3.5 w-3.5" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import { getDatasetMap } from '@/env'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { ToastProvider } from './components/base/toast'
|
||||
import { ToastHost } from './components/base/ui/toast'
|
||||
import { TooltipProvider } from './components/base/ui/tooltip'
|
||||
import BrowserInitializer from './components/browser-initializer'
|
||||
import { ReactScanLoader } from './components/devtools/react-scan/loader'
|
||||
@@ -70,6 +71,7 @@ const LocaleLayout = async ({
|
||||
<SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastHost timeout={5000} />
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
<TooltipProvider delay={300} closeDelay={200}>
|
||||
|
||||
@@ -13,6 +13,7 @@ This document tracks the migration away from legacy overlay APIs.
|
||||
- `@/app/components/base/popover`
|
||||
- `@/app/components/base/dropdown`
|
||||
- `@/app/components/base/dialog`
|
||||
- `@/app/components/base/toast` (including `context`)
|
||||
- Replacement primitives:
|
||||
- `@/app/components/base/ui/tooltip`
|
||||
- `@/app/components/base/ui/dropdown-menu`
|
||||
@@ -21,6 +22,7 @@ This document tracks the migration away from legacy overlay APIs.
|
||||
- `@/app/components/base/ui/dialog`
|
||||
- `@/app/components/base/ui/alert-dialog`
|
||||
- `@/app/components/base/ui/select`
|
||||
- `@/app/components/base/ui/toast`
|
||||
- Tracking issue: https://github.com/langgenius/dify/issues/32767
|
||||
|
||||
## ESLint policy
|
||||
@@ -42,6 +44,13 @@ This document tracks the migration away from legacy overlay APIs.
|
||||
- Remove remaining allowlist entries.
|
||||
- Remove legacy overlay implementations when import count reaches zero.
|
||||
|
||||
## Toast migration strategy
|
||||
|
||||
- During migration, `@/app/components/base/toast` and `@/app/components/base/ui/toast` may coexist.
|
||||
- All new toast usage must go through `@/app/components/base/ui/toast`.
|
||||
- When a file with legacy toast usage is touched, prefer migrating that call site in the same change; full-repo toast cleanup is not required in one PR.
|
||||
- `@/app/components/base/ui/toast` is the design-system stack toast host. Legacy `ToastContext`, `ToastProvider`, anchored toast behavior, and ad-hoc mount patterns stay in `base/toast` until their call sites are migrated away.
|
||||
|
||||
## Allowlist maintenance
|
||||
|
||||
- After each migration batch, run:
|
||||
@@ -55,7 +64,8 @@ pnpm -C web lint:fix --prune-suppressions <changed-files>
|
||||
|
||||
## z-index strategy
|
||||
|
||||
All new overlay primitives in `base/ui/` share a single z-index value: **`z-[1002]`**.
|
||||
All new overlay primitives in `base/ui/` share a single z-index value:
|
||||
**`z-[1002]`**, except Toast which stays at **`z-[1101]`** during migration.
|
||||
|
||||
### Why z-[1002]?
|
||||
|
||||
@@ -69,13 +79,17 @@ portal to `document.body` with explicit z-index values:
|
||||
| Legacy PortalToFollowElem callers | up to `z-[1001]` | various business components |
|
||||
| **New UI primitives** | **`z-[1002]`** | `base/ui/*` (Popover, Dialog, Tooltip, etc.) |
|
||||
| Legacy Modal (highPriority) | `z-[1100]` | `base/modal` (`highPriority={true}`) |
|
||||
| Toast | `z-[9999]` | `base/toast` |
|
||||
| Toast (legacy + new) | `z-[1101]` | `base/toast`, `base/ui/toast` |
|
||||
|
||||
`z-[1002]` sits above all common legacy overlays, so new primitives always
|
||||
render on top without needing per-call-site z-index hacks. Among themselves,
|
||||
new primitives share the same z-index and rely on **DOM order** for stacking
|
||||
(later portal = on top).
|
||||
|
||||
Toast intentionally stays one layer above the remaining legacy `highPriority`
|
||||
modal path (`z-[1100]`) so notifications keep their current visibility without
|
||||
falling back to `z-[9999]`.
|
||||
|
||||
### Rules
|
||||
|
||||
- **Do NOT add z-index overrides** (e.g. `className="z-[1003]"`) on new
|
||||
@@ -91,7 +105,7 @@ new primitives share the same z-index and rely on **DOM order** for stacking
|
||||
Once all legacy overlays are removed:
|
||||
|
||||
1. Reduce `z-[1002]` back to `z-50` across all `base/ui/` primitives.
|
||||
1. Reduce Toast from `z-[9999]` to `z-[99]`.
|
||||
1. Reduce Toast from `z-[1101]` to `z-[51]`.
|
||||
1. Remove this section from the migration guide.
|
||||
|
||||
## React Refresh policy for base UI primitives
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,15 @@ const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/toast',
|
||||
'**/base/toast/index',
|
||||
'**/base/toast/context',
|
||||
'**/base/toast/context/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/toast instead. See issue #32811.',
|
||||
},
|
||||
]
|
||||
|
||||
export default antfu(
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "نسخ الصورة",
|
||||
"operation.create": "إنشاء",
|
||||
"operation.deSelectAll": "إلغاء تحديد الكل",
|
||||
"operation.decrement": "تقليل",
|
||||
"operation.delete": "حذف",
|
||||
"operation.deleteApp": "حذف التطبيق",
|
||||
"operation.deleteConfirmTitle": "حذف؟",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "تم نسخ الصورة",
|
||||
"operation.imageDownloaded": "تم تنزيل الصورة",
|
||||
"operation.in": "في",
|
||||
"operation.increment": "زيادة",
|
||||
"operation.learnMore": "تعرف على المزيد",
|
||||
"operation.lineBreak": "فاصل أسطر",
|
||||
"operation.log": "سجل",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "داكن",
|
||||
"theme.light": "فاتح",
|
||||
"theme.theme": "السمة",
|
||||
"toast.close": "إغلاق الإشعار",
|
||||
"toast.notifications": "الإشعارات",
|
||||
"unit.char": "أحرف",
|
||||
"userProfile.about": "حول",
|
||||
"userProfile.community": "المجتمع",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Bild kopieren",
|
||||
"operation.create": "Erstellen",
|
||||
"operation.deSelectAll": "Alle abwählen",
|
||||
"operation.decrement": "Verringern",
|
||||
"operation.delete": "Löschen",
|
||||
"operation.deleteApp": "App löschen",
|
||||
"operation.deleteConfirmTitle": "Löschen?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Kopiertes Bild",
|
||||
"operation.imageDownloaded": "Bild heruntergeladen",
|
||||
"operation.in": "in",
|
||||
"operation.increment": "Erhöhen",
|
||||
"operation.learnMore": "Mehr erfahren",
|
||||
"operation.lineBreak": "Zeilenumbruch",
|
||||
"operation.log": "Protokoll",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "dunkel",
|
||||
"theme.light": "Licht",
|
||||
"theme.theme": "Thema",
|
||||
"toast.close": "Benachrichtigung schließen",
|
||||
"toast.notifications": "Benachrichtigungen",
|
||||
"unit.char": "Zeichen",
|
||||
"userProfile.about": "Über",
|
||||
"userProfile.community": "Gemeinschaft",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Copy Image",
|
||||
"operation.create": "Create",
|
||||
"operation.deSelectAll": "Deselect All",
|
||||
"operation.decrement": "Decrement",
|
||||
"operation.delete": "Delete",
|
||||
"operation.deleteApp": "Delete App",
|
||||
"operation.deleteConfirmTitle": "Delete?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Image copied",
|
||||
"operation.imageDownloaded": "Image downloaded",
|
||||
"operation.in": "in",
|
||||
"operation.increment": "Increment",
|
||||
"operation.learnMore": "Learn More",
|
||||
"operation.lineBreak": "Line break",
|
||||
"operation.log": "Log",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "dark",
|
||||
"theme.light": "light",
|
||||
"theme.theme": "Theme",
|
||||
"toast.close": "Dismiss notification",
|
||||
"toast.notifications": "Notifications",
|
||||
"unit.char": "chars",
|
||||
"userProfile.about": "About",
|
||||
"userProfile.community": "Community",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Copiar imagen",
|
||||
"operation.create": "Crear",
|
||||
"operation.deSelectAll": "Deseleccionar todo",
|
||||
"operation.decrement": "Disminuir",
|
||||
"operation.delete": "Eliminar",
|
||||
"operation.deleteApp": "Eliminar aplicación",
|
||||
"operation.deleteConfirmTitle": "¿Eliminar?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Imagen copiada",
|
||||
"operation.imageDownloaded": "Imagen descargada",
|
||||
"operation.in": "en",
|
||||
"operation.increment": "Incrementar",
|
||||
"operation.learnMore": "Aprender más",
|
||||
"operation.lineBreak": "Salto de línea",
|
||||
"operation.log": "Registro",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "noche",
|
||||
"theme.light": "luz",
|
||||
"theme.theme": "Tema",
|
||||
"toast.close": "Cerrar notificación",
|
||||
"toast.notifications": "Notificaciones",
|
||||
"unit.char": "caracteres",
|
||||
"userProfile.about": "Acerca de",
|
||||
"userProfile.community": "Comunidad",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "کپی تصویر",
|
||||
"operation.create": "ایجاد",
|
||||
"operation.deSelectAll": "همه را انتخاب نکنید",
|
||||
"operation.decrement": "کاهش",
|
||||
"operation.delete": "حذف",
|
||||
"operation.deleteApp": "حذف برنامه",
|
||||
"operation.deleteConfirmTitle": "حذف شود؟",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "تصویر کپی شده",
|
||||
"operation.imageDownloaded": "تصویر دانلود شد",
|
||||
"operation.in": "در",
|
||||
"operation.increment": "افزایش",
|
||||
"operation.learnMore": "اطلاعات بیشتر",
|
||||
"operation.lineBreak": "خط جدید",
|
||||
"operation.log": "گزارش",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "تاریک",
|
||||
"theme.light": "نور",
|
||||
"theme.theme": "تم",
|
||||
"toast.close": "بستن اعلان",
|
||||
"toast.notifications": "اعلانها",
|
||||
"unit.char": "کاراکتر",
|
||||
"userProfile.about": "درباره",
|
||||
"userProfile.community": "انجمن",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Copier l’image",
|
||||
"operation.create": "Créer",
|
||||
"operation.deSelectAll": "Désélectionner tout",
|
||||
"operation.decrement": "Décrémenter",
|
||||
"operation.delete": "Supprimer",
|
||||
"operation.deleteApp": "Supprimer l’application",
|
||||
"operation.deleteConfirmTitle": "Supprimer ?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Image copied",
|
||||
"operation.imageDownloaded": "Image téléchargée",
|
||||
"operation.in": "dans",
|
||||
"operation.increment": "Incrémenter",
|
||||
"operation.learnMore": "En savoir plus",
|
||||
"operation.lineBreak": "Saut de ligne",
|
||||
"operation.log": "Journal",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "sombre",
|
||||
"theme.light": "lumière",
|
||||
"theme.theme": "Thème",
|
||||
"toast.close": "Fermer la notification",
|
||||
"toast.notifications": "Notifications",
|
||||
"unit.char": "caractères",
|
||||
"userProfile.about": "À propos",
|
||||
"userProfile.community": "Communauté",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "छवि कॉपी करें",
|
||||
"operation.create": "बनाएं",
|
||||
"operation.deSelectAll": "सभी चयन हटाएँ",
|
||||
"operation.decrement": "घटाएं",
|
||||
"operation.delete": "हटाएं",
|
||||
"operation.deleteApp": "ऐप हटाएं",
|
||||
"operation.deleteConfirmTitle": "हटाएं?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "कॉपी की गई छवि",
|
||||
"operation.imageDownloaded": "छवि डाउनलोड की गई",
|
||||
"operation.in": "में",
|
||||
"operation.increment": "बढ़ाएं",
|
||||
"operation.learnMore": "अधिक जानें",
|
||||
"operation.lineBreak": "लाइन ब्रेक",
|
||||
"operation.log": "लॉग",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "अंधेरा",
|
||||
"theme.light": "रोशनी",
|
||||
"theme.theme": "थीम",
|
||||
"toast.close": "सूचना बंद करें",
|
||||
"toast.notifications": "सूचनाएं",
|
||||
"unit.char": "वर्ण",
|
||||
"userProfile.about": "के बारे में",
|
||||
"userProfile.community": "समुदाय",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Salin Gambar",
|
||||
"operation.create": "Menciptakan",
|
||||
"operation.deSelectAll": "Batalkan pilihan Semua",
|
||||
"operation.decrement": "Kurangi",
|
||||
"operation.delete": "Menghapus",
|
||||
"operation.deleteApp": "Hapus Aplikasi",
|
||||
"operation.deleteConfirmTitle": "Hapus?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Gambar yang disalin",
|
||||
"operation.imageDownloaded": "Gambar diunduh",
|
||||
"operation.in": "di",
|
||||
"operation.increment": "Tambah",
|
||||
"operation.learnMore": "Pelajari lebih lanjut",
|
||||
"operation.lineBreak": "Baris Baru",
|
||||
"operation.log": "Batang",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "Gelap",
|
||||
"theme.light": "Terang",
|
||||
"theme.theme": "Tema",
|
||||
"toast.close": "Tutup notifikasi",
|
||||
"toast.notifications": "Notifikasi",
|
||||
"unit.char": "karakter",
|
||||
"userProfile.about": "Tentang",
|
||||
"userProfile.community": "Masyarakat",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Copia immagine",
|
||||
"operation.create": "Crea",
|
||||
"operation.deSelectAll": "Deseleziona tutto",
|
||||
"operation.decrement": "Decrementa",
|
||||
"operation.delete": "Elimina",
|
||||
"operation.deleteApp": "Elimina app",
|
||||
"operation.deleteConfirmTitle": "Eliminare?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Immagine copiata",
|
||||
"operation.imageDownloaded": "Immagine scaricata",
|
||||
"operation.in": "in",
|
||||
"operation.increment": "Incrementa",
|
||||
"operation.learnMore": "Scopri di più",
|
||||
"operation.lineBreak": "A capo",
|
||||
"operation.log": "Log",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "scuro",
|
||||
"theme.light": "luce",
|
||||
"theme.theme": "Tema",
|
||||
"toast.close": "Chiudi notifica",
|
||||
"toast.notifications": "Notifiche",
|
||||
"unit.char": "caratteri",
|
||||
"userProfile.about": "Informazioni",
|
||||
"userProfile.community": "Comunità",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "画像をコピー",
|
||||
"operation.create": "作成",
|
||||
"operation.deSelectAll": "すべて選択解除",
|
||||
"operation.decrement": "減らす",
|
||||
"operation.delete": "削除",
|
||||
"operation.deleteApp": "アプリを削除",
|
||||
"operation.deleteConfirmTitle": "削除しますか?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "コピーした画像",
|
||||
"operation.imageDownloaded": "画像がダウンロードされました",
|
||||
"operation.in": "中",
|
||||
"operation.increment": "増やす",
|
||||
"operation.learnMore": "詳細はこちら",
|
||||
"operation.lineBreak": "改行",
|
||||
"operation.log": "ログ",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "暗い",
|
||||
"theme.light": "明るい",
|
||||
"theme.theme": "テーマ",
|
||||
"toast.close": "通知を閉じる",
|
||||
"toast.notifications": "通知",
|
||||
"unit.char": "文字",
|
||||
"userProfile.about": "Dify について",
|
||||
"userProfile.community": "コミュニティ",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "이미지 복사",
|
||||
"operation.create": "생성",
|
||||
"operation.deSelectAll": "모두 선택 해제",
|
||||
"operation.decrement": "감소",
|
||||
"operation.delete": "삭제",
|
||||
"operation.deleteApp": "앱 삭제",
|
||||
"operation.deleteConfirmTitle": "삭제하시겠습니까?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "복사된 이미지",
|
||||
"operation.imageDownloaded": "이미지 다운로드됨",
|
||||
"operation.in": "안으로",
|
||||
"operation.increment": "증가",
|
||||
"operation.learnMore": "자세히 알아보기",
|
||||
"operation.lineBreak": "줄 바꿈",
|
||||
"operation.log": "로그",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "어두운",
|
||||
"theme.light": "밝은",
|
||||
"theme.theme": "테마",
|
||||
"toast.close": "알림 닫기",
|
||||
"toast.notifications": "알림",
|
||||
"unit.char": "문자",
|
||||
"userProfile.about": "Dify 소개",
|
||||
"userProfile.community": "커뮤니티",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Copy Image",
|
||||
"operation.create": "Create",
|
||||
"operation.deSelectAll": "Deselect All",
|
||||
"operation.decrement": "Verlagen",
|
||||
"operation.delete": "Delete",
|
||||
"operation.deleteApp": "Delete App",
|
||||
"operation.deleteConfirmTitle": "Delete?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Image copied",
|
||||
"operation.imageDownloaded": "Image downloaded",
|
||||
"operation.in": "in",
|
||||
"operation.increment": "Verhogen",
|
||||
"operation.learnMore": "Learn More",
|
||||
"operation.lineBreak": "Line break",
|
||||
"operation.log": "Log",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "dark",
|
||||
"theme.light": "light",
|
||||
"theme.theme": "Theme",
|
||||
"toast.close": "Melding sluiten",
|
||||
"toast.notifications": "Meldingen",
|
||||
"unit.char": "chars",
|
||||
"userProfile.about": "About",
|
||||
"userProfile.community": "Community",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Kopiuj obraz",
|
||||
"operation.create": "Utwórz",
|
||||
"operation.deSelectAll": "Odznacz wszystkie",
|
||||
"operation.decrement": "Zmniejsz",
|
||||
"operation.delete": "Usuń",
|
||||
"operation.deleteApp": "Usuń aplikację",
|
||||
"operation.deleteConfirmTitle": "Usunąć?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Skopiowany obraz",
|
||||
"operation.imageDownloaded": "Obraz pobrany",
|
||||
"operation.in": "w",
|
||||
"operation.increment": "Zwiększ",
|
||||
"operation.learnMore": "Dowiedz się więcej",
|
||||
"operation.lineBreak": "Złamanie linii",
|
||||
"operation.log": "Dziennik",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "ciemny",
|
||||
"theme.light": "światło",
|
||||
"theme.theme": "Temat",
|
||||
"toast.close": "Odrzuć powiadomienie",
|
||||
"toast.notifications": "Powiadomienia",
|
||||
"unit.char": "znaki",
|
||||
"userProfile.about": "O",
|
||||
"userProfile.community": "Społeczność",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Copiar imagem",
|
||||
"operation.create": "Criar",
|
||||
"operation.deSelectAll": "Desmarcar tudo",
|
||||
"operation.decrement": "Diminuir",
|
||||
"operation.delete": "Excluir",
|
||||
"operation.deleteApp": "Excluir aplicativo",
|
||||
"operation.deleteConfirmTitle": "Excluir?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Imagem copiada",
|
||||
"operation.imageDownloaded": "Imagem baixada",
|
||||
"operation.in": "em",
|
||||
"operation.increment": "Aumentar",
|
||||
"operation.learnMore": "Saiba Mais",
|
||||
"operation.lineBreak": "Quebra de linha",
|
||||
"operation.log": "Log",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "escuro",
|
||||
"theme.light": "luz",
|
||||
"theme.theme": "Tema",
|
||||
"toast.close": "Dispensar notificação",
|
||||
"toast.notifications": "Notificações",
|
||||
"unit.char": "caracteres",
|
||||
"userProfile.about": "Sobre",
|
||||
"userProfile.community": "Comunidade",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Copiere imagine",
|
||||
"operation.create": "Creează",
|
||||
"operation.deSelectAll": "Deselectați tot",
|
||||
"operation.decrement": "Decrementare",
|
||||
"operation.delete": "Șterge",
|
||||
"operation.deleteApp": "Ștergeți aplicația",
|
||||
"operation.deleteConfirmTitle": "Ștergere?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Imagine copiată",
|
||||
"operation.imageDownloaded": "Imagine descărcată",
|
||||
"operation.in": "în",
|
||||
"operation.increment": "Incrementare",
|
||||
"operation.learnMore": "Află mai multe",
|
||||
"operation.lineBreak": "Linie nouă",
|
||||
"operation.log": "Jurnal",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "întunecat",
|
||||
"theme.light": "lumina",
|
||||
"theme.theme": "Temă",
|
||||
"toast.close": "Închide notificarea",
|
||||
"toast.notifications": "Notificări",
|
||||
"unit.char": "caractere",
|
||||
"userProfile.about": "Despre",
|
||||
"userProfile.community": "Comunitate",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Скопировать изображение",
|
||||
"operation.create": "Создать",
|
||||
"operation.deSelectAll": "Снять выделение со всех",
|
||||
"operation.decrement": "Уменьшить",
|
||||
"operation.delete": "Удалить",
|
||||
"operation.deleteApp": "Удалить приложение",
|
||||
"operation.deleteConfirmTitle": "Удалить?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Скопированное изображение",
|
||||
"operation.imageDownloaded": "Изображение загружено",
|
||||
"operation.in": "в",
|
||||
"operation.increment": "Увеличить",
|
||||
"operation.learnMore": "Узнать больше",
|
||||
"operation.lineBreak": "Разрыв строки",
|
||||
"operation.log": "Журнал",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "темный",
|
||||
"theme.light": "свет",
|
||||
"theme.theme": "Тема",
|
||||
"toast.close": "Закрыть уведомление",
|
||||
"toast.notifications": "Уведомления",
|
||||
"unit.char": "символов",
|
||||
"userProfile.about": "О нас",
|
||||
"userProfile.community": "Сообщество",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Kopiraj sliko",
|
||||
"operation.create": "Ustvari",
|
||||
"operation.deSelectAll": "Odberi vse",
|
||||
"operation.decrement": "Zmanjšaj",
|
||||
"operation.delete": "Izbriši",
|
||||
"operation.deleteApp": "Izbriši aplikacijo",
|
||||
"operation.deleteConfirmTitle": "Izbrisati?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Kopirana slika",
|
||||
"operation.imageDownloaded": "Slika prenesena",
|
||||
"operation.in": "v",
|
||||
"operation.increment": "Povečaj",
|
||||
"operation.learnMore": "Izvedi več",
|
||||
"operation.lineBreak": "Prelom vrstice",
|
||||
"operation.log": "Dnevnik",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "temno",
|
||||
"theme.light": "svetloba",
|
||||
"theme.theme": "Tema",
|
||||
"toast.close": "Zapri obvestilo",
|
||||
"toast.notifications": "Obvestila",
|
||||
"unit.char": "znaki",
|
||||
"userProfile.about": "O nas",
|
||||
"userProfile.community": "Skupnost",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "คัดลอกรูปภาพ",
|
||||
"operation.create": "สร้าง",
|
||||
"operation.deSelectAll": "ยกเลิกการเลือกทั้งหมด",
|
||||
"operation.decrement": "ลดลง",
|
||||
"operation.delete": "ลบ",
|
||||
"operation.deleteApp": "ลบแอพ",
|
||||
"operation.deleteConfirmTitle": "ลบหรือไม่?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "ภาพที่คัดลอก",
|
||||
"operation.imageDownloaded": "ดาวน์โหลดรูปภาพแล้ว",
|
||||
"operation.in": "ใน",
|
||||
"operation.increment": "เพิ่มขึ้น",
|
||||
"operation.learnMore": "ศึกษาเพิ่มเติม",
|
||||
"operation.lineBreak": "ตัวแบ่งบรรทัด",
|
||||
"operation.log": "ซุง",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "มืด",
|
||||
"theme.light": "แสง",
|
||||
"theme.theme": "ธีม",
|
||||
"toast.close": "ปิดการแจ้งเตือน",
|
||||
"toast.notifications": "การแจ้งเตือน",
|
||||
"unit.char": "รถ ถัง",
|
||||
"userProfile.about": "ประมาณ",
|
||||
"userProfile.community": "ชุมชน",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Resmi Kopyala",
|
||||
"operation.create": "Oluştur",
|
||||
"operation.deSelectAll": "Hepsini Seçme",
|
||||
"operation.decrement": "Azalt",
|
||||
"operation.delete": "Sil",
|
||||
"operation.deleteApp": "Uygulamayı Sil",
|
||||
"operation.deleteConfirmTitle": "Silinsin mi?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Kopyalanan görüntü",
|
||||
"operation.imageDownloaded": "Resim indirildi",
|
||||
"operation.in": "içinde",
|
||||
"operation.increment": "Artır",
|
||||
"operation.learnMore": "Daha Fazla Bilgi",
|
||||
"operation.lineBreak": "Satır sonu",
|
||||
"operation.log": "log",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "koyu",
|
||||
"theme.light": "ışık",
|
||||
"theme.theme": "Tema",
|
||||
"toast.close": "Bildirimi kapat",
|
||||
"toast.notifications": "Bildirimler",
|
||||
"unit.char": "karakter",
|
||||
"userProfile.about": "Hakkında",
|
||||
"userProfile.community": "Topluluk",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Скопіювати зображення",
|
||||
"operation.create": "Створити",
|
||||
"operation.deSelectAll": "Вимкнути все",
|
||||
"operation.decrement": "Зменшити",
|
||||
"operation.delete": "Видалити",
|
||||
"operation.deleteApp": "Видалити програму",
|
||||
"operation.deleteConfirmTitle": "Видалити?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Скопійоване зображення",
|
||||
"operation.imageDownloaded": "Зображення завантажено",
|
||||
"operation.in": "В",
|
||||
"operation.increment": "Збільшити",
|
||||
"operation.learnMore": "Дізнатися більше",
|
||||
"operation.lineBreak": "Перенесення рядка",
|
||||
"operation.log": "Журнал",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "темний",
|
||||
"theme.light": "світло",
|
||||
"theme.theme": "Тема",
|
||||
"toast.close": "Закрити сповіщення",
|
||||
"toast.notifications": "Сповіщення",
|
||||
"unit.char": "символів",
|
||||
"userProfile.about": "Про нас",
|
||||
"userProfile.community": "Спільнота",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "Sao chép hình ảnh",
|
||||
"operation.create": "Tạo mới",
|
||||
"operation.deSelectAll": "Bỏ chọn tất cả",
|
||||
"operation.decrement": "Giảm",
|
||||
"operation.delete": "Xóa",
|
||||
"operation.deleteApp": "Xóa ứng dụng",
|
||||
"operation.deleteConfirmTitle": "Xóa?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "Hình ảnh sao chép",
|
||||
"operation.imageDownloaded": "Hình ảnh đã được tải xuống",
|
||||
"operation.in": "trong",
|
||||
"operation.increment": "Tăng",
|
||||
"operation.learnMore": "Tìm hiểu thêm",
|
||||
"operation.lineBreak": "Ngắt dòng",
|
||||
"operation.log": "Nhật ký",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "tối",
|
||||
"theme.light": "ánh sáng",
|
||||
"theme.theme": "Chủ đề",
|
||||
"toast.close": "Bỏ qua thông báo",
|
||||
"toast.notifications": "Thông báo",
|
||||
"unit.char": "ký tự",
|
||||
"userProfile.about": "Về chúng tôi",
|
||||
"userProfile.community": "Cộng đồng",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "复制图片",
|
||||
"operation.create": "创建",
|
||||
"operation.deSelectAll": "取消全选",
|
||||
"operation.decrement": "减少",
|
||||
"operation.delete": "删除",
|
||||
"operation.deleteApp": "删除应用",
|
||||
"operation.deleteConfirmTitle": "删除?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "图片已复制",
|
||||
"operation.imageDownloaded": "图片已下载",
|
||||
"operation.in": "在",
|
||||
"operation.increment": "增加",
|
||||
"operation.learnMore": "了解更多",
|
||||
"operation.lineBreak": "换行",
|
||||
"operation.log": "日志",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "深色",
|
||||
"theme.light": "浅色",
|
||||
"theme.theme": "主题",
|
||||
"toast.close": "关闭通知",
|
||||
"toast.notifications": "通知",
|
||||
"unit.char": "个字符",
|
||||
"userProfile.about": "关于",
|
||||
"userProfile.community": "社区",
|
||||
|
||||
@@ -438,6 +438,7 @@
|
||||
"operation.copyImage": "複製圖像",
|
||||
"operation.create": "建立",
|
||||
"operation.deSelectAll": "全不選",
|
||||
"operation.decrement": "減少",
|
||||
"operation.delete": "刪除",
|
||||
"operation.deleteApp": "刪除應用程式",
|
||||
"operation.deleteConfirmTitle": "刪除?",
|
||||
@@ -451,6 +452,7 @@
|
||||
"operation.imageCopied": "複製的圖片",
|
||||
"operation.imageDownloaded": "圖片已下載",
|
||||
"operation.in": "在",
|
||||
"operation.increment": "增加",
|
||||
"operation.learnMore": "瞭解更多",
|
||||
"operation.lineBreak": "換行",
|
||||
"operation.log": "日誌",
|
||||
@@ -587,6 +589,8 @@
|
||||
"theme.dark": "黑暗",
|
||||
"theme.light": "光",
|
||||
"theme.theme": "主題",
|
||||
"toast.close": "關閉通知",
|
||||
"toast.notifications": "通知",
|
||||
"unit.char": "個字元",
|
||||
"userProfile.about": "關於",
|
||||
"userProfile.community": "社群",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.13.0",
|
||||
"version": "1.13.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"imports": {
|
||||
|
||||
Reference in New Issue
Block a user