mirror of
https://github.com/langgenius/dify.git
synced 2026-03-16 20:57:03 +00:00
Compare commits
37 Commits
optional-p
...
refactor/b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c18a8ab528 | ||
|
|
0132837443 | ||
|
|
bfeac060a4 | ||
|
|
5d955cd91d | ||
|
|
045e6fe005 | ||
|
|
7f5c65d2eb | ||
|
|
fc070c6953 | ||
|
|
6f59a7ad06 | ||
|
|
3ee1081887 | ||
|
|
337c429447 | ||
|
|
d4ee97fc19 | ||
|
|
d54293106e | ||
|
|
1c62b4607f | ||
|
|
04348e3513 | ||
|
|
0c0364d2f5 | ||
|
|
fb091b01f4 | ||
|
|
796b2f5366 | ||
|
|
f0c7bd0f20 | ||
|
|
cd6890bd1b | ||
|
|
e445f69604 | ||
|
|
bcd563178d | ||
|
|
6a31a98a37 | ||
|
|
7dba5e2d29 | ||
|
|
755fd2f280 | ||
|
|
7c3e1c3c76 | ||
|
|
e716bf1b70 | ||
|
|
bb6522210f | ||
|
|
c7f86dba09 | ||
|
|
6da802eb2a | ||
|
|
c3ee83645f | ||
|
|
4a090876f1 | ||
|
|
598189d307 | ||
|
|
1f3fa95e2c | ||
|
|
0d72d99263 | ||
|
|
57d476d4e2 | ||
|
|
4822d550b6 | ||
|
|
041d7ffe3d |
5
.github/workflows/main-ci.yml
vendored
5
.github/workflows/main-ci.yml
vendored
@@ -63,8 +63,9 @@ jobs:
|
||||
if: needs.check-changes.outputs.web-changed == 'true'
|
||||
uses: ./.github/workflows/web-tests.yml
|
||||
with:
|
||||
base_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
||||
head_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
base_sha: ${{ github.event.before || github.event.pull_request.base.sha }}
|
||||
diff_range_mode: ${{ github.event.before && 'exact' || 'merge-base' }}
|
||||
head_sha: ${{ github.event.after || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
style-check:
|
||||
name: Style Check
|
||||
|
||||
4
.github/workflows/web-tests.yml
vendored
4
.github/workflows/web-tests.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
base_sha:
|
||||
required: false
|
||||
type: string
|
||||
diff_range_mode:
|
||||
required: false
|
||||
type: string
|
||||
head_sha:
|
||||
required: false
|
||||
type: string
|
||||
@@ -89,6 +92,7 @@ jobs:
|
||||
- name: Check app/components diff coverage
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
run: node ./scripts/check-components-diff-coverage.mjs
|
||||
|
||||
|
||||
@@ -737,24 +737,25 @@ SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000
|
||||
|
||||
|
||||
# Redis URL used for PubSub between API and
|
||||
# Redis URL used for event bus between API and
|
||||
# celery worker
|
||||
# defaults to url constructed from `REDIS_*`
|
||||
# configurations
|
||||
PUBSUB_REDIS_URL=
|
||||
# Pub/sub channel type for streaming events.
|
||||
# valid options are:
|
||||
EVENT_BUS_REDIS_URL=
|
||||
# Event transport type. Options are:
|
||||
#
|
||||
# - pubsub: for normal Pub/Sub
|
||||
# - sharded: for sharded Pub/Sub
|
||||
# - pubsub: normal Pub/Sub (at-most-once)
|
||||
# - sharded: sharded Pub/Sub (at-most-once)
|
||||
# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races)
|
||||
#
|
||||
# It's highly recommended to use sharded Pub/Sub AND redis cluster
|
||||
# for large deployments.
|
||||
PUBSUB_REDIS_CHANNEL_TYPE=pubsub
|
||||
# Whether to use Redis cluster mode while running
|
||||
# PubSub.
|
||||
# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs.
|
||||
# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce
|
||||
# the risk of data loss from Redis auto-eviction under memory pressure.
|
||||
# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE.
|
||||
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
|
||||
# Whether to use Redis cluster mode while use redis as event bus.
|
||||
# It's highly recommended to enable this for large deployments.
|
||||
PUBSUB_REDIS_USE_CLUSTERS=false
|
||||
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
||||
|
||||
# Whether to Enable human input timeout check task
|
||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||
|
||||
@@ -41,10 +41,10 @@ class RedisPubSubConfig(BaseSettings, RedisConfigDefaultsMixin):
|
||||
)
|
||||
|
||||
PUBSUB_REDIS_USE_CLUSTERS: bool = Field(
|
||||
validation_alias=AliasChoices("EVENT_BUS_REDIS_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"),
|
||||
validation_alias=AliasChoices("EVENT_BUS_REDIS_USE_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"),
|
||||
description=(
|
||||
"Enable Redis Cluster mode for pub/sub or streams transport. Recommended for large deployments. "
|
||||
"Also accepts ENV: EVENT_BUS_REDIS_CLUSTERS."
|
||||
"Also accepts ENV: EVENT_BUS_REDIS_USE_CLUSTERS."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
@@ -55,15 +55,31 @@ def build_protected_resource_metadata_discovery_urls(
|
||||
"""
|
||||
urls = []
|
||||
|
||||
parsed_server_url = urlparse(server_url)
|
||||
base_url = f"{parsed_server_url.scheme}://{parsed_server_url.netloc}"
|
||||
path = parsed_server_url.path.rstrip("/")
|
||||
|
||||
# First priority: URL from WWW-Authenticate header
|
||||
if www_auth_resource_metadata_url:
|
||||
urls.append(www_auth_resource_metadata_url)
|
||||
parsed_metadata_url = urlparse(www_auth_resource_metadata_url)
|
||||
normalized_metadata_url = None
|
||||
if parsed_metadata_url.scheme and parsed_metadata_url.netloc:
|
||||
normalized_metadata_url = www_auth_resource_metadata_url
|
||||
elif not parsed_metadata_url.scheme and parsed_metadata_url.netloc:
|
||||
normalized_metadata_url = f"{parsed_server_url.scheme}:{www_auth_resource_metadata_url}"
|
||||
elif (
|
||||
not parsed_metadata_url.scheme
|
||||
and not parsed_metadata_url.netloc
|
||||
and parsed_metadata_url.path.startswith("/")
|
||||
):
|
||||
first_segment = parsed_metadata_url.path.lstrip("/").split("/", 1)[0]
|
||||
if first_segment == ".well-known" or "." not in first_segment:
|
||||
normalized_metadata_url = urljoin(base_url, parsed_metadata_url.path)
|
||||
|
||||
if normalized_metadata_url:
|
||||
urls.append(normalized_metadata_url)
|
||||
|
||||
# Fallback: construct from server URL
|
||||
parsed = urlparse(server_url)
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
path = parsed.path.rstrip("/")
|
||||
|
||||
# Priority 2: With path insertion (e.g., /.well-known/oauth-protected-resource/public/mcp)
|
||||
if path:
|
||||
path_url = f"{base_url}/.well-known/oauth-protected-resource{path}"
|
||||
|
||||
@@ -6,6 +6,5 @@ of responses based on upstream node outputs and constants.
|
||||
"""
|
||||
|
||||
from .coordinator import ResponseStreamCoordinator
|
||||
from .session import RESPONSE_SESSION_NODE_TYPES
|
||||
|
||||
__all__ = ["RESPONSE_SESSION_NODE_TYPES", "ResponseStreamCoordinator"]
|
||||
__all__ = ["ResponseStreamCoordinator"]
|
||||
|
||||
@@ -3,10 +3,6 @@ Internal response session management for response coordinator.
|
||||
|
||||
This module contains the private ResponseSession class used internally
|
||||
by ResponseStreamCoordinator to manage streaming sessions.
|
||||
|
||||
`RESPONSE_SESSION_NODE_TYPES` is intentionally mutable so downstream applications
|
||||
can opt additional response-capable node types into session creation without
|
||||
patching the coordinator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,7 +10,6 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, cast
|
||||
|
||||
from dify_graph.enums import BuiltinNodeTypes, NodeType
|
||||
from dify_graph.nodes.base.template import Template
|
||||
from dify_graph.runtime.graph_runtime_state import NodeProtocol
|
||||
|
||||
@@ -25,12 +20,6 @@ class _ResponseSessionNodeProtocol(NodeProtocol, Protocol):
|
||||
def get_streaming_template(self) -> Template: ...
|
||||
|
||||
|
||||
RESPONSE_SESSION_NODE_TYPES: list[NodeType] = [
|
||||
BuiltinNodeTypes.ANSWER,
|
||||
BuiltinNodeTypes.END,
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResponseSession:
|
||||
"""
|
||||
@@ -49,8 +38,8 @@ class ResponseSession:
|
||||
Create a ResponseSession from a response-capable node.
|
||||
|
||||
The parameter is typed as `NodeProtocol` because the graph is exposed behind a protocol at the runtime layer.
|
||||
At runtime this must be a node whose `node_type` is listed in `RESPONSE_SESSION_NODE_TYPES`
|
||||
and which implements `get_streaming_template()`.
|
||||
At runtime this must be a node that implements `get_streaming_template()`. The coordinator decides which
|
||||
graph nodes should be treated as response-capable before they reach this factory.
|
||||
|
||||
Args:
|
||||
node: Node from the materialized workflow graph.
|
||||
@@ -59,15 +48,8 @@ class ResponseSession:
|
||||
ResponseSession configured with the node's streaming template
|
||||
|
||||
Raises:
|
||||
TypeError: If node is not a supported response node type.
|
||||
TypeError: If node does not implement the response-session streaming contract.
|
||||
"""
|
||||
if node.node_type not in RESPONSE_SESSION_NODE_TYPES:
|
||||
supported_node_types = ", ".join(RESPONSE_SESSION_NODE_TYPES)
|
||||
raise TypeError(
|
||||
"ResponseSession.from_node only supports node types in "
|
||||
f"RESPONSE_SESSION_NODE_TYPES: {supported_node_types}"
|
||||
)
|
||||
|
||||
response_node = cast(_ResponseSessionNodeProtocol, node)
|
||||
try:
|
||||
template = response_node.get_streaming_template()
|
||||
|
||||
@@ -8,6 +8,8 @@ from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Any, ClassVar, Literal, Self
|
||||
|
||||
import bleach
|
||||
import markdown
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from dify_graph.entities.base_node_data import BaseNodeData
|
||||
@@ -58,6 +60,39 @@ class EmailDeliveryConfig(BaseModel):
|
||||
"""Configuration for email delivery method."""
|
||||
|
||||
URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}"
|
||||
_SUBJECT_NEWLINE_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"[\r\n]+")
|
||||
_ALLOWED_HTML_TAGS: ClassVar[list[str]] = [
|
||||
"a",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"strong",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"ul",
|
||||
]
|
||||
_ALLOWED_HTML_ATTRIBUTES: ClassVar[dict[str, list[str]]] = {
|
||||
"a": ["href", "title"],
|
||||
"td": ["align"],
|
||||
"th": ["align"],
|
||||
}
|
||||
_ALLOWED_PROTOCOLS: ClassVar[list[str]] = ["http", "https", "mailto"]
|
||||
|
||||
recipients: EmailRecipients
|
||||
|
||||
@@ -98,6 +133,43 @@ class EmailDeliveryConfig(BaseModel):
|
||||
return templated_body
|
||||
return variable_pool.convert_template(templated_body).text
|
||||
|
||||
@classmethod
|
||||
def render_markdown_body(cls, body: str) -> str:
|
||||
"""Render markdown to safe HTML for email delivery."""
|
||||
sanitized_markdown = bleach.clean(
|
||||
body,
|
||||
tags=[],
|
||||
attributes={},
|
||||
strip=True,
|
||||
strip_comments=True,
|
||||
)
|
||||
rendered_html = markdown.markdown(
|
||||
sanitized_markdown,
|
||||
extensions=["nl2br", "tables"],
|
||||
extension_configs={"tables": {"use_align_attribute": True}},
|
||||
)
|
||||
return bleach.clean(
|
||||
rendered_html,
|
||||
tags=cls._ALLOWED_HTML_TAGS,
|
||||
attributes=cls._ALLOWED_HTML_ATTRIBUTES,
|
||||
protocols=cls._ALLOWED_PROTOCOLS,
|
||||
strip=True,
|
||||
strip_comments=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def sanitize_subject(cls, subject: str) -> str:
|
||||
"""Sanitize email subject to plain text and prevent CRLF injection."""
|
||||
sanitized_subject = bleach.clean(
|
||||
subject,
|
||||
tags=[],
|
||||
attributes={},
|
||||
strip=True,
|
||||
strip_comments=True,
|
||||
)
|
||||
sanitized_subject = cls._SUBJECT_NEWLINE_PATTERN.sub(" ", sanitized_subject)
|
||||
return " ".join(sanitized_subject.split())
|
||||
|
||||
|
||||
class _DeliveryMethodBase(BaseModel):
|
||||
"""Base delivery method configuration."""
|
||||
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"numpy~=1.26.4",
|
||||
"openpyxl~=3.1.5",
|
||||
"opik~=1.10.37",
|
||||
"litellm==1.82.2", # Pinned to avoid madoka dependency issue
|
||||
"litellm==1.82.2", # Pinned to avoid madoka dependency issue
|
||||
"opentelemetry-api==1.28.0",
|
||||
"opentelemetry-distro==0.49b0",
|
||||
"opentelemetry-exporter-otlp==1.28.0",
|
||||
@@ -91,6 +91,7 @@ dependencies = [
|
||||
"apscheduler>=3.11.0",
|
||||
"weave>=0.52.16",
|
||||
"fastopenapi[flask]>=0.7.0",
|
||||
"bleach~=6.2.0",
|
||||
]
|
||||
# Before adding new dependency, consider place it in
|
||||
# alphabet order (a-z) and suitable group.
|
||||
@@ -118,7 +119,7 @@ dev = [
|
||||
"pytest~=9.0.2",
|
||||
"pytest-benchmark~=5.2.3",
|
||||
"pytest-cov~=7.0.0",
|
||||
"pytest-env~=1.1.3",
|
||||
"pytest-env~=1.6.0",
|
||||
"pytest-mock~=3.15.1",
|
||||
"testcontainers~=4.14.1",
|
||||
"types-aiofiles~=25.1.0",
|
||||
@@ -251,10 +252,7 @@ ignore_errors = true
|
||||
|
||||
[tool.pyrefly]
|
||||
project-includes = ["."]
|
||||
project-excludes = [
|
||||
".venv",
|
||||
"migrations/",
|
||||
]
|
||||
project-excludes = [".venv", "migrations/"]
|
||||
python-platform = "linux"
|
||||
python-version = "3.11.0"
|
||||
infer-with-first-use = false
|
||||
|
||||
@@ -155,13 +155,15 @@ class EmailDeliveryTestHandler:
|
||||
context=context,
|
||||
recipient_email=recipient_email,
|
||||
)
|
||||
subject = render_email_template(method.config.subject, substitutions)
|
||||
subject_template = render_email_template(method.config.subject, substitutions)
|
||||
subject = EmailDeliveryConfig.sanitize_subject(subject_template)
|
||||
templated_body = EmailDeliveryConfig.render_body_template(
|
||||
body=method.config.body,
|
||||
url=substitutions.get("form_link"),
|
||||
variable_pool=context.variable_pool,
|
||||
)
|
||||
body = render_email_template(templated_body, substitutions)
|
||||
body = EmailDeliveryConfig.render_markdown_body(body)
|
||||
|
||||
mail.send(
|
||||
to=recipient_email,
|
||||
|
||||
@@ -111,7 +111,7 @@ def _render_body(
|
||||
url=form_link,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
return body
|
||||
return EmailDeliveryConfig.render_markdown_body(body)
|
||||
|
||||
|
||||
def _load_variable_pool(workflow_run_id: str | None) -> VariablePool | None:
|
||||
@@ -173,10 +173,11 @@ def dispatch_human_input_email_task(form_id: str, node_title: str | None = None,
|
||||
for recipient in job.recipients:
|
||||
form_link = _build_form_link(recipient.token)
|
||||
body = _render_body(job.body, form_link, variable_pool=variable_pool)
|
||||
subject = EmailDeliveryConfig.sanitize_subject(job.subject)
|
||||
|
||||
mail.send(
|
||||
to=recipient.email,
|
||||
subject=job.subject,
|
||||
subject=subject,
|
||||
html=body,
|
||||
)
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ class DifyTestContainers:
|
||||
# Start Dify Plugin Daemon container for plugin management
|
||||
# Dify Plugin Daemon provides plugin lifecycle management and execution
|
||||
logger.info("Initializing Dify Plugin Daemon container...")
|
||||
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.3.0-local").with_network(
|
||||
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.4-local").with_network(
|
||||
self.network
|
||||
)
|
||||
self.dify_plugin_daemon.with_exposed_ports(5002)
|
||||
|
||||
@@ -22,7 +22,7 @@ from controllers.console.extension import (
|
||||
)
|
||||
|
||||
if _NEEDS_METHOD_VIEW_CLEANUP:
|
||||
delattr(builtins, "MethodView")
|
||||
del builtins.MethodView
|
||||
from models.account import AccountStatus
|
||||
from models.api_based_extension import APIBasedExtension
|
||||
|
||||
|
||||
@@ -801,6 +801,27 @@ class TestAuthOrchestration:
|
||||
urls = build_protected_resource_metadata_discovery_urls(None, "https://api.example.com")
|
||||
assert urls == ["https://api.example.com/.well-known/oauth-protected-resource"]
|
||||
|
||||
def test_build_protected_resource_metadata_discovery_urls_with_relative_hint(self):
|
||||
urls = build_protected_resource_metadata_discovery_urls(
|
||||
"/.well-known/oauth-protected-resource/tenant/mcp",
|
||||
"https://api.example.com/tenant/mcp",
|
||||
)
|
||||
assert urls == [
|
||||
"https://api.example.com/.well-known/oauth-protected-resource/tenant/mcp",
|
||||
"https://api.example.com/.well-known/oauth-protected-resource",
|
||||
]
|
||||
|
||||
def test_build_protected_resource_metadata_discovery_urls_ignores_scheme_less_hint(self):
|
||||
urls = build_protected_resource_metadata_discovery_urls(
|
||||
"/openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource/tenant/mcp",
|
||||
"https://openapi-mcp.cn-hangzhou.aliyuncs.com/tenant/mcp",
|
||||
)
|
||||
|
||||
assert urls == [
|
||||
"https://openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource/tenant/mcp",
|
||||
"https://openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource",
|
||||
]
|
||||
|
||||
def test_build_oauth_authorization_server_metadata_discovery_urls(self):
|
||||
# Case 1: with auth_server_url
|
||||
urls = build_oauth_authorization_server_metadata_discovery_urls(
|
||||
|
||||
@@ -4,9 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import dify_graph.graph_engine.response_coordinator.session as response_session_module
|
||||
from dify_graph.enums import BuiltinNodeTypes, NodeExecutionType, NodeState, NodeType
|
||||
from dify_graph.graph_engine.response_coordinator import RESPONSE_SESSION_NODE_TYPES
|
||||
from dify_graph.graph_engine.response_coordinator.session import ResponseSession
|
||||
from dify_graph.nodes.base.template import Template, TextSegment
|
||||
|
||||
@@ -35,28 +33,14 @@ class DummyNodeWithoutStreamingTemplate:
|
||||
self.state = NodeState.UNKNOWN
|
||||
|
||||
|
||||
def test_response_session_from_node_rejects_node_types_outside_allowlist() -> None:
|
||||
"""Unsupported node types are rejected even if they expose a template."""
|
||||
def test_response_session_from_node_accepts_nodes_outside_previous_allowlist() -> None:
|
||||
"""Session creation depends on the streaming-template contract rather than node type."""
|
||||
node = DummyResponseNode(
|
||||
node_id="llm-node",
|
||||
node_type=BuiltinNodeTypes.LLM,
|
||||
template=Template(segments=[TextSegment(text="hello")]),
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="RESPONSE_SESSION_NODE_TYPES"):
|
||||
ResponseSession.from_node(node)
|
||||
|
||||
|
||||
def test_response_session_from_node_supports_downstream_allowlist_extension(monkeypatch) -> None:
|
||||
"""Downstream applications can extend the supported node-type list."""
|
||||
node = DummyResponseNode(
|
||||
node_id="llm-node",
|
||||
node_type=BuiltinNodeTypes.LLM,
|
||||
template=Template(segments=[TextSegment(text="hello")]),
|
||||
)
|
||||
extended_node_types = [*RESPONSE_SESSION_NODE_TYPES, BuiltinNodeTypes.LLM]
|
||||
monkeypatch.setattr(response_session_module, "RESPONSE_SESSION_NODE_TYPES", extended_node_types)
|
||||
|
||||
session = ResponseSession.from_node(node)
|
||||
|
||||
assert session.node_id == "llm-node"
|
||||
|
||||
@@ -14,3 +14,64 @@ def test_render_body_template_replaces_variable_values():
|
||||
result = config.render_body_template(body=config.body, url="https://example.com", variable_pool=variable_pool)
|
||||
|
||||
assert result == "Hello World https://example.com"
|
||||
|
||||
|
||||
def test_render_markdown_body_renders_markdown_to_html():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body("**Bold** and [link](https://example.com)")
|
||||
|
||||
assert "<strong>Bold</strong>" in rendered
|
||||
assert '<a href="https://example.com">link</a>' in rendered
|
||||
|
||||
|
||||
def test_render_markdown_body_sanitizes_unsafe_html():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body(
|
||||
'<script>alert("xss")</script><a href="javascript:alert(1)" onclick="alert(2)">Click</a>'
|
||||
)
|
||||
|
||||
assert "<script" not in rendered
|
||||
assert "<a" not in rendered
|
||||
assert "onclick" not in rendered
|
||||
assert "javascript:" not in rendered
|
||||
assert "Click" in rendered
|
||||
|
||||
|
||||
def test_render_markdown_body_sanitizes_markdown_link_with_javascript_href():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body("[bad](javascript:alert(1)) and [ok](https://example.com)")
|
||||
|
||||
assert "javascript:" not in rendered
|
||||
assert "<a>bad</a>" in rendered
|
||||
assert '<a href="https://example.com">ok</a>' in rendered
|
||||
|
||||
|
||||
def test_render_markdown_body_does_not_allow_raw_html_tags():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body("<b>raw html</b> and **markdown**")
|
||||
|
||||
assert "<b>" not in rendered
|
||||
assert "raw html" in rendered
|
||||
assert "<strong>markdown</strong>" in rendered
|
||||
|
||||
|
||||
def test_render_markdown_body_supports_table_syntax():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body("| h1 | h2 |\n| --- | ---: |\n| v1 | v2 |")
|
||||
|
||||
assert "<table>" in rendered
|
||||
assert "<thead>" in rendered
|
||||
assert "<tbody>" in rendered
|
||||
assert 'align="right"' in rendered
|
||||
assert "style=" not in rendered
|
||||
|
||||
|
||||
def test_sanitize_subject_removes_crlf():
|
||||
sanitized = EmailDeliveryConfig.sanitize_subject("Notice\r\nBCC:attacker@example.com")
|
||||
|
||||
assert "\r" not in sanitized
|
||||
assert "\n" not in sanitized
|
||||
assert sanitized == "Notice BCC:attacker@example.com"
|
||||
|
||||
|
||||
def test_sanitize_subject_removes_html_tags():
|
||||
sanitized = EmailDeliveryConfig.sanitize_subject("<b>Alert</b><img src=x onerror=1>")
|
||||
|
||||
assert "<" not in sanitized
|
||||
assert ">" not in sanitized
|
||||
assert sanitized == "Alert"
|
||||
|
||||
@@ -140,7 +140,7 @@ class TestLoginRequired:
|
||||
|
||||
# Remove ensure_sync to simulate Flask 1.x
|
||||
if hasattr(setup_app, "ensure_sync"):
|
||||
delattr(setup_app, "ensure_sync")
|
||||
del setup_app.ensure_sync
|
||||
|
||||
with setup_app.test_request_context():
|
||||
mock_user = MockUser("test_user", is_authenticated=True)
|
||||
|
||||
@@ -207,6 +207,45 @@ class TestEmailDeliveryTestHandler:
|
||||
assert kwargs["to"] == "test@example.com"
|
||||
assert "RENDERED_Subj" in kwargs["subject"]
|
||||
|
||||
def test_send_test_sanitizes_subject(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
service_module.FeatureService,
|
||||
"get_features",
|
||||
lambda _id: SimpleNamespace(human_input_email_delivery_enabled=True),
|
||||
)
|
||||
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
|
||||
mock_mail_send = MagicMock()
|
||||
monkeypatch.setattr(service_module.mail, "send", mock_mail_send)
|
||||
monkeypatch.setattr(
|
||||
service_module,
|
||||
"render_email_template",
|
||||
lambda template, substitutions: template.replace("{{ recipient_email }}", substitutions["recipient_email"]),
|
||||
)
|
||||
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
handler._resolve_recipients = MagicMock(return_value=["test@example.com"])
|
||||
|
||||
context = DeliveryTestContext(
|
||||
tenant_id="t1",
|
||||
app_id="a1",
|
||||
node_id="n1",
|
||||
node_title="title",
|
||||
rendered_content="content",
|
||||
recipients=[DeliveryTestEmailRecipient(email="test@example.com", form_token="token123")],
|
||||
)
|
||||
method = EmailDeliveryMethod(
|
||||
config=EmailDeliveryConfig(
|
||||
recipients=EmailRecipients(whole_workspace=False, items=[]),
|
||||
subject="<b>Notice</b>\r\nBCC:{{ recipient_email }}",
|
||||
body="Body",
|
||||
)
|
||||
)
|
||||
|
||||
handler.send_test(context=context, method=method)
|
||||
|
||||
_, kwargs = mock_mail_send.call_args
|
||||
assert kwargs["subject"] == "Notice BCC:test@example.com"
|
||||
|
||||
def test_resolve_recipients(self):
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
|
||||
|
||||
@@ -120,4 +120,37 @@ def test_dispatch_human_input_email_task_replaces_body_variables(monkeypatch: py
|
||||
session_factory=lambda: _DummySession(form),
|
||||
)
|
||||
|
||||
assert mail.sent[0]["html"] == "Body OK"
|
||||
assert mail.sent[0]["html"] == "<p>Body OK</p>"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("line_break", ["\r\n", "\r", "\n"])
|
||||
def test_dispatch_human_input_email_task_sanitizes_subject(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
line_break: str,
|
||||
):
|
||||
mail = _DummyMail()
|
||||
form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None)
|
||||
job = task_module._EmailDeliveryJob(
|
||||
form_id="form-1",
|
||||
subject=f"Notice{line_break}BCC:attacker@example.com <b>Alert</b>",
|
||||
body="Body",
|
||||
form_content="content",
|
||||
recipients=[task_module._EmailRecipient(email="user@example.com", token="token-1")],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(task_module, "mail", mail)
|
||||
monkeypatch.setattr(
|
||||
task_module.FeatureService,
|
||||
"get_features",
|
||||
lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=True),
|
||||
)
|
||||
monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [job])
|
||||
monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: None)
|
||||
|
||||
task_module.dispatch_human_input_email_task(
|
||||
form_id="form-1",
|
||||
node_title="Approve",
|
||||
session_factory=lambda: _DummySession(form),
|
||||
)
|
||||
|
||||
assert mail.sent[0]["subject"] == "Notice BCC:attacker@example.com Alert"
|
||||
|
||||
171
api/uv.lock
generated
171
api/uv.lock
generated
@@ -658,6 +658,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/cc/38b6f87170908bd8aaf9e412b021d17e85f690abe00edf50192f1a4566b9/billiard-4.2.3-py3-none-any.whl", hash = "sha256:989e9b688e3abf153f307b68a1328dfacfb954e30a4f920005654e276c69236b", size = 87042, upload-time = "2025-11-16T17:47:29.005Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bleach"
|
||||
version = "6.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406, upload-time = "2024-10-29T18:30:38.186Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
@@ -708,16 +720,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3-stubs"
|
||||
version = "1.41.3"
|
||||
version = "1.42.68"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore-stubs" },
|
||||
{ name = "types-s3transfer" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/5b/6d274aa25f7fa09f8b7defab5cb9389e6496a7d9b76c1efcf27b0b15e868/boto3_stubs-1.41.3.tar.gz", hash = "sha256:c7cc9706ac969c8ea284c2d45ec45b6371745666d087c6c5e7c9d39dafdd48bc", size = 100010, upload-time = "2025-11-24T20:34:27.052Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/8c/dd4b0c95ff008bed5a35ab411452ece121b355539d2a0b6dcd62a0c47be5/boto3_stubs-1.42.68.tar.gz", hash = "sha256:96ad1020735619483fb9b4da7a5e694b460bf2e18f84a34d5d175d0ffe8c4653", size = 101372, upload-time = "2026-03-13T19:49:54.867Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/d6/ef971013d1fc7333c6df322d98ebf4592df9c80e1966fb12732f91e9e71b/boto3_stubs-1.41.3-py3-none-any.whl", hash = "sha256:bec698419b31b499f3740f1dfb6dae6519167d9e3aa536f6f730ed280556230b", size = 69294, upload-time = "2025-11-24T20:34:23.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/15/3ca5848917214a168134512a5b45f856a56e913659888947a052e02031b5/boto3_stubs-1.42.68-py3-none-any.whl", hash = "sha256:ed7f98334ef7b2377fa8532190e63dc2c6d1dc895e3d7cb3d6d1c83771b81bf6", size = 70011, upload-time = "2026-03-13T19:49:42.801Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1529,6 +1541,7 @@ dependencies = [
|
||||
{ name = "arize-phoenix-otel" },
|
||||
{ name = "azure-identity" },
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "bleach" },
|
||||
{ name = "boto3" },
|
||||
{ name = "bs4" },
|
||||
{ name = "cachetools" },
|
||||
@@ -1730,6 +1743,7 @@ requires-dist = [
|
||||
{ name = "arize-phoenix-otel", specifier = "~=0.15.0" },
|
||||
{ name = "azure-identity", specifier = "==1.25.3" },
|
||||
{ name = "beautifulsoup4", specifier = "==4.14.3" },
|
||||
{ name = "bleach", specifier = "~=6.2.0" },
|
||||
{ name = "boto3", specifier = "==1.42.68" },
|
||||
{ name = "bs4", specifier = "~=0.0.1" },
|
||||
{ name = "cachetools", specifier = "~=5.3.0" },
|
||||
@@ -1831,7 +1845,7 @@ dev = [
|
||||
{ name = "pytest", specifier = "~=9.0.2" },
|
||||
{ name = "pytest-benchmark", specifier = "~=5.2.3" },
|
||||
{ name = "pytest-cov", specifier = "~=7.0.0" },
|
||||
{ name = "pytest-env", specifier = "~=1.1.3" },
|
||||
{ name = "pytest-env", specifier = "~=1.6.0" },
|
||||
{ name = "pytest-mock", specifier = "~=3.15.1" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.4.0" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.8.0" },
|
||||
@@ -3143,14 +3157,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hypothesis"
|
||||
version = "6.148.2"
|
||||
version = "6.151.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sortedcontainers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4a/99/a3c6eb3fdd6bfa01433d674b0f12cd9102aa99630689427422d920aea9c6/hypothesis-6.148.2.tar.gz", hash = "sha256:07e65d34d687ddff3e92a3ac6b43966c193356896813aec79f0a611c5018f4b1", size = 469984, upload-time = "2025-11-18T20:21:17.047Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/d2/c2673aca0127e204965e0e9b3b7a0e91e9b12993859ac8758abd22669b89/hypothesis-6.148.2-py3-none-any.whl", hash = "sha256:bf8ddc829009da73b321994b902b1964bcc3e5c3f0ed9a1c1e6a1631ab97c5fa", size = 536986, upload-time = "2025-11-18T20:21:15.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3164,19 +3178,17 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "import-linter"
|
||||
version = "2.10"
|
||||
version = "2.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "grimp" },
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/c4/a83cc1ea9ed0171725c0e2edc11fd929994d4f026028657e8b30d62bca37/import_linter-2.10.tar.gz", hash = "sha256:c6a5057d2dbd32e1854c4d6b60e90dfad459b7ab5356230486d8521f25872963", size = 1149263, upload-time = "2026-02-06T17:57:24.779Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/66/55b697a17bb15c6cb88d97d73716813f5427281527b90f02cc0a600abc6e/import_linter-2.11.tar.gz", hash = "sha256:5abc3394797a54f9bae315e7242dc98715ba485f840ac38c6d3192c370d0085e", size = 1153682, upload-time = "2026-03-06T12:11:38.198Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/e5/4b7b9435eac78ecfd537fa1004a0bcf0f4eac17d3a893f64d38a7bacb51b/import_linter-2.10-py3-none-any.whl", hash = "sha256:cc2ddd7ec0145cbf83f3b25391d2a5dbbf138382aaf80708612497fa6ebc8f60", size = 637081, upload-time = "2026-02-06T17:57:23.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/aa/2ed2c89543632ded7196e0d93dcc6c7fe87769e88391a648c4a298ea864a/import_linter-2.11-py3-none-any.whl", hash = "sha256:3dc54cae933bae3430358c30989762b721c77aa99d424f56a08265be0eeaa465", size = 637315, upload-time = "2026-03-06T12:11:36.599Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3918,14 +3930,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mypy-boto3-bedrock-runtime"
|
||||
version = "1.41.2"
|
||||
version = "1.42.42"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/f1/00aea4f91501728e7af7e899ce3a75d48d6df97daa720db11e46730fa123/mypy_boto3_bedrock_runtime-1.41.2.tar.gz", hash = "sha256:ba2c11f2f18116fd69e70923389ce68378fa1620f70e600efb354395a1a9e0e5", size = 28890, upload-time = "2025-11-21T20:35:30.074Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/bb/65dc1b2c5796a6ab5f60bdb57343bd6c3ecb82251c580eca415c8548333e/mypy_boto3_bedrock_runtime-1.42.42.tar.gz", hash = "sha256:3a4088218478b6fbbc26055c03c95bee4fc04624a801090b3cce3037e8275c8d", size = 29840, upload-time = "2026-02-04T20:53:05.999Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/cc/96a2af58c632701edb5be1dda95434464da43df40ae868a1ab1ddf033839/mypy_boto3_bedrock_runtime-1.41.2-py3-none-any.whl", hash = "sha256:a720ff1e98cf10723c37a61a46cff220b190c55b8fb57d4397e6cf286262cf02", size = 34967, upload-time = "2025-11-21T20:35:27.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/43/7ea062f2228f47b5779dcfa14dab48d6e29f979b35d1a5102b0ba80b9c1b/mypy_boto3_bedrock_runtime-1.42.42-py3-none-any.whl", hash = "sha256:b2d16eae22607d0685f90796b3a0afc78c0b09d45872e00eafd634a31dd9358f", size = 36077, upload-time = "2026-02-04T20:53:01.768Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5514,14 +5526,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-env"
|
||||
version = "1.1.5"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911, upload-time = "2024-09-17T22:39:18.566Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/69/4db1c30625af0621df8dbe73797b38b6d1b04e15d021dd5d26a6d297f78c/pytest_env-1.6.0.tar.gz", hash = "sha256:ac02d6fba16af54d61e311dd70a3c61024a4e966881ea844affc3c8f0bf207d3", size = 16163, upload-time = "2026-03-12T22:39:43.78Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141, upload-time = "2024-09-17T22:39:16.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/16/ad52f56b96d851a2bcfdc1e754c3531341885bd7177a128c13ff2ca72ab4/pytest_env-1.6.0-py3-none-any.whl", hash = "sha256:1e7f8a62215e5885835daaed694de8657c908505b964ec8097a7ce77b403d9a3", size = 10400, upload-time = "2026-03-12T22:39:41.887Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6033,27 +6046,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.5"
|
||||
version = "0.15.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6092,14 +6105,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "scipy-stubs"
|
||||
version = "1.16.3.1"
|
||||
version = "1.17.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "optype", extra = ["numpy"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/3e/8baf960c68f012b8297930d4686b235813974833a417db8d0af798b0b93d/scipy_stubs-1.16.3.1.tar.gz", hash = "sha256:0738d55a7f8b0c94cdb8063f711d53330ebefe166f7d48dec9ffd932a337226d", size = 359990, upload-time = "2025-11-23T23:05:21.274Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/ab/43f681ffba42f363b7ed6b767fd215d1e26006578214ff8330586a11bf95/scipy_stubs-1.17.1.2.tar.gz", hash = "sha256:2ecadc8c87a3b61aaf7379d6d6b10f1038a829c53b9efe5b174fb97fc8b52237", size = 388354, upload-time = "2026-03-15T22:33:20.449Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/39/e2a69866518f88dc01940c9b9b044db97c3387f2826bd2a173e49a5c0469/scipy_stubs-1.16.3.1-py3-none-any.whl", hash = "sha256:69bc52ef6c3f8e09208abdfaf32291eb51e9ddf8fa4389401ccd9473bdd2a26d", size = 560397, upload-time = "2025-11-23T23:05:19.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/0b/ec4fe720c1202d9df729a3e9d9b7e4d2da9f6e7f28bd2877b7d0769f4f75/scipy_stubs-1.17.1.2-py3-none-any.whl", hash = "sha256:f19e8f5273dbe3b7ee6a9554678c3973b9695fa66b91f29206d00830a1536c06", size = 594377, upload-time = "2026-03-15T22:33:18.684Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6788,14 +6801,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-cffi"
|
||||
version = "1.17.0.20250915"
|
||||
version = "2.0.0.20260316"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "types-setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/98/ea454cea03e5f351323af6a482c65924f3c26c515efd9090dede58f2b4b6/types_cffi-1.17.0.20250915.tar.gz", hash = "sha256:4362e20368f78dabd5c56bca8004752cc890e07a71605d9e0d9e069dbaac8c06", size = 17229, upload-time = "2025-09-15T03:01:25.31Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/4c/805b40b094eb3fd60f8d17fa7b3c58a33781311a95d0e6a74da0751ce294/types_cffi-2.0.0.20260316.tar.gz", hash = "sha256:8fb06ed4709675c999853689941133affcd2250cd6121cc11fd22c0d81ad510c", size = 17399, upload-time = "2026-03-16T07:54:43.059Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ec/092f2b74b49ec4855cdb53050deb9699f7105b8fda6fe034c0781b8687f3/types_cffi-1.17.0.20250915-py3-none-any.whl", hash = "sha256:cef4af1116c83359c11bb4269283c50f0688e9fc1d7f0eeb390f3661546da52c", size = 20112, upload-time = "2025-09-15T03:01:24.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5e/9f1a709225ad9d0e1d7a6e4366ff285f0113c749e882d6cbeb40eab32e75/types_cffi-2.0.0.20260316-py3-none-any.whl", hash = "sha256:dd504698029db4c580385f679324621cc64d886e6a23e9821d52bc5169251302", size = 20096, upload-time = "2026-03-16T07:54:41.994Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6827,11 +6840,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-docutils"
|
||||
version = "0.22.3.20260223"
|
||||
version = "0.22.3.20260316"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/33/92c0129283363e3b3ba270bf6a2b7d077d949d2f90afc4abaf6e73578563/types_docutils-0.22.3.20260223.tar.gz", hash = "sha256:e90e868da82df615ea2217cf36dff31f09660daa15fc0f956af53f89c1364501", size = 57230, upload-time = "2026-02-23T04:11:21.806Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/27/a7f16b3a2fad0a4ddd85a668319f9a1d0311c4bd9578894f6471c7e6c788/types_docutils-0.22.3.20260316.tar.gz", hash = "sha256:8ef27d565b9831ff094fe2eac75337a74151013e2d21ecabd445c2955f891564", size = 57263, upload-time = "2026-03-16T04:29:12.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/c7/a4ae6a75d5b07d63089d5c04d450a0de4a5d48ffcb84b95659b22d3885fe/types_docutils-0.22.3.20260223-py3-none-any.whl", hash = "sha256:cc2d6b7560a28e351903db0989091474aa619ad287843a018324baee9c4d9a8f", size = 91969, upload-time = "2026-02-23T04:11:20.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/60/c1f22b7cfc4837d5419e5a2d8702c7d65f03343f866364b71cccd8a73b79/types_docutils-0.22.3.20260316-py3-none-any.whl", hash = "sha256:083c7091b8072c242998ec51da1bf1492f0332387da81c3b085efbf5ca754c7d", size = 91968, upload-time = "2026-03-16T04:29:11.114Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6861,15 +6874,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-gevent"
|
||||
version = "25.9.0.20251102"
|
||||
version = "25.9.0.20251228"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "types-greenlet" },
|
||||
{ name = "types-psutil" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/21/552d818a475e1a31780fb7ae50308feb64211a05eb403491d1a34df95e5f/types_gevent-25.9.0.20251102.tar.gz", hash = "sha256:76f93513af63f4577bb4178c143676dd6c4780abc305f405a4e8ff8f1fa177f8", size = 38096, upload-time = "2025-11-02T03:07:42.112Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/85/c5043c4472f82c8ee3d9e0673eb4093c7d16770a26541a137a53a1d096f6/types_gevent-25.9.0.20251228.tar.gz", hash = "sha256:423ef9891d25c5a3af236c3e9aace4c444c86ff773fe13ef22731bc61d59abef", size = 38063, upload-time = "2025-12-28T03:28:28.651Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/a1/776d2de31a02123f225aaa790641113ae47f738f6e8e3091d3012240a88e/types_gevent-25.9.0.20251102-py3-none-any.whl", hash = "sha256:0f14b9977cb04bf3d94444b5ae6ec5d78ac30f74c4df83483e0facec86f19d8b", size = 55592, upload-time = "2025-11-02T03:07:41.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/b7/a2d6b652ab5a26318b68cafd58c46fafb9b15c5313d2d76a70b838febb4b/types_gevent-25.9.0.20251228-py3-none-any.whl", hash = "sha256:e2e225af4fface9241c16044983eb2fc3993f2d13d801f55c2932848649b7f2f", size = 55486, upload-time = "2025-12-28T03:28:27.382Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6895,11 +6908,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-jmespath"
|
||||
version = "1.0.2.20250809"
|
||||
version = "1.1.0.20260124"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/ff/6848b1603ca47fff317b44dfff78cc1fb0828262f840b3ab951b619d5a22/types_jmespath-1.0.2.20250809.tar.gz", hash = "sha256:e194efec21c0aeae789f701ae25f17c57c25908e789b1123a5c6f8d915b4adff", size = 10248, upload-time = "2025-08-09T03:14:57.996Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/ca/c8d7fc6e450c2f8fc6f510cb194754c43b17f933f2dcabcfc6985cbb97a8/types_jmespath-1.1.0.20260124.tar.gz", hash = "sha256:29d86868e72c0820914577077b27d167dcab08b1fc92157a29d537ff7153fdfe", size = 10709, upload-time = "2026-01-24T03:18:46.557Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/6a/65c8be6b6555beaf1a654ae1c2308c2e19a610c0b318a9730e691b79ac79/types_jmespath-1.0.2.20250809-py3-none-any.whl", hash = "sha256:4147d17cc33454f0dac7e78b4e18e532a1330c518d85f7f6d19e5818ab83da21", size = 11494, upload-time = "2025-08-09T03:14:57.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/91/915c4a6e6e9bd2bca3ec0c21c1771b175c59e204b85e57f3f572370fe753/types_jmespath-1.1.0.20260124-py3-none-any.whl", hash = "sha256:ec387666d446b15624215aa9cbd2867ffd885b6c74246d357c65e830c7a138b3", size = 11509, upload-time = "2026-01-24T03:18:45.536Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6952,20 +6965,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-openpyxl"
|
||||
version = "3.1.5.20250919"
|
||||
version = "3.1.5.20260316"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/12/8bc4a25d49f1e4b7bbca868daa3ee80b1983d8137b4986867b5b65ab2ecd/types_openpyxl-3.1.5.20250919.tar.gz", hash = "sha256:232b5906773eebace1509b8994cdadda043f692cfdba9bfbb86ca921d54d32d7", size = 100880, upload-time = "2025-09-19T02:54:39.997Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/38/32f8ee633dd66ca6d52b8853b9fd45dc3869490195a6ed435d5c868b9c2d/types_openpyxl-3.1.5.20260316.tar.gz", hash = "sha256:081dda9427ea1141e5649e3dcf630e7013a4cf254a5862a7e0a3f53c123b7ceb", size = 101318, upload-time = "2026-03-16T04:29:05.004Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/3c/d49cf3f4489a10e9ddefde18fd258f120754c5825d06d145d9a0aaac770b/types_openpyxl-3.1.5.20250919-py3-none-any.whl", hash = "sha256:bd06f18b12fd5e1c9f0b666ee6151d8140216afa7496f7ebb9fe9d33a1a3ce99", size = 166078, upload-time = "2025-09-19T02:54:38.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/df/b87ae6226ed7cc84b9e43119c489c7f053a9a25e209e0ebb5d84bc36fa37/types_openpyxl-3.1.5.20260316-py3-none-any.whl", hash = "sha256:38e7e125df520fb7eb72cb1129c9f024eb99ef9564aad2c27f68f080c26bcf2d", size = 166084, upload-time = "2026-03-16T04:29:03.657Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-pexpect"
|
||||
version = "4.9.0.20250916"
|
||||
version = "4.9.0.20260127"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/e6/cc43e306dc7de14ec7861c24ac4957f688741ae39ae685049695d796b587/types_pexpect-4.9.0.20250916.tar.gz", hash = "sha256:69e5fed6199687a730a572de780a5749248a4c5df2ff1521e194563475c9928d", size = 13322, upload-time = "2025-09-16T02:49:25.61Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/32/7e03a07e16f79a404d6200ed6bdfcc320d0fb833436a5c6895a1403dedb7/types_pexpect-4.9.0.20260127.tar.gz", hash = "sha256:f8d43efc24251a8e533c71ea9be03d19bb5d08af096d561611697af9720cba7f", size = 13461, upload-time = "2026-01-27T03:28:30.923Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/6d/7740e235a9fb2570968da7d386d7feb511ce68cd23472402ff8cdf7fc78f/types_pexpect-4.9.0.20250916-py3-none-any.whl", hash = "sha256:7fa43cb96042ac58bc74f7c28e5d85782be0ee01344149886849e9d90936fe8a", size = 17057, upload-time = "2025-09-16T02:49:24.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/d9/7ac5c9aa5a89a1a64cd835ae348227f4939406d826e461b85b690a8ba1c2/types_pexpect-4.9.0.20260127-py3-none-any.whl", hash = "sha256:69216c0ebf0fe45ad2900823133959b027e9471e24fc3f2e4c7b00605555da5f", size = 17078, upload-time = "2026-01-27T03:28:29.848Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6988,11 +7001,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-psycopg2"
|
||||
version = "2.9.21.20251012"
|
||||
version = "2.9.21.20260223"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/b3/2d09eaf35a084cffd329c584970a3fa07101ca465c13cad1576d7c392587/types_psycopg2-2.9.21.20251012.tar.gz", hash = "sha256:4cdafd38927da0cfde49804f39ab85afd9c6e9c492800e42f1f0c1a1b0312935", size = 26710, upload-time = "2025-10-12T02:55:39.5Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/1f/4daff0ce5e8e191844e65aaa793ed1b9cb40027dc2700906ecf2b6bcc0ed/types_psycopg2-2.9.21.20260223.tar.gz", hash = "sha256:78ed70de2e56bc6b5c26c8c1da8e9af54e49fdc3c94d1504609f3519e2b84f02", size = 27090, upload-time = "2026-02-23T04:11:18.177Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0c/05feaf8cb51159f2c0af04b871dab7e98a2f83a3622f5f216331d2dd924c/types_psycopg2-2.9.21.20251012-py3-none-any.whl", hash = "sha256:712bad5c423fe979e357edbf40a07ca40ef775d74043de72bd4544ca328cc57e", size = 24883, upload-time = "2025-10-12T02:55:38.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/e7/c566df58410bc0728348b514e718f0b38fa0d248b5c10599a11494ba25d2/types_psycopg2-2.9.21.20260223-py3-none-any.whl", hash = "sha256:c6228ade72d813b0624f4c03feeb89471950ac27cd0506b5debed6f053086bc8", size = 24919, upload-time = "2026-02-23T04:11:17.214Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7009,11 +7022,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-pymysql"
|
||||
version = "1.1.0.20250916"
|
||||
version = "1.1.0.20251220"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/12/bda1d977c07e0e47502bede1c44a986dd45946494d89e005e04cdeb0f8de/types_pymysql-1.1.0.20250916.tar.gz", hash = "sha256:98d75731795fcc06723a192786662bdfa760e1e00f22809c104fbb47bac5e29b", size = 22131, upload-time = "2025-09-16T02:49:22.039Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/e959dd6d2f8e3b3c3f058d79ac9ece328922a5a8770c707fe9c3a757481c/types_pymysql-1.1.0.20251220.tar.gz", hash = "sha256:ae1c3df32a777489431e2e9963880a0df48f6591e0aa2fd3a6fabd9dee6eca54", size = 22184, upload-time = "2025-12-20T03:07:38.689Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/eb/a225e32a6e7b196af67ab2f1b07363595f63255374cc3b88bfdab53b4ee8/types_pymysql-1.1.0.20250916-py3-none-any.whl", hash = "sha256:873eb9836bb5e3de4368cc7010ca72775f86e9692a5c7810f8c7f48da082e55b", size = 23063, upload-time = "2025-09-16T02:49:20.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/fa/4f4d3bfca9ef6dd17d69ed18b96564c53b32d3ce774132308d0bee849f10/types_pymysql-1.1.0.20251220-py3-none-any.whl", hash = "sha256:fa1082af7dea6c53b6caa5784241924b1296ea3a8d3bd060417352c5e10c0618", size = 23067, upload-time = "2025-12-20T03:07:37.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7031,11 +7044,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-python-dateutil"
|
||||
version = "2.9.0.20251115"
|
||||
version = "2.9.0.20260305"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/36/06d01fb52c0d57e9ad0c237654990920fa41195e4b3d640830dabf9eeb2f/types_python_dateutil-2.9.0.20251115.tar.gz", hash = "sha256:8a47f2c3920f52a994056b8786309b43143faa5a64d4cbb2722d6addabdf1a58", size = 16363, upload-time = "2025-11-15T03:00:13.717Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/025c624f347e10476b439a6619a95f1d200250ea88e7ccea6e09e48a7544/types_python_dateutil-2.9.0.20260305.tar.gz", hash = "sha256:389717c9f64d8f769f36d55a01873915b37e97e52ce21928198d210fbd393c8b", size = 16885, upload-time = "2026-03-05T04:00:47.409Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/0b/56961d3ba517ed0df9b3a27bfda6514f3d01b28d499d1bce9068cfe4edd1/types_python_dateutil-2.9.0.20251115-py3-none-any.whl", hash = "sha256:9cf9c1c582019753b8639a081deefd7e044b9fa36bd8217f565c6c4e36ee0624", size = 18251, upload-time = "2025-11-15T03:00:12.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/77/8c0d1ec97f0d9707ad3d8fa270ab8964e7b31b076d2f641c94987395cc75/types_python_dateutil-2.9.0.20260305-py3-none-any.whl", hash = "sha256:a3be9ca444d38cadabd756cfbb29780d8b338ae2a3020e73c266a83cc3025dd7", size = 18419, upload-time = "2026-03-05T04:00:46.392Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7049,11 +7062,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-pywin32"
|
||||
version = "311.0.0.20251008"
|
||||
version = "311.0.0.20260316"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/05/cd94300066241a7abb52238f0dd8d7f4fe1877cf2c72bd1860856604d962/types_pywin32-311.0.0.20251008.tar.gz", hash = "sha256:d6d4faf8e0d7fdc0e0a1ff297b80be07d6d18510f102d793bf54e9e3e86f6d06", size = 329561, upload-time = "2025-10-08T02:51:39.436Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/a8/b4652002a854fcfe5d272872a0ae2d5df0e9dc482e1a6dfb5e97b905b76f/types_pywin32-311.0.0.20260316.tar.gz", hash = "sha256:c136fa489fe6279a13bca167b750414e18d657169b7cf398025856dc363004e8", size = 329956, upload-time = "2026-03-16T04:28:57.366Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/08/00a38e6b71585e6741d5b3b4cc9dd165cf549b6f1ed78815c6585f8b1b58/types_pywin32-311.0.0.20251008-py3-none-any.whl", hash = "sha256:775e1046e0bad6d29ca47501301cce67002f6661b9cebbeca93f9c388c53fab4", size = 392942, upload-time = "2025-10-08T02:51:38.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/83/704698d93788cf1c2f5e236eae2b37f1b2152ef84dc66b4b83f6c7487b76/types_pywin32-311.0.0.20260316-py3-none-any.whl", hash = "sha256:abb643d50012386d697af49384cc0e6e475eab76b0ca2a7f93d480d0862b3692", size = 392959, upload-time = "2026-03-16T04:28:56.104Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7110,11 +7123,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-setuptools"
|
||||
version = "80.9.0.20250822"
|
||||
version = "82.0.0.20260210"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/bd/1e5f949b7cb740c9f0feaac430e301b8f1c5f11a81e26324299ea671a237/types_setuptools-80.9.0.20250822.tar.gz", hash = "sha256:070ea7716968ec67a84c7f7768d9952ff24d28b65b6594797a464f1b3066f965", size = 41296, upload-time = "2025-08-22T03:02:08.771Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/90/796ac8c774a7f535084aacbaa6b7053d16fff5c630eff87c3ecff7896c37/types_setuptools-82.0.0.20260210.tar.gz", hash = "sha256:d9719fbbeb185254480ade1f25327c4654f8c00efda3fec36823379cebcdee58", size = 44768, upload-time = "2026-02-10T04:22:02.107Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2d/475bf15c1cdc172e7a0d665b6e373ebfb1e9bf734d3f2f543d668b07a142/types_setuptools-80.9.0.20250822-py3-none-any.whl", hash = "sha256:53bf881cb9d7e46ed12c76ef76c0aaf28cfe6211d3fab12e0b83620b1a8642c3", size = 63179, upload-time = "2025-08-22T03:02:07.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/54/3489432b1d9bc713c9d8aa810296b8f5b0088403662959fb63a8acdbd4fc/types_setuptools-82.0.0.20260210-py3-none-any.whl", hash = "sha256:5124a7daf67f195c6054e0f00f1d97c69caad12fdcf9113eba33eff0bce8cd2b", size = 68433, upload-time = "2026-02-10T04:22:00.876Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7149,28 +7162,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-tensorflow"
|
||||
version = "2.18.0.20251008"
|
||||
version = "2.18.0.20260224"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "types-protobuf" },
|
||||
{ name = "types-requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0d/0a/13bde03fb5a23faaadcca2d6914f865e444334133902310ea05e6ade780c/types_tensorflow-2.18.0.20251008.tar.gz", hash = "sha256:8db03d4dd391a362e2ea796ffdbccb03c082127606d4d852edb7ed9504745933", size = 257550, upload-time = "2025-10-08T02:51:51.104Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/cb/4914c2fbc1cf8a8d1ef2a7c727bb6f694879be85edeee880a0c88e696af8/types_tensorflow-2.18.0.20260224.tar.gz", hash = "sha256:9b0ccc91c79c88791e43d3f80d6c879748fa0361409c5ff23c7ffe3709be00f2", size = 258786, upload-time = "2026-02-24T04:06:45.613Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/66/cc/e50e49db621b0cf03c1f3d10be47389de41a02dc9924c3a83a9c1a55bf28/types_tensorflow-2.18.0.20251008-py3-none-any.whl", hash = "sha256:d6b0dd4d81ac6d9c5af803ebcc8ce0f65c5850c063e8b9789dc828898944b5f4", size = 329023, upload-time = "2025-10-08T02:51:50.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/1d/a1c3c60f0eb1a204500dbdc66e3d18aafabc86ad07a8eca71ea05bc8c5a8/types_tensorflow-2.18.0.20260224-py3-none-any.whl", hash = "sha256:6a25f5f41f3e06f28c1f65c6e09f484d4ba0031d6d8df83a39df9d890245eefc", size = 329746, upload-time = "2026-02-24T04:06:44.4Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-tqdm"
|
||||
version = "4.67.0.20250809"
|
||||
version = "4.67.3.20260303"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "types-requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/d0/cf498fc630d9fdaf2428b93e60b0e67b08008fec22b78716b8323cf644dc/types_tqdm-4.67.0.20250809.tar.gz", hash = "sha256:02bf7ab91256080b9c4c63f9f11b519c27baaf52718e5fdab9e9606da168d500", size = 17200, upload-time = "2025-08-09T03:17:43.489Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/64/3e7cb0f40c4bf9578098b6873df33a96f7e0de90f3a039e614d22bfde40a/types_tqdm-4.67.3.20260303.tar.gz", hash = "sha256:7bfddb506a75aedb4030fabf4f05c5638c9a3bbdf900d54ec6c82be9034bfb96", size = 18117, upload-time = "2026-03-03T04:03:49.679Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/13/3ff0781445d7c12730befce0fddbbc7a76e56eb0e7029446f2853238360a/types_tqdm-4.67.0.20250809-py3-none-any.whl", hash = "sha256:1a73053b31fcabf3c1f3e2a9d5ecdba0f301bde47a418cd0e0bdf774827c5c57", size = 24020, upload-time = "2025-08-09T03:17:42.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/32/e4a1fce59155c74082f1a42d0ffafa59652bfb8cff35b04d56333877748e/types_tqdm-4.67.3.20260303-py3-none-any.whl", hash = "sha256:459decf677e4b05cef36f9012ef8d6e20578edefb6b78c15bd0b546247eda62d", size = 24572, upload-time = "2026-03-03T04:03:48.913Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1546,24 +1546,25 @@ SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
|
||||
|
||||
|
||||
# Redis URL used for PubSub between API and
|
||||
# Redis URL used for event bus between API and
|
||||
# celery worker
|
||||
# defaults to url constructed from `REDIS_*`
|
||||
# configurations
|
||||
PUBSUB_REDIS_URL=
|
||||
# Pub/sub channel type for streaming events.
|
||||
# valid options are:
|
||||
EVENT_BUS_REDIS_URL=
|
||||
# Event transport type. Options are:
|
||||
#
|
||||
# - pubsub: for normal Pub/Sub
|
||||
# - sharded: for sharded Pub/Sub
|
||||
# - pubsub: normal Pub/Sub (at-most-once)
|
||||
# - sharded: sharded Pub/Sub (at-most-once)
|
||||
# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races)
|
||||
#
|
||||
# It's highly recommended to use sharded Pub/Sub AND redis cluster
|
||||
# for large deployments.
|
||||
PUBSUB_REDIS_CHANNEL_TYPE=pubsub
|
||||
# Whether to use Redis cluster mode while running
|
||||
# PubSub.
|
||||
# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs.
|
||||
# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce
|
||||
# the risk of data loss from Redis auto-eviction under memory pressure.
|
||||
# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE.
|
||||
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
|
||||
# Whether to use Redis cluster mode while use redis as event bus.
|
||||
# It's highly recommended to enable this for large deployments.
|
||||
PUBSUB_REDIS_USE_CLUSTERS=false
|
||||
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
||||
|
||||
# Whether to Enable human input timeout check task
|
||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||
|
||||
@@ -269,7 +269,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@@ -123,7 +123,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
|
||||
@@ -699,9 +699,9 @@ x-shared-env: &shared-api-worker-env
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000}
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL:-200}
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30}
|
||||
PUBSUB_REDIS_URL: ${PUBSUB_REDIS_URL:-}
|
||||
PUBSUB_REDIS_CHANNEL_TYPE: ${PUBSUB_REDIS_CHANNEL_TYPE:-pubsub}
|
||||
PUBSUB_REDIS_USE_CLUSTERS: ${PUBSUB_REDIS_USE_CLUSTERS:-false}
|
||||
EVENT_BUS_REDIS_URL: ${EVENT_BUS_REDIS_URL:-}
|
||||
EVENT_BUS_REDIS_CHANNEL_TYPE: ${EVENT_BUS_REDIS_CHANNEL_TYPE:-pubsub}
|
||||
EVENT_BUS_REDIS_USE_CLUSTERS: ${EVENT_BUS_REDIS_USE_CLUSTERS:-false}
|
||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK: ${ENABLE_HUMAN_INPUT_TIMEOUT_TASK:-true}
|
||||
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL: ${HUMAN_INPUT_TIMEOUT_TASK_INTERVAL:-1}
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL:-90000}
|
||||
@@ -976,7 +976,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
buildGitDiffRevisionArgs,
|
||||
getChangedBranchCoverage,
|
||||
getChangedStatementCoverage,
|
||||
getIgnoredChangedLinesFromSource,
|
||||
@@ -7,6 +8,11 @@ import {
|
||||
} from '../scripts/check-components-diff-coverage-lib.mjs'
|
||||
|
||||
describe('check-components-diff-coverage helpers', () => {
|
||||
it('should build exact and merge-base git diff revision args', () => {
|
||||
expect(buildGitDiffRevisionArgs('base-sha', 'head-sha', 'exact')).toEqual(['base-sha', 'head-sha'])
|
||||
expect(buildGitDiffRevisionArgs('base-sha', 'head-sha')).toEqual(['base-sha...head-sha'])
|
||||
})
|
||||
|
||||
it('should parse changed line maps from unified diffs', () => {
|
||||
const diff = [
|
||||
'diff --git a/web/app/components/share/a.ts b/web/app/components/share/a.ts',
|
||||
|
||||
@@ -218,7 +218,7 @@ describe('ParamConfigContent', () => {
|
||||
})
|
||||
|
||||
render(<ParamConfigContent />)
|
||||
const input = screen.getByRole('spinbutton') as HTMLInputElement
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: '4' } })
|
||||
|
||||
const updatedFile = getLatestFileConfig()
|
||||
|
||||
@@ -180,12 +180,12 @@ 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(() => {
|
||||
const [topKInput] = dialogScope.getAllByRole('spinbutton')
|
||||
expect(topKInput).toHaveValue(5)
|
||||
const [topKInput] = dialogScope.getAllByRole('textbox')
|
||||
expect(topKInput).toHaveValue('5')
|
||||
})
|
||||
|
||||
await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
|
||||
@@ -197,10 +197,10 @@ describe('dataset-config/params-config', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
|
||||
const reopenedScope = within(reopenedDialog)
|
||||
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
|
||||
const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
|
||||
|
||||
// Assert
|
||||
expect(reopenedTopKInput).toHaveValue(5)
|
||||
expect(reopenedTopKInput).toHaveValue('5')
|
||||
})
|
||||
|
||||
it('should discard changes when cancel is clicked', async () => {
|
||||
@@ -213,12 +213,12 @@ 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(() => {
|
||||
const [topKInput] = dialogScope.getAllByRole('spinbutton')
|
||||
expect(topKInput).toHaveValue(5)
|
||||
const [topKInput] = dialogScope.getAllByRole('textbox')
|
||||
expect(topKInput).toHaveValue('5')
|
||||
})
|
||||
|
||||
const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' })
|
||||
@@ -231,10 +231,10 @@ describe('dataset-config/params-config', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
|
||||
const reopenedScope = within(reopenedDialog)
|
||||
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
|
||||
const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
|
||||
|
||||
// Assert
|
||||
expect(reopenedTopKInput).toHaveValue(4)
|
||||
expect(reopenedTopKInput).toHaveValue('4')
|
||||
})
|
||||
|
||||
it('should prevent saving when rerank model is required but invalid', async () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
|
||||
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
|
||||
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -117,10 +116,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
<div className="px-10">
|
||||
<div className="h-6 w-full 2xl:h-[139px]" />
|
||||
<div className="pb-6 pt-1">
|
||||
<span className="title-2xl-semi-bold text-text-primary">{t('newApp.startFromBlank', { ns: 'app' })}</span>
|
||||
<span className="text-text-primary title-2xl-semi-bold">{t('newApp.startFromBlank', { ns: 'app' })}</span>
|
||||
</div>
|
||||
<div className="mb-2 leading-6">
|
||||
<span className="system-sm-semibold text-text-secondary">{t('newApp.chooseAppType', { ns: 'app' })}</span>
|
||||
<span className="text-text-secondary system-sm-semibold">{t('newApp.chooseAppType', { ns: 'app' })}</span>
|
||||
</div>
|
||||
<div className="flex w-[660px] flex-col gap-4">
|
||||
<div>
|
||||
@@ -160,7 +159,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
className="flex cursor-pointer items-center border-0 bg-transparent p-0"
|
||||
onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)}
|
||||
>
|
||||
<span className="system-2xs-medium-uppercase text-text-tertiary">{t('newApp.forBeginners', { ns: 'app' })}</span>
|
||||
<span className="text-text-tertiary system-2xs-medium-uppercase">{t('newApp.forBeginners', { ns: 'app' })}</span>
|
||||
<RiArrowRightSLine className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -212,7 +211,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<label className="system-sm-semibold text-text-secondary">{t('newApp.captionName', { ns: 'app' })}</label>
|
||||
<label className="text-text-secondary system-sm-semibold">{t('newApp.captionName', { ns: 'app' })}</label>
|
||||
</div>
|
||||
<Input
|
||||
value={name}
|
||||
@@ -243,8 +242,8 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<label className="system-sm-semibold text-text-secondary">{t('newApp.captionDescription', { ns: 'app' })}</label>
|
||||
<span className="system-xs-regular ml-1 text-text-tertiary">
|
||||
<label className="text-text-secondary system-sm-semibold">{t('newApp.captionDescription', { ns: 'app' })}</label>
|
||||
<span className="ml-1 text-text-tertiary system-xs-regular">
|
||||
(
|
||||
{t('newApp.optional', { ns: 'app' })}
|
||||
)
|
||||
@@ -260,7 +259,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
</div>
|
||||
{isAppsFull && <AppsFull className="mt-4" loc="app-create" />}
|
||||
<div className="flex items-center justify-between pb-10 pt-5">
|
||||
<div className="system-xs-regular flex cursor-pointer items-center gap-1 text-text-tertiary" onClick={onCreateFromTemplate}>
|
||||
<div className="flex cursor-pointer items-center gap-1 text-text-tertiary system-xs-regular" onClick={onCreateFromTemplate}>
|
||||
<span>{t('newApp.noIdeaTip', { ns: 'app' })}</span>
|
||||
<div className="p-[1px]">
|
||||
<RiArrowRightLine className="h-3.5 w-3.5" />
|
||||
@@ -334,8 +333,8 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
<div className="system-sm-semibold mb-0.5 mt-2 text-text-secondary">{title}</div>
|
||||
<div className="system-xs-regular line-clamp-2 text-text-tertiary" title={description}>{description}</div>
|
||||
<div className="mb-0.5 mt-2 text-text-secondary system-sm-semibold">{title}</div>
|
||||
<div className="line-clamp-2 text-text-tertiary system-xs-regular" title={description}>{description}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -367,8 +366,8 @@ function AppPreview({ mode }: { mode: AppModeEnum }) {
|
||||
const previewInfo = modeToPreviewInfoMap[mode]
|
||||
return (
|
||||
<div className="px-8 py-4">
|
||||
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>
|
||||
<div className="system-xs-regular mt-1 min-h-8 max-w-96 text-text-tertiary">
|
||||
<h4 className="text-text-secondary system-sm-semibold-uppercase">{previewInfo.title}</h4>
|
||||
<div className="mt-1 min-h-8 max-w-96 text-text-tertiary system-xs-regular">
|
||||
<span>{previewInfo.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -389,7 +388,7 @@ function AppScreenShot({ mode, show }: { mode: AppModeEnum, show: boolean }) {
|
||||
<source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} />
|
||||
<source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@2x.png`} />
|
||||
<source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@3x.png`} />
|
||||
<Image
|
||||
<img
|
||||
className={show ? '' : 'hidden'}
|
||||
src={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`}
|
||||
alt="App Screen Shot"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import type { EmbeddedChatbotContextValue } from '../../context'
|
||||
import type { AppData } from '@/models/share'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
@@ -22,15 +20,6 @@ vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropd
|
||||
default: () => <div data-testid="view-form-dropdown" />,
|
||||
}))
|
||||
|
||||
// Mock next/image to render a normal img tag for testing
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => {
|
||||
const { unoptimized: _, ...rest } = props
|
||||
return <img {...rest} />
|
||||
},
|
||||
}))
|
||||
|
||||
type GlobalPublicStoreMock = {
|
||||
systemFeatures: SystemFeatures
|
||||
setSystemFeatures: (systemFeatures: SystemFeatures) => void
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CheckboxList from '..'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('checkbox list component', () => {
|
||||
const options = [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
@@ -169,7 +168,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
|
||||
{searchQuery
|
||||
? (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<Image alt="search menu" src={SearchMenu} width={32} />
|
||||
<img alt="search menu" src={SearchMenu.src} width={32} />
|
||||
<span className="text-text-secondary system-sm-regular">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
|
||||
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import FileThumb from '../index'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('FileThumb Component', () => {
|
||||
const mockImageFile = {
|
||||
name: 'test-image.jpg',
|
||||
|
||||
@@ -22,12 +22,26 @@ describe('NumberInputField', () => {
|
||||
|
||||
it('should render current number value', () => {
|
||||
render(<NumberInputField label="Count" />)
|
||||
expect(screen.getByDisplayValue('2')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('2')
|
||||
})
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('BaseField', () => {
|
||||
it('should render a number input when configured as number input', () => {
|
||||
render(<FieldHarness config={createConfig({ type: BaseFieldType.numberInput, label: 'Age' })} initialData={{ fieldA: 20 }} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByText('Age')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,353 +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('spinbutton')
|
||||
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('spinbutton')
|
||||
|
||||
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('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('does not call onChange when parsed value is NaN', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
const originalNumber = globalThis.Number
|
||||
const numberSpy = vi.spyOn(globalThis, 'Number').mockImplementation((val: unknown) => {
|
||||
if (val === '123') {
|
||||
return Number.NaN
|
||||
}
|
||||
return originalNumber(val)
|
||||
})
|
||||
|
||||
try {
|
||||
fireEvent.change(input, { target: { value: '123' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
numberSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
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('spinbutton')
|
||||
|
||||
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('spinbutton')
|
||||
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('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('spinbutton')
|
||||
|
||||
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('spinbutton')
|
||||
|
||||
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('spinbutton')
|
||||
|
||||
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 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('spinbutton')
|
||||
|
||||
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,129 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import type { InputProps } from '../input'
|
||||
import { useCallback } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Input from '../input'
|
||||
|
||||
export type InputNumberProps = {
|
||||
unit?: string
|
||||
value?: number
|
||||
onChange: (value: number) => void
|
||||
amount?: number
|
||||
size?: 'regular' | 'large'
|
||||
max?: number
|
||||
min?: number
|
||||
defaultValue?: number
|
||||
disabled?: boolean
|
||||
wrapClassName?: string
|
||||
controlWrapClassName?: string
|
||||
controlClassName?: string
|
||||
} & Omit<InputProps, 'value' | 'onChange' | 'size' | 'min' | 'max' | 'defaultValue'>
|
||||
|
||||
export const InputNumber: FC<InputNumberProps> = (props) => {
|
||||
const {
|
||||
unit,
|
||||
className,
|
||||
onChange,
|
||||
amount = 1,
|
||||
value,
|
||||
size = 'regular',
|
||||
max,
|
||||
min,
|
||||
defaultValue,
|
||||
wrapClassName,
|
||||
controlWrapClassName,
|
||||
controlClassName,
|
||||
disabled,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const isValidValue = useCallback((v: number) => {
|
||||
if (typeof max === 'number' && v > max)
|
||||
return false
|
||||
return !(typeof min === 'number' && v < min)
|
||||
}, [max, min])
|
||||
|
||||
const inc = () => {
|
||||
/* v8 ignore next 2 - @preserve */
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
if (value === undefined) {
|
||||
onChange(defaultValue ?? 0)
|
||||
return
|
||||
}
|
||||
const newValue = value + amount
|
||||
if (!isValidValue(newValue))
|
||||
return
|
||||
onChange(newValue)
|
||||
}
|
||||
const dec = () => {
|
||||
/* v8 ignore next 2 - @preserve */
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
if (value === undefined) {
|
||||
onChange(defaultValue ?? 0)
|
||||
return
|
||||
}
|
||||
const newValue = value - amount
|
||||
if (!isValidValue(newValue))
|
||||
return
|
||||
onChange(newValue)
|
||||
}
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.value === '') {
|
||||
onChange(0)
|
||||
return
|
||||
}
|
||||
const parsed = Number(e.target.value)
|
||||
if (Number.isNaN(parsed))
|
||||
return
|
||||
|
||||
if (!isValidValue(parsed))
|
||||
return
|
||||
onChange(parsed)
|
||||
}, [isValidValue, onChange])
|
||||
|
||||
return (
|
||||
<div data-testid="input-number-wrapper" className={cn('flex', wrapClassName)}>
|
||||
<Input
|
||||
{...rest}
|
||||
// disable default controller
|
||||
type="number"
|
||||
className={cn('rounded-r-none no-spinner', className)}
|
||||
value={value ?? 0}
|
||||
max={max}
|
||||
min={min}
|
||||
disabled={disabled}
|
||||
onChange={handleInputChange}
|
||||
unit={unit}
|
||||
size={size}
|
||||
/>
|
||||
<div
|
||||
data-testid="input-number-controls"
|
||||
className={cn('flex flex-col rounded-r-md border-l border-divider-subtle bg-components-input-bg-normal text-text-tertiary focus:shadow-xs', disabled && 'cursor-not-allowed opacity-50', controlWrapClassName)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={inc}
|
||||
disabled={disabled}
|
||||
aria-label="increment"
|
||||
className={cn(size === 'regular' ? 'pt-1' : 'pt-1.5', 'px-1.5 hover:bg-components-input-bg-hover', disabled && 'cursor-not-allowed hover:bg-transparent', controlClassName)}
|
||||
>
|
||||
<span className="i-ri-arrow-up-s-line size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dec}
|
||||
disabled={disabled}
|
||||
aria-label="decrement"
|
||||
className={cn(size === 'regular' ? 'pb-1' : 'pb-1.5', 'px-1.5 hover:bg-components-input-bg-hover', disabled && 'cursor-not-allowed hover:bg-transparent', controlClassName)}
|
||||
>
|
||||
<span className="i-ri-arrow-down-s-line size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import WithIconCardItem from './with-icon-card-item'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('WithIconCardItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { WithIconCardItemProps } from './markdown-with-directive-schema'
|
||||
import Image from 'next/image'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type WithIconItemProps = WithIconCardItemProps & {
|
||||
@@ -11,18 +10,13 @@ type WithIconItemProps = WithIconCardItemProps & {
|
||||
function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) {
|
||||
return (
|
||||
<div className={cn('flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2', className)}>
|
||||
{/*
|
||||
* unoptimized to "url parameter is not allowed" for external domains despite correct remotePatterns configuration.
|
||||
* https://github.com/vercel/next.js/issues/88873
|
||||
*/}
|
||||
<Image
|
||||
<img
|
||||
src={icon}
|
||||
className="!border-none object-contain"
|
||||
alt={iconAlt ?? ''}
|
||||
aria-hidden={iconAlt ? undefined : true}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
/>
|
||||
<div className="min-w-0 grow overflow-hidden text-text-secondary system-sm-medium [&_p]:!m-0 [&_p]:block [&_p]:w-full [&_p]:overflow-hidden [&_p]:text-ellipsis [&_p]:whitespace-nowrap">
|
||||
{children}
|
||||
|
||||
@@ -7,10 +7,6 @@ import { MarkdownWithDirective } from './index'
|
||||
|
||||
const FOUR_COLON_RE = /:{4}/
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
|
||||
}))
|
||||
|
||||
function expectDecorativeIcon(container: HTMLElement, src: string) {
|
||||
const icon = container.querySelector('img')
|
||||
expect(icon).toBeInTheDocument()
|
||||
|
||||
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CredentialSelector from '../index'
|
||||
|
||||
// Mock CredentialIcon since it's likely a complex component or uses next/image
|
||||
// Mock CredentialIcon since it's likely a complex component.
|
||||
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
|
||||
CredentialIcon: ({ name }: { name: string }) => <div data-testid="credential-icon">{name}</div>,
|
||||
}))
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('ParamItem', () => {
|
||||
it('should render InputNumber and Slider', () => {
|
||||
render(<ParamItem {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -68,7 +68,7 @@ describe('ParamItem', () => {
|
||||
it('should disable InputNumber when enable is false', () => {
|
||||
render(<ParamItem {...defaultProps} enable={false} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeDisabled()
|
||||
expect(screen.getByRole('textbox')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable Slider when enable is false', () => {
|
||||
@@ -104,7 +104,7 @@ describe('ParamItem', () => {
|
||||
}
|
||||
|
||||
render(<StatefulParamItem />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '0.8')
|
||||
@@ -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')
|
||||
@@ -166,14 +223,10 @@ describe('ParamItem', () => {
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '10')
|
||||
})
|
||||
|
||||
it('should use default step of 0.1 and min of 0 when not provided', () => {
|
||||
it('should expose default minimum of 0 when min is not provided', () => {
|
||||
render(<ParamItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
// Component renders without error with default step/min
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(input).toHaveAttribute('step', '0.1')
|
||||
expect(input).toHaveAttribute('min', '0')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('ScoreThresholdItem', () => {
|
||||
it('should render InputNumber and Slider', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -62,7 +62,7 @@ describe('ScoreThresholdItem', () => {
|
||||
it('should disable controls when enable is false', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} enable={false} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeDisabled()
|
||||
expect(screen.getByRole('textbox')).toBeDisabled()
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
@@ -70,23 +70,19 @@ describe('ScoreThresholdItem', () => {
|
||||
describe('Value Clamping', () => {
|
||||
it('should clamp values to minimum of 0', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('min', '0')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clamp values to maximum of 1', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('max', '1')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use step of 0.01', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('step', '0.01')
|
||||
render(<ScoreThresholdItem {...defaultProps} value={0.5} />)
|
||||
expect(screen.getByRole('textbox')).toHaveValue('0.5')
|
||||
})
|
||||
|
||||
it('should call onChange with rounded value when input changes', async () => {
|
||||
@@ -107,7 +103,7 @@ describe('ScoreThresholdItem', () => {
|
||||
}
|
||||
|
||||
render(<StatefulScoreThresholdItem />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '0.55')
|
||||
@@ -138,8 +134,8 @@ describe('ScoreThresholdItem', () => {
|
||||
|
||||
it('should clamp to max=1 when value exceeds maximum', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} value={1.5} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(1)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('TopKItem', () => {
|
||||
it('should render InputNumber and Slider', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -51,7 +51,7 @@ describe('TopKItem', () => {
|
||||
it('should disable controls when enable is false', () => {
|
||||
render(<TopKItem {...defaultProps} enable={false} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeDisabled()
|
||||
expect(screen.getByRole('textbox')).toBeDisabled()
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
@@ -59,23 +59,20 @@ describe('TopKItem', () => {
|
||||
describe('Value Limits', () => {
|
||||
it('should use step of 1', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('step', '1')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('2')
|
||||
})
|
||||
|
||||
it('should use minimum of 1', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('min', '1')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use maximum from env (10)', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('max', '10')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render slider with max >= 5 so no scaling is applied', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
275
web/app/components/base/ui/number-field/__tests__/index.spec.tsx
Normal file
275
web/app/components/base/ui/number-field/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
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 {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
// 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')
|
||||
|
||||
expect(group).toHaveClass('radius-md')
|
||||
expect(group).toHaveClass('custom-group')
|
||||
})
|
||||
|
||||
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.getByTestId('input')
|
||||
|
||||
expect(group).toHaveClass('radius-lg')
|
||||
expect(input).toHaveClass('px-4')
|
||||
expect(input).toHaveClass('py-2')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
// 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')
|
||||
|
||||
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(decrement).toHaveClass(decrementClass)
|
||||
expect(decrement).toHaveClass('custom-decrement')
|
||||
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>
|
||||
),
|
||||
}
|
||||
227
web/app/components/base/ui/number-field/index.tsx
Normal file
227
web/app/components/base/ui/number-field/index.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
'use client'
|
||||
|
||||
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(
|
||||
[
|
||||
'group/number-field flex w-full min-w-0 items-stretch overflow-hidden border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-none transition-[background-color,border-color,box-shadow]',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'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 data-[readonly]:hover:border-transparent data-[readonly]:hover:bg-components-input-bg-normal motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
regular: 'radius-md',
|
||||
large: 'radius-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
},
|
||||
},
|
||||
)
|
||||
export type NumberFieldSize = NonNullable<VariantProps<typeof numberFieldGroupVariants>['size']>
|
||||
|
||||
export type NumberFieldGroupProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Group> & VariantProps<typeof numberFieldGroupVariants>
|
||||
|
||||
export function NumberFieldGroup({
|
||||
className,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldGroupProps) {
|
||||
return (
|
||||
<BaseNumberField.Group
|
||||
className={cn(numberFieldGroupVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const numberFieldInputVariants = cva(
|
||||
[
|
||||
'w-0 min-w-0 flex-1 appearance-none border-0 bg-transparent text-components-input-text-filled caret-primary-600 outline-none',
|
||||
'placeholder:text-components-input-text-placeholder',
|
||||
'disabled:cursor-not-allowed disabled:text-components-input-text-filled-disabled disabled:placeholder:text-components-input-text-disabled',
|
||||
'data-[readonly]:cursor-default',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
regular: 'px-3 py-[7px] system-sm-regular',
|
||||
large: 'px-4 py-2 system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type NumberFieldInputProps = Omit<React.ComponentPropsWithoutRef<typeof BaseNumberField.Input>, 'size'> & VariantProps<typeof numberFieldInputVariants>
|
||||
|
||||
export function NumberFieldInput({
|
||||
className,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldInputProps) {
|
||||
return (
|
||||
<BaseNumberField.Input
|
||||
className={cn(numberFieldInputVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const numberFieldUnitVariants = cva(
|
||||
'flex shrink-0 items-center self-stretch text-text-tertiary system-sm-regular',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
regular: 'pr-2',
|
||||
large: 'pr-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type NumberFieldUnitProps = React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof numberFieldUnitVariants>
|
||||
|
||||
export function NumberFieldUnit({
|
||||
className,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldUnitProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(numberFieldUnitVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const numberFieldControlsVariants = cva(
|
||||
'flex shrink-0 flex-col items-stretch border-l border-divider-subtle bg-transparent text-text-tertiary',
|
||||
)
|
||||
|
||||
export type NumberFieldControlsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function NumberFieldControls({
|
||||
className,
|
||||
...props
|
||||
}: NumberFieldControlsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(numberFieldControlsVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const numberFieldControlButtonVariants = cva(
|
||||
[
|
||||
'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',
|
||||
'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',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
regular: '',
|
||||
large: '',
|
||||
},
|
||||
direction: {
|
||||
increment: '',
|
||||
decrement: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'regular',
|
||||
direction: 'increment',
|
||||
className: 'pt-1',
|
||||
},
|
||||
{
|
||||
size: 'regular',
|
||||
direction: 'decrement',
|
||||
className: 'pb-1',
|
||||
},
|
||||
{
|
||||
size: 'large',
|
||||
direction: 'increment',
|
||||
className: 'pt-1.5',
|
||||
},
|
||||
{
|
||||
size: 'large',
|
||||
direction: 'decrement',
|
||||
className: 'pb-1.5',
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
direction: 'increment',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
type NumberFieldButtonVariantProps = Omit<
|
||||
VariantProps<typeof numberFieldControlButtonVariants>,
|
||||
'direction'
|
||||
>
|
||||
|
||||
export type NumberFieldButtonProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Increment> & NumberFieldButtonVariantProps
|
||||
|
||||
export function NumberFieldIncrement({
|
||||
className,
|
||||
children,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<BaseNumberField.Increment
|
||||
{...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
|
||||
{...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>
|
||||
)
|
||||
}
|
||||
@@ -1,496 +1,179 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { contactSalesUrl } from '@/app/components/billing/config'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import {
|
||||
initialLangGeniusVersionInfo,
|
||||
initialWorkspaceInfo,
|
||||
useAppContext,
|
||||
userProfilePlaceholder,
|
||||
} from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import CustomPage from '../index'
|
||||
|
||||
// Mock external dependencies only
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock the complex CustomWebAppBrand component to avoid dependency issues
|
||||
// This is acceptable because it has complex dependencies (fetch, APIs)
|
||||
vi.mock('@/app/components/custom/custom-web-app-brand', () => ({
|
||||
default: () => <div data-testid="custom-web-app-brand">CustomWebAppBrand</div>,
|
||||
vi.mock('@/context/app-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/app-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useAppContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseModalContext = vi.mocked(useModalContext)
|
||||
const mockUseAppContext = vi.mocked(useAppContext)
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockUseToastContext = vi.mocked(useToastContext)
|
||||
|
||||
const createProviderContext = ({
|
||||
enableBilling = false,
|
||||
planType = Plan.professional,
|
||||
}: {
|
||||
enableBilling?: boolean
|
||||
planType?: Plan
|
||||
} = {}) => {
|
||||
return createMockProviderContextValue({
|
||||
enableBilling,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const createAppContextValue = (): AppContextValue => ({
|
||||
userProfile: userProfilePlaceholder,
|
||||
mutateUserProfile: vi.fn(),
|
||||
currentWorkspace: {
|
||||
...initialWorkspaceInfo,
|
||||
custom_config: {
|
||||
replace_webapp_logo: 'https://example.com/replace.png',
|
||||
remove_webapp_brand: false,
|
||||
},
|
||||
},
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isCurrentWorkspaceEditor: false,
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
langGeniusVersionInfo: initialLangGeniusVersionInfo,
|
||||
useSelector: vi.fn() as unknown as AppContextValue['useSelector'],
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isValidatingCurrentWorkspace: false,
|
||||
})
|
||||
|
||||
const createSystemFeatures = (): SystemFeatures => ({
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: true,
|
||||
workspace_logo: 'https://example.com/workspace-logo.png',
|
||||
},
|
||||
})
|
||||
|
||||
describe('CustomPage', () => {
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const setShowPricingModal = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default mock setup
|
||||
;(useModalContext as Mock).mockReturnValue({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext())
|
||||
mockUseModalContext.mockReturnValue({
|
||||
setShowPricingModal,
|
||||
} as unknown as ReturnType<typeof useModalContext>)
|
||||
mockUseAppContext.mockReturnValue(createAppContextValue())
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector({
|
||||
systemFeatures: createSystemFeatures(),
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
mockUseToastContext.mockReturnValue({
|
||||
notify: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useToastContext>)
|
||||
})
|
||||
|
||||
// Helper function to render with different provider contexts
|
||||
const renderWithContext = (overrides = {}) => {
|
||||
;(useProviderContext as Mock).mockReturnValue(
|
||||
createMockProviderContextValue(overrides),
|
||||
)
|
||||
return render(<CustomPage />)
|
||||
}
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
// Integration coverage for the page and its child custom brand section.
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext()
|
||||
it('should render the custom brand configuration by default', () => {
|
||||
render(<CustomPage />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should always render CustomWebAppBrand component', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct layout structure', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext()
|
||||
|
||||
// Assert
|
||||
const mainContainer = container.querySelector('.flex.flex-col')
|
||||
expect(mainContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Conditional Rendering - Billing Tip
|
||||
describe('Billing Tip Banner', () => {
|
||||
it('should show billing tip when enableBilling is true and plan is sandbox', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('custom.upgradeTip.des')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show billing tip when enableBilling is false', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: false,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('custom.webapp.removeBrand')).toBeInTheDocument()
|
||||
expect(screen.getByText('Chatflow App')).toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show billing tip when plan is professional', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show billing tip when plan is team', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.team },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct gradient styling for billing tip banner', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const banner = container.querySelector('.bg-gradient-to-r')
|
||||
expect(banner).toBeInTheDocument()
|
||||
expect(banner).toHaveClass('from-components-input-border-active-prompt-1')
|
||||
expect(banner).toHaveClass('to-components-input-border-active-prompt-2')
|
||||
expect(banner).toHaveClass('p-4')
|
||||
expect(banner).toHaveClass('pl-6')
|
||||
expect(banner).toHaveClass('shadow-lg')
|
||||
})
|
||||
})
|
||||
|
||||
// Conditional Rendering - Contact Sales
|
||||
describe('Contact Sales Section', () => {
|
||||
it('should show contact section when enableBilling is true and plan is professional', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert - Check that contact section exists with all parts
|
||||
const contactSection = container.querySelector('.absolute.bottom-0')
|
||||
expect(contactSection).toBeInTheDocument()
|
||||
expect(contactSection).toHaveTextContent('custom.customize.prefix')
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
expect(contactSection).toHaveTextContent('custom.customize.suffix')
|
||||
})
|
||||
|
||||
it('should show contact section when enableBilling is true and plan is team', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.team },
|
||||
})
|
||||
|
||||
// Assert - Check that contact section exists with all parts
|
||||
const contactSection = container.querySelector('.absolute.bottom-0')
|
||||
expect(contactSection).toBeInTheDocument()
|
||||
expect(contactSection).toHaveTextContent('custom.customize.prefix')
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
expect(contactSection).toHaveTextContent('custom.customize.suffix')
|
||||
})
|
||||
|
||||
it('should not show contact section when enableBilling is false', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: false,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show contact section when plan is sandbox', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render contact link with correct URL', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const link = screen.getByText('custom.customize.contactUs').closest('a')
|
||||
expect(link).toHaveAttribute('href', contactSalesUrl)
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('should have correct positioning for contact section', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const contactSection = container.querySelector('.absolute.bottom-0')
|
||||
expect(contactSection).toBeInTheDocument()
|
||||
expect(contactSection).toHaveClass('h-[50px]')
|
||||
expect(contactSection).toHaveClass('text-xs')
|
||||
expect(contactSection).toHaveClass('leading-[50px]')
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call setShowPricingModal when upgrade button is clicked', async () => {
|
||||
// Arrange
|
||||
it('should show the upgrade banner and open pricing modal for sandbox billing', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithContext({
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
planType: Plan.sandbox,
|
||||
}))
|
||||
|
||||
// Act
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
await user.click(upgradeButton)
|
||||
render(<CustomPage />)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call setShowPricingModal without arguments', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Act
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
await user.click(upgradeButton)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should handle multiple clicks on upgrade button', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Act
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
await user.click(upgradeButton)
|
||||
await user.click(upgradeButton)
|
||||
await user.click(upgradeButton)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should have correct button styling for upgrade button', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
expect(upgradeButton).toHaveClass('cursor-pointer')
|
||||
expect(upgradeButton).toHaveClass('bg-white')
|
||||
expect(upgradeButton).toHaveClass('text-text-accent')
|
||||
expect(upgradeButton).toHaveClass('rounded-3xl')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined plan type gracefully', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: undefined },
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle plan without type property', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: null },
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show any banners when both conditions are false', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: false,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle enableBilling undefined', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderWithContext({
|
||||
enableBilling: undefined,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show only billing tip for sandbox plan, not contact section', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('billing.upgradeBtn.encourageShort'))
|
||||
|
||||
expect(setShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show only contact section for professional plan, not billing tip', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
it('should show the contact link for professional workspaces', () => {
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
planType: Plan.professional,
|
||||
}))
|
||||
|
||||
// Assert
|
||||
render(<CustomPage />)
|
||||
|
||||
const contactLink = screen.getByText('custom.customize.contactUs').closest('a')
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
expect(contactLink).toHaveAttribute('href', contactSalesUrl)
|
||||
expect(contactLink).toHaveAttribute('target', '_blank')
|
||||
expect(contactLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('should show only contact section for team plan, not billing tip', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
it('should show the contact link for team workspaces', () => {
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.team },
|
||||
})
|
||||
planType: Plan.team,
|
||||
}))
|
||||
|
||||
// Assert
|
||||
render(<CustomPage />)
|
||||
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty plan object', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: {},
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Accessibility Tests
|
||||
describe('Accessibility', () => {
|
||||
it('should have clickable upgrade button', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
expect(upgradeButton).toBeInTheDocument()
|
||||
expect(upgradeButton).toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('should have proper external link attributes on contact link', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const link = screen.getByText('custom.customize.contactUs').closest('a')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('should have proper text hierarchy in billing tip', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const title = screen.getByText('custom.upgradeTip.title')
|
||||
const description = screen.getByText('custom.upgradeTip.des')
|
||||
|
||||
expect(title).toHaveClass('title-xl-semi-bold')
|
||||
expect(description).toHaveClass('system-sm-regular')
|
||||
})
|
||||
|
||||
it('should use semantic color classes', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert - Check that the billing tip has text content (which implies semantic colors)
|
||||
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Integration Tests
|
||||
describe('Integration', () => {
|
||||
it('should render both CustomWebAppBrand and billing tip together', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both CustomWebAppBrand and contact section together', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only CustomWebAppBrand when no billing conditions met', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
it('should hide both billing sections when billing is disabled', () => {
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
enableBilling: false,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
planType: Plan.sandbox,
|
||||
}))
|
||||
|
||||
render(<CustomPage />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,147 +1,158 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { updateCurrentWorkspace } from '@/service/common'
|
||||
import useWebAppBrand from '../hooks/use-web-app-brand'
|
||||
import CustomWebAppBrand from '../index'
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/common', () => ({
|
||||
updateCurrentWorkspace: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/app/components/base/image-uploader/utils', () => ({
|
||||
imageUpload: vi.fn(),
|
||||
getImageUploadErrorMessage: vi.fn(),
|
||||
vi.mock('../hooks/use-web-app-brand', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockUseToastContext = vi.mocked(useToastContext)
|
||||
const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace)
|
||||
const mockUseAppContext = vi.mocked(useAppContext)
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockImageUpload = vi.mocked(imageUpload)
|
||||
const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage)
|
||||
const mockUseWebAppBrand = vi.mocked(useWebAppBrand)
|
||||
|
||||
const defaultPlanUsage = {
|
||||
buildApps: 0,
|
||||
teamMembers: 0,
|
||||
annotatedResponse: 0,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: 0,
|
||||
triggerEvents: 0,
|
||||
vectorSpace: 0,
|
||||
const createHookState = (overrides: Partial<ReturnType<typeof useWebAppBrand>> = {}): ReturnType<typeof useWebAppBrand> => ({
|
||||
fileId: '',
|
||||
imgKey: 100,
|
||||
uploadProgress: 0,
|
||||
uploading: false,
|
||||
webappLogo: 'https://example.com/replace.png',
|
||||
webappBrandRemoved: false,
|
||||
uploadDisabled: false,
|
||||
workspaceLogo: 'https://example.com/workspace-logo.png',
|
||||
isSandbox: false,
|
||||
isCurrentWorkspaceManager: true,
|
||||
handleApply: vi.fn(),
|
||||
handleCancel: vi.fn(),
|
||||
handleChange: vi.fn(),
|
||||
handleRestore: vi.fn(),
|
||||
handleSwitch: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderComponent = (overrides: Partial<ReturnType<typeof useWebAppBrand>> = {}) => {
|
||||
const hookState = createHookState(overrides)
|
||||
mockUseWebAppBrand.mockReturnValue(hookState)
|
||||
return {
|
||||
hookState,
|
||||
...render(<CustomWebAppBrand />),
|
||||
}
|
||||
}
|
||||
|
||||
const renderComponent = () => render(<CustomWebAppBrand />)
|
||||
|
||||
describe('CustomWebAppBrand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType<typeof useToastContext>)
|
||||
mockUpdateCurrentWorkspace.mockResolvedValue({} as unknown as Awaited<ReturnType<typeof updateCurrentWorkspace>>)
|
||||
mockUseAppContext.mockReturnValue({
|
||||
currentWorkspace: {
|
||||
custom_config: {
|
||||
replace_webapp_logo: 'https://example.com/replace.png',
|
||||
remove_webapp_brand: false,
|
||||
},
|
||||
},
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
isCurrentWorkspaceManager: true,
|
||||
} as unknown as ReturnType<typeof useAppContext>)
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
type: Plan.professional,
|
||||
usage: defaultPlanUsage,
|
||||
total: defaultPlanUsage,
|
||||
reset: {},
|
||||
},
|
||||
enableBilling: false,
|
||||
} as unknown as ReturnType<typeof useProviderContext>)
|
||||
const systemFeaturesState = {
|
||||
branding: {
|
||||
enabled: true,
|
||||
workspace_logo: 'https://example.com/workspace-logo.png',
|
||||
},
|
||||
}
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState, setSystemFeatures: vi.fn() } as unknown as ReturnType<typeof useGlobalPublicStore.getState>) : { systemFeatures: systemFeaturesState })
|
||||
mockGetImageUploadErrorMessage.mockReturnValue('upload error')
|
||||
})
|
||||
|
||||
it('disables upload controls when the user cannot manage the workspace', () => {
|
||||
mockUseAppContext.mockReturnValue({
|
||||
currentWorkspace: {
|
||||
custom_config: {
|
||||
replace_webapp_logo: '',
|
||||
remove_webapp_brand: false,
|
||||
},
|
||||
},
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
isCurrentWorkspaceManager: false,
|
||||
} as unknown as ReturnType<typeof useAppContext>)
|
||||
// Integration coverage for the root component with the hook mocked at the boundary.
|
||||
describe('Rendering', () => {
|
||||
it('should render the upload controls and preview cards with restore action', () => {
|
||||
renderComponent()
|
||||
|
||||
const { container } = renderComponent()
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(fileInput).toBeDisabled()
|
||||
})
|
||||
|
||||
it('toggles remove brand switch and calls the backend + mutate', async () => {
|
||||
const mutateMock = vi.fn()
|
||||
mockUseAppContext.mockReturnValue({
|
||||
currentWorkspace: {
|
||||
custom_config: {
|
||||
replace_webapp_logo: '',
|
||||
remove_webapp_brand: false,
|
||||
},
|
||||
},
|
||||
mutateCurrentWorkspace: mutateMock,
|
||||
isCurrentWorkspaceManager: true,
|
||||
} as unknown as ReturnType<typeof useAppContext>)
|
||||
|
||||
renderComponent()
|
||||
const switchInput = screen.getByRole('switch')
|
||||
fireEvent.click(switchInput)
|
||||
|
||||
await waitFor(() => expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
|
||||
url: '/workspaces/custom-config',
|
||||
body: { remove_webapp_brand: true },
|
||||
}))
|
||||
await waitFor(() => expect(mutateMock).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('shows cancel/apply buttons after successful upload and cancels properly', async () => {
|
||||
mockImageUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }) => {
|
||||
onProgressCallback(50)
|
||||
onSuccessCallback({ id: 'new-logo' })
|
||||
expect(screen.getByText('custom.webapp.removeBrand')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'custom.restore' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'custom.change' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Chatflow App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workflow App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const { container } = renderComponent()
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const testFile = new File(['content'], 'logo.png', { type: 'image/png' })
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } })
|
||||
it('should hide the restore action when uploads are disabled or no logo is configured', () => {
|
||||
renderComponent({
|
||||
uploadDisabled: true,
|
||||
webappLogo: '',
|
||||
})
|
||||
|
||||
await waitFor(() => expect(mockImageUpload).toHaveBeenCalled())
|
||||
await waitFor(() => screen.getByRole('button', { name: 'custom.apply' }))
|
||||
expect(screen.queryByRole('button', { name: 'custom.restore' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'custom.upload' })).toBeDisabled()
|
||||
})
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: 'common.operation.cancel' })
|
||||
fireEvent.click(cancelButton)
|
||||
it('should show the uploading button and failure message when upload state requires it', () => {
|
||||
renderComponent({
|
||||
uploading: true,
|
||||
uploadProgress: -1,
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'custom.apply' })).toBeNull())
|
||||
expect(screen.getByRole('button', { name: 'custom.uploading' })).toBeDisabled()
|
||||
expect(screen.getByText('custom.uploadedFail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show apply and cancel actions when a new file is ready', () => {
|
||||
renderComponent({
|
||||
fileId: 'new-logo',
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'custom.apply' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable the switch when sandbox restrictions are active', () => {
|
||||
renderComponent({
|
||||
isSandbox: true,
|
||||
})
|
||||
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('should default the switch to unchecked when brand removal state is missing', () => {
|
||||
const { container } = renderComponent({
|
||||
webappBrandRemoved: undefined,
|
||||
})
|
||||
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(container.querySelector('.opacity-30')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should dim the upload row when brand removal is enabled', () => {
|
||||
const { container } = renderComponent({
|
||||
webappBrandRemoved: true,
|
||||
uploadDisabled: true,
|
||||
})
|
||||
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(container.querySelector('.opacity-30')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions delegated to the hook callbacks.
|
||||
describe('Interactions', () => {
|
||||
it('should delegate switch changes to the hook handler', () => {
|
||||
const { hookState } = renderComponent()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(hookState.handleSwitch).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should delegate file input changes and reset the native input value on click', () => {
|
||||
const { container, hookState } = renderComponent()
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const file = new File(['logo'], 'logo.png', { type: 'image/png' })
|
||||
|
||||
Object.defineProperty(fileInput, 'value', {
|
||||
configurable: true,
|
||||
value: 'stale-selection',
|
||||
writable: true,
|
||||
})
|
||||
|
||||
fireEvent.click(fileInput)
|
||||
fireEvent.change(fileInput, {
|
||||
target: { files: [file] },
|
||||
})
|
||||
|
||||
expect(fileInput.value).toBe('')
|
||||
expect(hookState.handleChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should delegate restore, cancel, and apply actions to the hook handlers', () => {
|
||||
const { hookState } = renderComponent({
|
||||
fileId: 'new-logo',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'custom.restore' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'custom.apply' }))
|
||||
|
||||
expect(hookState.handleRestore).toHaveBeenCalledTimes(1)
|
||||
expect(hookState.handleCancel).toHaveBeenCalledTimes(1)
|
||||
expect(hookState.handleApply).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ChatPreviewCard from '../chat-preview-card'
|
||||
|
||||
describe('ChatPreviewCard', () => {
|
||||
it('should render the chat preview with the powered-by footer', () => {
|
||||
render(
|
||||
<ChatPreviewCard
|
||||
imgKey={8}
|
||||
webappLogo="https://example.com/custom-logo.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Chatflow App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hello! How can I assist you today?')).toBeInTheDocument()
|
||||
expect(screen.getByText('Talk to Dify')).toBeInTheDocument()
|
||||
expect(screen.getByText('POWERED BY')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide chat branding footer when brand removal is enabled', () => {
|
||||
render(
|
||||
<ChatPreviewCard
|
||||
imgKey={8}
|
||||
webappBrandRemoved
|
||||
webappLogo="https://example.com/custom-logo.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('POWERED BY')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import PoweredByBrand from '../powered-by-brand'
|
||||
|
||||
describe('PoweredByBrand', () => {
|
||||
it('should render the workspace logo when available', () => {
|
||||
render(
|
||||
<PoweredByBrand
|
||||
imgKey={1}
|
||||
workspaceLogo="https://example.com/workspace-logo.png"
|
||||
webappLogo="https://example.com/custom-logo.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('POWERED BY')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png')
|
||||
})
|
||||
|
||||
it('should fall back to the custom web app logo when workspace branding is unavailable', () => {
|
||||
render(
|
||||
<PoweredByBrand
|
||||
imgKey={42}
|
||||
webappLogo="https://example.com/custom-logo.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/custom-logo.png?hash=42')
|
||||
})
|
||||
|
||||
it('should fall back to the Dify logo when no custom branding exists', () => {
|
||||
render(<PoweredByBrand imgKey={7} />)
|
||||
|
||||
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when branding is removed', () => {
|
||||
const { container } = render(<PoweredByBrand imgKey={7} webappBrandRemoved />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import WorkflowPreviewCard from '../workflow-preview-card'
|
||||
|
||||
describe('WorkflowPreviewCard', () => {
|
||||
it('should render the workflow preview with execute action and branding footer', () => {
|
||||
render(
|
||||
<WorkflowPreviewCard
|
||||
imgKey={9}
|
||||
workspaceLogo="https://example.com/workspace-logo.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Workflow App')).toBeInTheDocument()
|
||||
expect(screen.getByText('RUN ONCE')).toBeInTheDocument()
|
||||
expect(screen.getByText('RUN BATCH')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Execute/i })).toBeDisabled()
|
||||
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png')
|
||||
})
|
||||
|
||||
it('should hide workflow branding footer when brand removal is enabled', () => {
|
||||
render(
|
||||
<WorkflowPreviewCard
|
||||
imgKey={9}
|
||||
webappBrandRemoved
|
||||
workspaceLogo="https://example.com/workspace-logo.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('POWERED BY')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import Button from '@/app/components/base/button'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import PoweredByBrand from './powered-by-brand'
|
||||
|
||||
type ChatPreviewCardProps = {
|
||||
webappBrandRemoved?: boolean
|
||||
workspaceLogo?: string
|
||||
webappLogo?: string
|
||||
imgKey: number
|
||||
}
|
||||
|
||||
const ChatPreviewCard = ({
|
||||
webappBrandRemoved,
|
||||
workspaceLogo,
|
||||
webappLogo,
|
||||
imgKey,
|
||||
}: ChatPreviewCardProps) => {
|
||||
return (
|
||||
<div className="flex h-[320px] grow basis-1/2 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
|
||||
<div className="flex h-full w-[232px] shrink-0 flex-col p-1 pr-0">
|
||||
<div className="flex items-center gap-3 p-3 pr-2">
|
||||
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-blue-light-solid')}>
|
||||
<span className="i-custom-vender-solid-communication-bubble-text-mod h-4 w-4 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="grow text-text-secondary system-md-semibold">Chatflow App</div>
|
||||
<div className="p-1.5">
|
||||
<span className="i-ri-layout-left-2-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-3">
|
||||
<Button variant="secondary-accent" className="w-full justify-center">
|
||||
<span className="i-ri-edit-box-line mr-1 h-4 w-4" />
|
||||
<div className="p-1 opacity-20">
|
||||
<div className="h-2 w-[94px] rounded-sm bg-text-accent-light-mode-only"></div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grow px-3 pt-5">
|
||||
<div className="flex h-8 items-center px-3 py-1">
|
||||
<div className="h-2 w-14 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="flex h-8 items-center px-3 py-1">
|
||||
<div className="h-2 w-[168px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="flex h-8 items-center px-3 py-1">
|
||||
<div className="h-2 w-[128px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between p-3">
|
||||
<div className="p-1.5">
|
||||
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PoweredByBrand
|
||||
webappBrandRemoved={webappBrandRemoved}
|
||||
workspaceLogo={workspaceLogo}
|
||||
webappLogo={webappLogo}
|
||||
imgKey={imgKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-[138px] grow flex-col justify-between p-2 pr-0">
|
||||
<div className="flex grow flex-col justify-between rounded-l-2xl border-[0.5px] border-r-0 border-components-panel-border-subtle bg-chatbot-bg pb-4 pl-[22px] pt-16">
|
||||
<div className="w-[720px] rounded-2xl border border-divider-subtle bg-chat-bubble-bg px-4 py-3">
|
||||
<div className="mb-1 text-text-primary body-md-regular">Hello! How can I assist you today?</div>
|
||||
<Button size="small">
|
||||
<div className="h-2 w-[144px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm body-lg-regular">Talk to Dify</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatPreviewCard
|
||||
@@ -0,0 +1,31 @@
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
|
||||
type PoweredByBrandProps = {
|
||||
webappBrandRemoved?: boolean
|
||||
workspaceLogo?: string
|
||||
webappLogo?: string
|
||||
imgKey: number
|
||||
}
|
||||
|
||||
const PoweredByBrand = ({
|
||||
webappBrandRemoved,
|
||||
workspaceLogo,
|
||||
webappLogo,
|
||||
imgKey,
|
||||
}: PoweredByBrandProps) => {
|
||||
if (webappBrandRemoved)
|
||||
return null
|
||||
|
||||
const previewLogo = workspaceLogo || (webappLogo ? `${webappLogo}?hash=${imgKey}` : '')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
|
||||
{previewLogo
|
||||
? <img src={previewLogo} alt="logo" className="block h-5 w-auto" />
|
||||
: <DifyLogo size="small" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PoweredByBrand
|
||||
@@ -0,0 +1,64 @@
|
||||
import Button from '@/app/components/base/button'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import PoweredByBrand from './powered-by-brand'
|
||||
|
||||
type WorkflowPreviewCardProps = {
|
||||
webappBrandRemoved?: boolean
|
||||
workspaceLogo?: string
|
||||
webappLogo?: string
|
||||
imgKey: number
|
||||
}
|
||||
|
||||
const WorkflowPreviewCard = ({
|
||||
webappBrandRemoved,
|
||||
workspaceLogo,
|
||||
webappLogo,
|
||||
imgKey,
|
||||
}: WorkflowPreviewCardProps) => {
|
||||
return (
|
||||
<div className="flex h-[320px] grow basis-1/2 flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
|
||||
<div className="w-full border-b-[0.5px] border-divider-subtle p-4 pb-0">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-indigo-solid')}>
|
||||
<span className="i-ri-exchange-2-fill h-4 w-4 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="grow text-text-secondary system-md-semibold">Workflow App</div>
|
||||
<div className="p-1.5">
|
||||
<span className="i-ri-layout-left-2-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary system-md-semibold-uppercase">RUN ONCE</div>
|
||||
<div className="flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary system-md-semibold-uppercase">RUN BATCH</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow bg-components-panel-bg">
|
||||
<div className="p-4 pb-1">
|
||||
<div className="mb-1 py-2">
|
||||
<div className="h-2 w-20 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<Button size="small">
|
||||
<div className="h-2 w-10 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</Button>
|
||||
<Button variant="primary" size="small" disabled>
|
||||
<span className="i-ri-play-large-line mr-1 h-4 w-4" />
|
||||
<span>Execute</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-12 shrink-0 items-center gap-1.5 bg-components-panel-bg p-4 pt-3">
|
||||
<PoweredByBrand
|
||||
webappBrandRemoved={webappBrandRemoved}
|
||||
workspaceLogo={workspaceLogo}
|
||||
webappLogo={webappLogo}
|
||||
imgKey={imgKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowPreviewCard
|
||||
@@ -0,0 +1,385 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import {
|
||||
initialLangGeniusVersionInfo,
|
||||
initialWorkspaceInfo,
|
||||
useAppContext,
|
||||
userProfilePlaceholder,
|
||||
} from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { updateCurrentWorkspace } from '@/service/common'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import useWebAppBrand from '../use-web-app-brand'
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/common', () => ({
|
||||
updateCurrentWorkspace: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/context/app-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/app-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useAppContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/app/components/base/image-uploader/utils', () => ({
|
||||
imageUpload: vi.fn(),
|
||||
getImageUploadErrorMessage: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockUseToastContext = vi.mocked(useToastContext)
|
||||
const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace)
|
||||
const mockUseAppContext = vi.mocked(useAppContext)
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockImageUpload = vi.mocked(imageUpload)
|
||||
const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage)
|
||||
|
||||
const createProviderContext = ({
|
||||
enableBilling = false,
|
||||
planType = Plan.professional,
|
||||
}: {
|
||||
enableBilling?: boolean
|
||||
planType?: Plan
|
||||
} = {}) => {
|
||||
return createMockProviderContextValue({
|
||||
enableBilling,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const createSystemFeatures = (brandingOverrides: Partial<SystemFeatures['branding']> = {}): SystemFeatures => ({
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: true,
|
||||
workspace_logo: 'https://example.com/workspace-logo.png',
|
||||
...brandingOverrides,
|
||||
},
|
||||
})
|
||||
|
||||
const createAppContextValue = (overrides: Partial<AppContextValue> = {}): AppContextValue => {
|
||||
const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides
|
||||
const workspaceOverrides: Partial<AppContextValue['currentWorkspace']> = currentWorkspaceOverride ?? {}
|
||||
const currentWorkspace = {
|
||||
...initialWorkspaceInfo,
|
||||
...workspaceOverrides,
|
||||
custom_config: {
|
||||
replace_webapp_logo: 'https://example.com/replace.png',
|
||||
remove_webapp_brand: false,
|
||||
...workspaceOverrides.custom_config,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
userProfile: userProfilePlaceholder,
|
||||
mutateUserProfile: vi.fn(),
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isCurrentWorkspaceEditor: false,
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
langGeniusVersionInfo: initialLangGeniusVersionInfo,
|
||||
useSelector: vi.fn() as unknown as AppContextValue['useSelector'],
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isValidatingCurrentWorkspace: false,
|
||||
...restOverrides,
|
||||
currentWorkspace,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useWebAppBrand', () => {
|
||||
let appContextValue: AppContextValue
|
||||
let systemFeatures: SystemFeatures
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
appContextValue = createAppContextValue()
|
||||
systemFeatures = createSystemFeatures()
|
||||
|
||||
mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType<typeof useToastContext>)
|
||||
mockUpdateCurrentWorkspace.mockResolvedValue(appContextValue.currentWorkspace)
|
||||
mockUseAppContext.mockImplementation(() => appContextValue)
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext())
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector({
|
||||
systemFeatures,
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
mockGetImageUploadErrorMessage.mockReturnValue('upload error')
|
||||
})
|
||||
|
||||
// Derived state from context and store inputs.
|
||||
describe('derived state', () => {
|
||||
it('should expose workspace branding and upload availability by default', () => {
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
expect(result.current.webappLogo).toBe('https://example.com/replace.png')
|
||||
expect(result.current.workspaceLogo).toBe('https://example.com/workspace-logo.png')
|
||||
expect(result.current.uploadDisabled).toBe(false)
|
||||
expect(result.current.uploading).toBe(false)
|
||||
})
|
||||
|
||||
it('should disable uploads in sandbox workspaces and when branding is removed', () => {
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
enableBilling: true,
|
||||
planType: Plan.sandbox,
|
||||
}))
|
||||
appContextValue = createAppContextValue({
|
||||
currentWorkspace: {
|
||||
...initialWorkspaceInfo,
|
||||
custom_config: {
|
||||
replace_webapp_logo: 'https://example.com/replace.png',
|
||||
remove_webapp_brand: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
expect(result.current.isSandbox).toBe(true)
|
||||
expect(result.current.webappBrandRemoved).toBe(true)
|
||||
expect(result.current.uploadDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should fall back to an empty workspace logo when branding is disabled', () => {
|
||||
systemFeatures = createSystemFeatures({
|
||||
enabled: false,
|
||||
workspace_logo: '',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
expect(result.current.workspaceLogo).toBe('')
|
||||
})
|
||||
|
||||
it('should fall back to an empty custom logo when custom config is missing', () => {
|
||||
appContextValue = {
|
||||
...createAppContextValue(),
|
||||
currentWorkspace: {
|
||||
...initialWorkspaceInfo,
|
||||
},
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
expect(result.current.webappLogo).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// State transitions driven by user actions.
|
||||
describe('actions', () => {
|
||||
it('should ignore empty file selections', () => {
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(mockImageUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject oversized files before upload starts', () => {
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
const oversizedFile = new File(['logo'], 'logo.png', { type: 'image/png' })
|
||||
|
||||
Object.defineProperty(oversizedFile, 'size', {
|
||||
configurable: true,
|
||||
value: 5 * 1024 * 1024 + 1,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [oversizedFile] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(mockImageUpload).not.toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.imageUploader.uploadFromComputerLimit:{"size":5}',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update upload state after a successful file upload', () => {
|
||||
mockImageUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }) => {
|
||||
onProgressCallback(100)
|
||||
onSuccessCallback({ id: 'new-logo' })
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(result.current.fileId).toBe('new-logo')
|
||||
expect(result.current.uploadProgress).toBe(100)
|
||||
expect(result.current.uploading).toBe(false)
|
||||
})
|
||||
|
||||
it('should expose the uploading state while progress is incomplete', () => {
|
||||
mockImageUpload.mockImplementation(({ onProgressCallback }) => {
|
||||
onProgressCallback(50)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(result.current.uploadProgress).toBe(50)
|
||||
expect(result.current.uploading).toBe(true)
|
||||
})
|
||||
|
||||
it('should surface upload errors and set the failure state', () => {
|
||||
mockImageUpload.mockImplementation(({ onErrorCallback }) => {
|
||||
onErrorCallback({ response: { code: 'forbidden' } })
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(mockGetImageUploadErrorMessage).toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'upload error',
|
||||
})
|
||||
expect(result.current.uploadProgress).toBe(-1)
|
||||
})
|
||||
|
||||
it('should persist the selected logo and reset transient state on apply', async () => {
|
||||
const mutateCurrentWorkspace = vi.fn()
|
||||
appContextValue = createAppContextValue({
|
||||
mutateCurrentWorkspace,
|
||||
})
|
||||
mockImageUpload.mockImplementation(({ onSuccessCallback }) => {
|
||||
onSuccessCallback({ id: 'new-logo' })
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
const previousImgKey = result.current.imgKey
|
||||
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(previousImgKey + 1)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleApply()
|
||||
})
|
||||
|
||||
expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
|
||||
url: '/workspaces/custom-config',
|
||||
body: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: 'new-logo',
|
||||
},
|
||||
})
|
||||
expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.fileId).toBe('')
|
||||
expect(result.current.imgKey).toBe(previousImgKey + 1)
|
||||
dateNowSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should restore the default branding configuration', async () => {
|
||||
const mutateCurrentWorkspace = vi.fn()
|
||||
appContextValue = createAppContextValue({
|
||||
mutateCurrentWorkspace,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRestore()
|
||||
})
|
||||
|
||||
expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
|
||||
url: '/workspaces/custom-config',
|
||||
body: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
})
|
||||
expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should persist brand removal changes', async () => {
|
||||
const mutateCurrentWorkspace = vi.fn()
|
||||
appContextValue = createAppContextValue({
|
||||
mutateCurrentWorkspace,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSwitch(true)
|
||||
})
|
||||
|
||||
expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
|
||||
url: '/workspaces/custom-config',
|
||||
body: {
|
||||
remove_webapp_brand: true,
|
||||
},
|
||||
})
|
||||
expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should clear temporary upload state on cancel', () => {
|
||||
mockImageUpload.mockImplementation(({ onSuccessCallback }) => {
|
||||
onSuccessCallback({ id: 'new-logo' })
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancel()
|
||||
})
|
||||
|
||||
expect(result.current.fileId).toBe('')
|
||||
expect(result.current.uploadProgress).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { updateCurrentWorkspace } from '@/service/common'
|
||||
|
||||
const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024
|
||||
const CUSTOM_CONFIG_URL = '/workspaces/custom-config'
|
||||
const WEB_APP_LOGO_UPLOAD_URL = '/workspaces/custom-config/webapp-logo/upload'
|
||||
|
||||
const useWebAppBrand = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const {
|
||||
currentWorkspace,
|
||||
mutateCurrentWorkspace,
|
||||
isCurrentWorkspaceManager,
|
||||
} = useAppContext()
|
||||
const [fileId, setFileId] = useState('')
|
||||
const [imgKey, setImgKey] = useState(() => Date.now())
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const isSandbox = enableBilling && plan.type === Plan.sandbox
|
||||
const uploading = uploadProgress > 0 && uploadProgress < 100
|
||||
const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || ''
|
||||
const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand
|
||||
const uploadDisabled = isSandbox || webappBrandRemoved || !isCurrentWorkspaceManager
|
||||
const workspaceLogo = systemFeatures.branding.enabled ? systemFeatures.branding.workspace_logo : ''
|
||||
|
||||
const persistWorkspaceBrand = async (body: Record<string, unknown>) => {
|
||||
await updateCurrentWorkspace({
|
||||
url: CUSTOM_CONFIG_URL,
|
||||
body,
|
||||
})
|
||||
mutateCurrentWorkspace()
|
||||
}
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
if (!file)
|
||||
return
|
||||
|
||||
if (file.size > MAX_LOGO_FILE_SIZE) {
|
||||
notify({ type: 'error', message: t('imageUploader.uploadFromComputerLimit', { ns: 'common', size: 5 }) })
|
||||
return
|
||||
}
|
||||
|
||||
imageUpload({
|
||||
file,
|
||||
onProgressCallback: setUploadProgress,
|
||||
onSuccessCallback: (res) => {
|
||||
setUploadProgress(100)
|
||||
setFileId(res.id)
|
||||
},
|
||||
onErrorCallback: (error) => {
|
||||
const errorMessage = getImageUploadErrorMessage(
|
||||
error,
|
||||
t('imageUploader.uploadFromComputerUploadError', { ns: 'common' }),
|
||||
t,
|
||||
)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
setUploadProgress(-1)
|
||||
},
|
||||
}, false, WEB_APP_LOGO_UPLOAD_URL)
|
||||
}
|
||||
|
||||
const handleApply = async () => {
|
||||
await persistWorkspaceBrand({
|
||||
remove_webapp_brand: webappBrandRemoved,
|
||||
replace_webapp_logo: fileId,
|
||||
})
|
||||
setFileId('')
|
||||
setImgKey(Date.now())
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
await persistWorkspaceBrand({
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleSwitch = async (checked: boolean) => {
|
||||
await persistWorkspaceBrand({
|
||||
remove_webapp_brand: checked,
|
||||
})
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setFileId('')
|
||||
setUploadProgress(0)
|
||||
}
|
||||
|
||||
return {
|
||||
fileId,
|
||||
imgKey,
|
||||
uploadProgress,
|
||||
uploading,
|
||||
webappLogo,
|
||||
webappBrandRemoved,
|
||||
uploadDisabled,
|
||||
workspaceLogo,
|
||||
isSandbox,
|
||||
isCurrentWorkspaceManager,
|
||||
handleApply,
|
||||
handleCancel,
|
||||
handleChange,
|
||||
handleRestore,
|
||||
handleSwitch,
|
||||
}
|
||||
}
|
||||
|
||||
export default useWebAppBrand
|
||||
@@ -1,118 +1,33 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import {
|
||||
RiEditBoxLine,
|
||||
RiEqualizer2Line,
|
||||
RiExchange2Fill,
|
||||
RiImageAddLine,
|
||||
RiLayoutLeft2Line,
|
||||
RiLoader2Line,
|
||||
RiPlayLargeLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { BubbleTextMod } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
updateCurrentWorkspace,
|
||||
} from '@/service/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ChatPreviewCard from './components/chat-preview-card'
|
||||
import WorkflowPreviewCard from './components/workflow-preview-card'
|
||||
import useWebAppBrand from './hooks/use-web-app-brand'
|
||||
|
||||
const ALLOW_FILE_EXTENSIONS = ['svg', 'png']
|
||||
|
||||
const CustomWebAppBrand = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const {
|
||||
currentWorkspace,
|
||||
mutateCurrentWorkspace,
|
||||
fileId,
|
||||
imgKey,
|
||||
uploadProgress,
|
||||
uploading,
|
||||
webappLogo,
|
||||
webappBrandRemoved,
|
||||
uploadDisabled,
|
||||
workspaceLogo,
|
||||
isCurrentWorkspaceManager,
|
||||
} = useAppContext()
|
||||
const [fileId, setFileId] = useState('')
|
||||
const [imgKey, setImgKey] = useState(() => Date.now())
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const isSandbox = enableBilling && plan.type === Plan.sandbox
|
||||
const uploading = uploadProgress > 0 && uploadProgress < 100
|
||||
const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || ''
|
||||
const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand
|
||||
const uploadDisabled = isSandbox || webappBrandRemoved || !isCurrentWorkspaceManager
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
if (!file)
|
||||
return
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
notify({ type: 'error', message: t('imageUploader.uploadFromComputerLimit', { ns: 'common', size: 5 }) })
|
||||
return
|
||||
}
|
||||
|
||||
imageUpload({
|
||||
file,
|
||||
onProgressCallback: (progress) => {
|
||||
setUploadProgress(progress)
|
||||
},
|
||||
onSuccessCallback: (res) => {
|
||||
setUploadProgress(100)
|
||||
setFileId(res.id)
|
||||
},
|
||||
onErrorCallback: (error?: any) => {
|
||||
const errorMessage = getImageUploadErrorMessage(error, t('imageUploader.uploadFromComputerUploadError', { ns: 'common' }), t as any)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
setUploadProgress(-1)
|
||||
},
|
||||
}, false, '/workspaces/custom-config/webapp-logo/upload')
|
||||
}
|
||||
|
||||
const handleApply = async () => {
|
||||
await updateCurrentWorkspace({
|
||||
url: '/workspaces/custom-config',
|
||||
body: {
|
||||
remove_webapp_brand: webappBrandRemoved,
|
||||
replace_webapp_logo: fileId,
|
||||
},
|
||||
})
|
||||
mutateCurrentWorkspace()
|
||||
setFileId('')
|
||||
setImgKey(Date.now())
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
await updateCurrentWorkspace({
|
||||
url: '/workspaces/custom-config',
|
||||
body: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
})
|
||||
mutateCurrentWorkspace()
|
||||
}
|
||||
|
||||
const handleSwitch = async (checked: boolean) => {
|
||||
await updateCurrentWorkspace({
|
||||
url: '/workspaces/custom-config',
|
||||
body: {
|
||||
remove_webapp_brand: checked,
|
||||
},
|
||||
})
|
||||
mutateCurrentWorkspace()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setFileId('')
|
||||
setUploadProgress(0)
|
||||
}
|
||||
isSandbox,
|
||||
handleApply,
|
||||
handleCancel,
|
||||
handleChange,
|
||||
handleRestore,
|
||||
handleSwitch,
|
||||
} = useWebAppBrand()
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
@@ -149,7 +64,7 @@ const CustomWebAppBrand = () => {
|
||||
className="relative mr-2"
|
||||
disabled={uploadDisabled}
|
||||
>
|
||||
<RiImageAddLine className="mr-1 h-4 w-4" />
|
||||
<span className="i-ri-image-add-line mr-1 h-4 w-4" />
|
||||
{
|
||||
(webappLogo || fileId)
|
||||
? t('change', { ns: 'custom' })
|
||||
@@ -172,7 +87,7 @@ const CustomWebAppBrand = () => {
|
||||
className="relative mr-2"
|
||||
disabled={true}
|
||||
>
|
||||
<RiLoader2Line className="mr-1 h-4 w-4 animate-spin" />
|
||||
<span className="i-ri-loader-2-line mr-1 h-4 w-4 animate-spin" />
|
||||
{t('uploading', { ns: 'custom' })}
|
||||
</Button>
|
||||
)
|
||||
@@ -208,118 +123,18 @@ const CustomWebAppBrand = () => {
|
||||
<Divider bgStyle="gradient" className="grow" />
|
||||
</div>
|
||||
<div className="relative mb-2 flex items-center gap-3">
|
||||
{/* chat card */}
|
||||
<div className="flex h-[320px] grow basis-1/2 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
|
||||
<div className="flex h-full w-[232px] shrink-0 flex-col p-1 pr-0">
|
||||
<div className="flex items-center gap-3 p-3 pr-2">
|
||||
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-blue-light-solid')}>
|
||||
<BubbleTextMod className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="grow text-text-secondary system-md-semibold">Chatflow App</div>
|
||||
<div className="p-1.5">
|
||||
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-3">
|
||||
<Button variant="secondary-accent" className="w-full justify-center">
|
||||
<RiEditBoxLine className="mr-1 h-4 w-4" />
|
||||
<div className="p-1 opacity-20">
|
||||
<div className="h-2 w-[94px] rounded-sm bg-text-accent-light-mode-only"></div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grow px-3 pt-5">
|
||||
<div className="flex h-8 items-center px-3 py-1">
|
||||
<div className="h-2 w-14 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="flex h-8 items-center px-3 py-1">
|
||||
<div className="h-2 w-[168px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="flex h-8 items-center px-3 py-1">
|
||||
<div className="h-2 w-[128px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between p-3">
|
||||
<div className="p-1.5">
|
||||
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!webappBrandRemoved && (
|
||||
<>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
|
||||
: webappLogo
|
||||
? <img src={`${webappLogo}?hash=${imgKey}`} alt="logo" className="block h-5 w-auto" />
|
||||
: <DifyLogo size="small" />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-[138px] grow flex-col justify-between p-2 pr-0">
|
||||
<div className="flex grow flex-col justify-between rounded-l-2xl border-[0.5px] border-r-0 border-components-panel-border-subtle bg-chatbot-bg pb-4 pl-[22px] pt-16">
|
||||
<div className="w-[720px] rounded-2xl border border-divider-subtle bg-chat-bubble-bg px-4 py-3">
|
||||
<div className="mb-1 text-text-primary body-md-regular">Hello! How can I assist you today?</div>
|
||||
<Button size="small">
|
||||
<div className="h-2 w-[144px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm body-lg-regular">Talk to Dify</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* workflow card */}
|
||||
<div className="flex h-[320px] grow basis-1/2 flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
|
||||
<div className="w-full border-b-[0.5px] border-divider-subtle p-4 pb-0">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-indigo-solid')}>
|
||||
<RiExchange2Fill className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="grow text-text-secondary system-md-semibold">Workflow App</div>
|
||||
<div className="p-1.5">
|
||||
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary system-md-semibold-uppercase">RUN ONCE</div>
|
||||
<div className="flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary system-md-semibold-uppercase">RUN BATCH</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow bg-components-panel-bg">
|
||||
<div className="p-4 pb-1">
|
||||
<div className="mb-1 py-2">
|
||||
<div className="h-2 w-20 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<Button size="small">
|
||||
<div className="h-2 w-10 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</Button>
|
||||
<Button variant="primary" size="small" disabled>
|
||||
<RiPlayLargeLine className="mr-1 h-4 w-4" />
|
||||
<span>Execute</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-12 shrink-0 items-center gap-1.5 bg-components-panel-bg p-4 pt-3">
|
||||
{!webappBrandRemoved && (
|
||||
<>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
|
||||
: webappLogo
|
||||
? <img src={`${webappLogo}?hash=${imgKey}`} alt="logo" className="block h-5 w-auto" />
|
||||
: <DifyLogo size="small" />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChatPreviewCard
|
||||
webappBrandRemoved={webappBrandRemoved}
|
||||
workspaceLogo={workspaceLogo}
|
||||
webappLogo={webappLogo}
|
||||
imgKey={imgKey}
|
||||
/>
|
||||
<WorkflowPreviewCard
|
||||
webappBrandRemoved={webappBrandRemoved}
|
||||
workspaceLogo={workspaceLogo}
|
||||
webappLogo={webappLogo}
|
||||
imgKey={imgKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,13 +4,6 @@ import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { retrievalIcon } from '../../../create/icons'
|
||||
import RetrievalMethodInfo, { getIcon } from '../index'
|
||||
|
||||
// Override global next/image auto-mock: tests assert on rendered <img> src attributes via data-testid
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
|
||||
<img src={src} alt={alt || ''} className={className} data-testid="method-icon" />
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock RadioCard
|
||||
vi.mock('@/app/components/base/radio-card', () => ({
|
||||
default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => (
|
||||
@@ -50,7 +43,7 @@ describe('RetrievalMethodInfo', () => {
|
||||
})
|
||||
|
||||
it('should render correctly with full config', () => {
|
||||
render(<RetrievalMethodInfo value={defaultConfig} />)
|
||||
const { container } = render(<RetrievalMethodInfo value={defaultConfig} />)
|
||||
|
||||
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
|
||||
|
||||
@@ -59,7 +52,7 @@ describe('RetrievalMethodInfo', () => {
|
||||
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
|
||||
|
||||
// Check Icon
|
||||
const icon = screen.getByTestId('method-icon')
|
||||
const icon = container.querySelector('img')
|
||||
expect(icon).toHaveAttribute('src', 'vector-icon.png')
|
||||
|
||||
// Check Config Details
|
||||
@@ -87,18 +80,18 @@ describe('RetrievalMethodInfo', () => {
|
||||
it('should handle different retrieval methods', () => {
|
||||
// Test Hybrid
|
||||
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
|
||||
const { unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
|
||||
const { container, unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
|
||||
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png')
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'hybrid-icon.png')
|
||||
|
||||
unmount()
|
||||
|
||||
// Test FullText
|
||||
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
|
||||
render(<RetrievalMethodInfo value={fullTextConfig} />)
|
||||
const { container: fullTextContainer } = render(<RetrievalMethodInfo value={fullTextConfig} />)
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png')
|
||||
expect(fullTextContainer.querySelector('img')).toHaveAttribute('src', 'fulltext-icon.png')
|
||||
})
|
||||
|
||||
describe('getIcon utility', () => {
|
||||
@@ -132,17 +125,17 @@ describe('RetrievalMethodInfo', () => {
|
||||
|
||||
it('should render correctly with invertedIndex search method', () => {
|
||||
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
|
||||
render(<RetrievalMethodInfo value={invertedIndexConfig} />)
|
||||
const { container } = render(<RetrievalMethodInfo value={invertedIndexConfig} />)
|
||||
|
||||
// invertedIndex uses vector icon
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
|
||||
})
|
||||
|
||||
it('should render correctly with keywordSearch search method', () => {
|
||||
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
|
||||
render(<RetrievalMethodInfo value={keywordSearchConfig} />)
|
||||
const { container } = render(<RetrievalMethodInfo value={keywordSearchConfig} />)
|
||||
|
||||
// keywordSearch uses vector icon
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RadioCard from '@/app/components/base/radio-card'
|
||||
@@ -28,7 +27,7 @@ const EconomicalRetrievalMethodConfig: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const type = value.search_method
|
||||
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" />
|
||||
const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" />
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<RadioCard
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import Image from 'next/image'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
@@ -127,7 +126,7 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<span className="system-sm-semibold mr-0.5 text-text-secondary">{t('modelProvider.rerankModel.key', { ns: 'common' })}</span>
|
||||
<span className="mr-0.5 text-text-secondary system-sm-semibold">{t('modelProvider.rerankModel.key', { ns: 'common' })}</span>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className="w-[200px]">{t('modelProvider.rerankModel.tip', { ns: 'common' })}</div>
|
||||
@@ -157,7 +156,7 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
<div className="p-1">
|
||||
<AlertTriangle className="size-4 text-text-warning-secondary" />
|
||||
</div>
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
<span className="text-text-primary system-xs-medium">
|
||||
{t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -215,11 +214,11 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
isChosen={value.reranking_mode === option.value}
|
||||
onChosen={() => handleChangeRerankMode(option.value)}
|
||||
icon={(
|
||||
<Image
|
||||
<img
|
||||
src={
|
||||
option.value === RerankingModeEnum.WeightedScore
|
||||
? ProgressIndicator
|
||||
: Reranking
|
||||
? ProgressIndicator.src
|
||||
: Reranking.src
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
@@ -281,7 +280,7 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
<div className="p-1">
|
||||
<AlertTriangle className="size-4 text-text-warning-secondary" />
|
||||
</div>
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
<span className="text-text-primary system-xs-medium">
|
||||
{t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -20,14 +20,6 @@ vi.mock('next/navigation', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
}))
|
||||
|
||||
// Override global next/image auto-mock: test asserts on data-testid="next-image"
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
|
||||
// eslint-disable-next-line next/no-img-element
|
||||
<img src={src} alt={alt} className={className} data-testid="next-image" />
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock API service
|
||||
const mockFetchIndexingStatusBatch = vi.fn()
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
@@ -979,9 +971,9 @@ describe('RuleDetail', () => {
|
||||
})
|
||||
|
||||
it('should render correct icon for indexing type', () => {
|
||||
render(<RuleDetail indexingType="high_quality" />)
|
||||
const { container } = render(<RuleDetail indexingType="high_quality" />)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ProcessRuleResponse } from '@/models/datasets'
|
||||
import Image from 'next/image'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata'
|
||||
@@ -119,12 +118,12 @@ const RuleDetail: FC<RuleDetailProps> = ({ sourceData, indexingType, retrievalMe
|
||||
<FieldInfo
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={indexModeLabel}
|
||||
valueIcon={<Image className="size-4" src={indexMethodIconSrc} alt="" />}
|
||||
valueIcon={<img className="size-4" src={indexMethodIconSrc} alt="" />}
|
||||
/>
|
||||
<FieldInfo
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
displayedValue={retrievalLabel}
|
||||
valueIcon={<Image className="size-4" src={retrievalIconSrc} alt="" />}
|
||||
valueIcon={<img className="size-4" src={retrievalIconSrc} alt="" />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,12 +5,12 @@ import Research from './assets/research-mod.svg'
|
||||
import Selection from './assets/selection-mod.svg'
|
||||
|
||||
export const indexMethodIcon = {
|
||||
high_quality: GoldIcon,
|
||||
economical: Piggybank,
|
||||
high_quality: GoldIcon.src,
|
||||
economical: Piggybank.src,
|
||||
}
|
||||
|
||||
export const retrievalIcon = {
|
||||
vector: Selection,
|
||||
fullText: Research,
|
||||
hybrid: PatternRecognition,
|
||||
vector: Selection.src,
|
||||
fullText: Research.src,
|
||||
hybrid: PatternRecognition.src,
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -47,19 +47,34 @@ describe('MaxLengthInput', () => {
|
||||
|
||||
it('should render number input', () => {
|
||||
render(<MaxLengthInput onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept value prop', () => {
|
||||
render(<MaxLengthInput value={500} onChange={vi.fn()} />)
|
||||
expect(screen.getByDisplayValue('500')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('500')
|
||||
})
|
||||
|
||||
it('should have min of 1', () => {
|
||||
render(<MaxLengthInput onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveAttribute('min', '1')
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,18 +90,33 @@ describe('OverlapInput', () => {
|
||||
|
||||
it('should render number input', () => {
|
||||
render(<OverlapInput onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept value prop', () => {
|
||||
render(<OverlapInput value={50} onChange={vi.fn()} />)
|
||||
expect(screen.getByDisplayValue('50')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('50')
|
||||
})
|
||||
|
||||
it('should have min of 1', () => {
|
||||
render(<OverlapInput onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveAttribute('min', '1')
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,13 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OptionCard, OptionCardHeader } from '../option-card'
|
||||
|
||||
// Override global next/image auto-mock: tests assert on rendered <img> elements
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, ...props }: { src?: string, alt?: string, width?: number, height?: number }) => (
|
||||
<img src={src} alt={alt} {...props} />
|
||||
),
|
||||
}))
|
||||
|
||||
describe('OptionCardHeader', () => {
|
||||
const defaultProps = {
|
||||
icon: <span data-testid="icon">icon</span>,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
RiAlertFill,
|
||||
RiSearchEyeLine,
|
||||
} from '@remixicon/react'
|
||||
import Image from 'next/image'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
@@ -26,7 +25,7 @@ type TextLabelProps = {
|
||||
}
|
||||
|
||||
const TextLabel: FC<TextLabelProps> = ({ children }) => {
|
||||
return <label className="system-sm-semibold text-text-secondary">{children}</label>
|
||||
return <label className="text-text-secondary system-sm-semibold">{children}</label>
|
||||
}
|
||||
|
||||
type GeneralChunkingOptionsProps = {
|
||||
@@ -97,7 +96,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
|
||||
<OptionCard
|
||||
className="mb-2 bg-background-section"
|
||||
title={t('stepTwo.general', { ns: 'datasetCreation' })}
|
||||
icon={<Image width={20} height={20} src={SettingCog} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
|
||||
icon={<img width={20} height={20} src={SettingCog.src} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
|
||||
activeHeaderClassName="bg-dataset-option-card-blue-gradient"
|
||||
description={t('stepTwo.generalTip', { ns: 'datasetCreation' })}
|
||||
isActive={isActive}
|
||||
@@ -148,7 +147,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
|
||||
onClick={() => onRuleToggle(rule.id)}
|
||||
>
|
||||
<Checkbox checked={rule.enabled} />
|
||||
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
|
||||
<label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
|
||||
{getRuleName(rule.id)}
|
||||
</label>
|
||||
</div>
|
||||
@@ -183,7 +182,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
|
||||
checked={currentDocForm === ChunkingMode.qa}
|
||||
disabled={hasCurrentDatasetDocForm}
|
||||
/>
|
||||
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
|
||||
<label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
|
||||
{t('stepTwo.useQALanguage', { ns: 'datasetCreation' })}
|
||||
</label>
|
||||
</div>
|
||||
@@ -202,7 +201,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
|
||||
className="mt-2 flex h-10 items-center gap-2 rounded-xl border border-components-panel-border px-3 text-xs shadow-xs backdrop-blur-[5px]"
|
||||
>
|
||||
<RiAlertFill className="size-4 text-text-warning-secondary" />
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
<span className="text-text-primary system-xs-medium">
|
||||
{t('stepTwo.QATip', { ns: 'datasetCreation' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { FC } from 'react'
|
||||
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
@@ -70,7 +69,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
return (
|
||||
<>
|
||||
{/* Index Mode */}
|
||||
<div className="system-md-semibold mb-1 text-text-secondary">
|
||||
<div className="mb-1 text-text-secondary system-md-semibold">
|
||||
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -98,7 +97,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
</div>
|
||||
)}
|
||||
description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })}
|
||||
icon={<Image src={indexMethodIcon.high_quality} alt="" />}
|
||||
icon={<img src={indexMethodIcon.high_quality} alt="" />}
|
||||
isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED}
|
||||
disabled={hasSetIndexType}
|
||||
onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)}
|
||||
@@ -143,7 +142,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
className="h-full"
|
||||
title={t('stepTwo.economical', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
|
||||
icon={<Image src={indexMethodIcon.economical} alt="" />}
|
||||
icon={<img src={indexMethodIcon.economical} alt="" />}
|
||||
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
|
||||
disabled={hasSetIndexType || docForm !== ChunkingMode.text}
|
||||
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
|
||||
@@ -160,7 +159,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
<div className="p-1">
|
||||
<AlertTriangle className="size-4 text-text-warning-secondary" />
|
||||
</div>
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
<span className="text-text-primary system-xs-medium">
|
||||
{t('stepTwo.highQualityTip', { ns: 'datasetCreation' })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -168,7 +167,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
|
||||
{/* Economical index setting tip */}
|
||||
{hasSetIndexType && indexType === IndexingType.ECONOMICAL && (
|
||||
<div className="system-xs-medium mt-2 text-text-tertiary">
|
||||
<div className="mt-2 text-text-tertiary system-xs-medium">
|
||||
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
|
||||
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
|
||||
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
|
||||
@@ -179,7 +178,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
{/* Embedding model */}
|
||||
{indexType === IndexingType.QUALIFIED && (
|
||||
<div className="mt-5">
|
||||
<div className={cn('system-md-semibold mb-1 text-text-secondary', datasetId && 'flex items-center justify-between')}>
|
||||
<div className={cn('mb-1 text-text-secondary system-md-semibold', datasetId && 'flex items-center justify-between')}>
|
||||
{t('form.embeddingModel', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<ModelSelector
|
||||
@@ -190,7 +189,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
onSelect={onEmbeddingModelChange}
|
||||
/>
|
||||
{isModelAndRetrievalConfigDisabled && (
|
||||
<div className="system-xs-medium mt-2 text-text-tertiary">
|
||||
<div className="mt-2 text-text-tertiary system-xs-medium">
|
||||
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
|
||||
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
|
||||
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
|
||||
@@ -207,10 +206,10 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
{!isModelAndRetrievalConfigDisabled
|
||||
? (
|
||||
<div className="mb-1">
|
||||
<div className="system-md-semibold mb-0.5 text-text-secondary">
|
||||
<div className="mb-0.5 text-text-secondary system-md-semibold">
|
||||
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<div className="body-xs-regular text-text-tertiary">
|
||||
<div className="text-text-tertiary body-xs-regular">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -224,7 +223,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={cn('system-md-semibold mb-0.5 text-text-secondary', 'flex items-center justify-between')}>
|
||||
<div className={cn('mb-0.5 text-text-secondary system-md-semibold', 'flex items-center justify-between')}>
|
||||
<div>{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ComponentProps, FC, ReactNode } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const TriangleArrow: FC<ComponentProps<'svg'>> = props => (
|
||||
@@ -23,7 +22,7 @@ export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
|
||||
return (
|
||||
<div className={cn('relative flex h-full overflow-hidden rounded-t-xl', isActive && activeClassName, !disabled && 'cursor-pointer')}>
|
||||
<div className="relative flex size-14 items-center justify-center overflow-hidden">
|
||||
{isActive && effectImg && <Image src={effectImg} className="absolute left-0 top-0 h-full w-full" alt="" width={56} height={56} />}
|
||||
{isActive && effectImg && <img src={effectImg} className="absolute left-0 top-0 h-full w-full" alt="" width={56} height={56} />}
|
||||
<div className="p-1">
|
||||
<div className="flex size-8 justify-center rounded-lg border border-components-panel-border-subtle bg-background-default-dodge p-1.5 shadow-md">
|
||||
{icon}
|
||||
@@ -34,8 +33,8 @@ export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
|
||||
className={cn('absolute -bottom-1.5 left-4 text-transparent', isActive && 'text-components-panel-bg')}
|
||||
/>
|
||||
<div className="flex-1 space-y-0.5 py-3 pr-4">
|
||||
<div className="system-md-semibold text-text-secondary">{title}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{description}</div>
|
||||
<div className="text-text-secondary system-md-semibold">{title}</div>
|
||||
<div className="text-text-tertiary system-xs-regular">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { FC } from 'react'
|
||||
import type { ParentChildConfig } from '../hooks'
|
||||
import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
|
||||
import { RiSearchEyeLine } from '@remixicon/react'
|
||||
import Image from 'next/image'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
@@ -26,7 +25,7 @@ type TextLabelProps = {
|
||||
}
|
||||
|
||||
const TextLabel: FC<TextLabelProps> = ({ children }) => {
|
||||
return <label className="system-sm-semibold text-text-secondary">{children}</label>
|
||||
return <label className="text-text-secondary system-sm-semibold">{children}</label>
|
||||
}
|
||||
|
||||
type ParentChildOptionsProps = {
|
||||
@@ -118,7 +117,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
</div>
|
||||
<RadioCard
|
||||
className="mt-1"
|
||||
icon={<Image src={Note} alt="" />}
|
||||
icon={<img src={Note.src} alt="" />}
|
||||
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
|
||||
isChosen={parentChildConfig.chunkForContext === 'paragraph'}
|
||||
@@ -140,7 +139,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
/>
|
||||
<RadioCard
|
||||
className="mt-2"
|
||||
icon={<Image src={FileList} alt="" />}
|
||||
icon={<img src={FileList.src} alt="" />}
|
||||
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
|
||||
onChosen={() => onChunkForContextChange('full-doc')}
|
||||
@@ -186,7 +185,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
onClick={() => onRuleToggle(rule.id)}
|
||||
>
|
||||
<Checkbox checked={rule.enabled} />
|
||||
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
|
||||
<label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
|
||||
{getRuleName(rule.id)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -6,14 +6,6 @@ import { ProcessMode } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import RuleDetail from '../rule-detail'
|
||||
|
||||
// Override global next/image auto-mock: tests assert on data-testid="next-image" and src attributes
|
||||
vi.mock('next/image', () => ({
|
||||
default: function MockImage({ src, alt, className }: { src: string, alt: string, className?: string }) {
|
||||
// eslint-disable-next-line next/no-img-element
|
||||
return <img src={src} alt={alt} className={className} data-testid="next-image" />
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock FieldInfo component
|
||||
vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({
|
||||
FieldInfo: ({ label, displayedValue, valueIcon }: { label: string, displayedValue: string, valueIcon?: React.ReactNode }) => (
|
||||
@@ -184,16 +176,16 @@ describe('RuleDetail', () => {
|
||||
})
|
||||
|
||||
it('should show high_quality icon for qualified indexing', () => {
|
||||
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
const { container } = render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg')
|
||||
})
|
||||
|
||||
it('should show economical icon for economical indexing', () => {
|
||||
render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />)
|
||||
const { container } = render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images[0]).toHaveAttribute('src', '/icons/economical.svg')
|
||||
})
|
||||
})
|
||||
@@ -256,38 +248,38 @@ describe('RuleDetail', () => {
|
||||
})
|
||||
|
||||
it('should show vector icon for semantic search', () => {
|
||||
render(
|
||||
const { container } = render(
|
||||
<RuleDetail
|
||||
indexingType={IndexingType.QUALIFIED}
|
||||
retrievalMethod={RETRIEVE_METHOD.semantic}
|
||||
/>,
|
||||
)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images[1]).toHaveAttribute('src', '/icons/vector.svg')
|
||||
})
|
||||
|
||||
it('should show fullText icon for full text search', () => {
|
||||
render(
|
||||
const { container } = render(
|
||||
<RuleDetail
|
||||
indexingType={IndexingType.QUALIFIED}
|
||||
retrievalMethod={RETRIEVE_METHOD.fullText}
|
||||
/>,
|
||||
)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg')
|
||||
})
|
||||
|
||||
it('should show hybrid icon for hybrid search', () => {
|
||||
render(
|
||||
const { container } = render(
|
||||
<RuleDetail
|
||||
indexingType={IndexingType.QUALIFIED}
|
||||
retrievalMethod={RETRIEVE_METHOD.hybrid}
|
||||
/>,
|
||||
)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg')
|
||||
})
|
||||
})
|
||||
@@ -308,9 +300,9 @@ describe('RuleDetail', () => {
|
||||
})
|
||||
|
||||
it('should handle undefined retrievalMethod with defined indexingType', () => {
|
||||
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
const { container } = render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
// When retrievalMethod is undefined, vector icon is used as default
|
||||
expect(images[1]).toHaveAttribute('src', '/icons/vector.svg')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ProcessRuleResponse } from '@/models/datasets'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -50,7 +49,7 @@ const RuleDetail = ({
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
|
||||
valueIcon={(
|
||||
<Image
|
||||
<img
|
||||
className="size-4"
|
||||
src={
|
||||
indexingType === IndexingType.ECONOMICAL
|
||||
@@ -65,7 +64,7 @@ const RuleDetail = ({
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
displayedValue={t(`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
|
||||
valueIcon={(
|
||||
<Image
|
||||
<img
|
||||
className="size-4"
|
||||
src={
|
||||
retrievalMethod === RETRIEVE_METHOD.fullText
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ProcessRuleResponse } from '@/models/datasets'
|
||||
import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -101,7 +100,7 @@ const RuleDetail: FC<RuleDetailProps> = React.memo(({
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
|
||||
valueIcon={(
|
||||
<Image
|
||||
<img
|
||||
className="size-4"
|
||||
src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality}
|
||||
alt=""
|
||||
@@ -112,7 +111,7 @@ const RuleDetail: FC<RuleDetailProps> = React.memo(({
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
|
||||
valueIcon={(
|
||||
<Image
|
||||
<img
|
||||
className="size-4"
|
||||
src={getRetrievalIcon(retrievalMethod)}
|
||||
alt=""
|
||||
|
||||
@@ -905,8 +905,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// The TopKItem should render an input
|
||||
const inputs = screen.getAllByRole('spinbutton')
|
||||
// The TopKItem renders the visible number-field input as a textbox.
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
const topKInput = inputs[0]
|
||||
fireEvent.change(topKInput, { target: { value: '8' } })
|
||||
|
||||
@@ -924,8 +924,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// The ScoreThresholdItem should render an input
|
||||
const inputs = screen.getAllByRole('spinbutton')
|
||||
// The ScoreThresholdItem renders the visible number-field input as a textbox.
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
const scoreThresholdInput = inputs[1]
|
||||
fireEvent.change(scoreThresholdInput, { target: { value: '0.8' } })
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
RiEqualizer2Line,
|
||||
RiPlayCircleLine,
|
||||
} from '@remixicon/react'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -178,7 +177,7 @@ const QueryInput = ({
|
||||
}, [text, externalRetrievalSettings, externalKnowledgeBaseHitTestingMutation, onUpdateList, setExternalHitResult])
|
||||
|
||||
const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method
|
||||
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" />
|
||||
const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" />
|
||||
const TextAreaComp = useMemo(() => {
|
||||
return (
|
||||
<Textarea
|
||||
@@ -206,7 +205,7 @@ const QueryInput = ({
|
||||
<div className={cn('relative flex h-80 shrink-0 flex-col overflow-hidden rounded-xl bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs')}>
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-[10px] bg-background-section-burn">
|
||||
<div className="relative flex shrink-0 items-center justify-between p-1.5 pb-1 pl-3">
|
||||
<span className="system-sm-semibold-uppercase text-text-secondary">
|
||||
<span className="text-text-secondary system-sm-semibold-uppercase">
|
||||
{t('input.title', { ns: 'datasetHitTesting' })}
|
||||
</span>
|
||||
{isExternal
|
||||
@@ -218,7 +217,7 @@ const QueryInput = ({
|
||||
>
|
||||
<RiEqualizer2Line className="h-3.5 w-3.5 text-components-button-secondary-text" />
|
||||
<div className="flex items-center justify-center gap-1 px-[3px]">
|
||||
<span className="system-xs-medium text-components-button-secondary-text">{t('settingTitle', { ns: 'datasetHitTesting' })}</span>
|
||||
<span className="text-components-button-secondary-text system-xs-medium">{t('settingTitle', { ns: 'datasetHitTesting' })}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -43,8 +43,9 @@ describe('InputCombined', () => {
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
const input = screen.getByDisplayValue('42')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue('42')
|
||||
})
|
||||
|
||||
it('should render date picker for time type', () => {
|
||||
@@ -96,19 +97,31 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '123' } })
|
||||
|
||||
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(
|
||||
<InputCombined type={DataType.number} value={999} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('999')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('999')
|
||||
})
|
||||
|
||||
it('should apply readOnly prop to number input', () => {
|
||||
@@ -117,7 +130,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
})
|
||||
})
|
||||
@@ -186,7 +199,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={null} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -208,7 +221,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('rounded-l-md')
|
||||
})
|
||||
})
|
||||
@@ -230,7 +243,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('0')
|
||||
})
|
||||
|
||||
it('should handle negative number', () => {
|
||||
@@ -239,7 +252,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('-100')
|
||||
})
|
||||
|
||||
it('should handle special characters in string', () => {
|
||||
@@ -263,7 +276,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,15 +129,15 @@ describe('IndexMethod', () => {
|
||||
|
||||
it('should pass keywordNumber to KeywordNumber component', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={25} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('25')
|
||||
})
|
||||
|
||||
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
|
||||
const handleKeywordChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '30' } })
|
||||
|
||||
expect(handleKeywordChange).toHaveBeenCalled()
|
||||
@@ -190,16 +190,16 @@ 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('spinbutton')
|
||||
expect(input).toHaveValue(0)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('0')
|
||||
})
|
||||
|
||||
it('should handle max keywordNumber', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={50} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(50)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('50')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -38,15 +37,15 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
it('should render input number field', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display correct keywordNumber value in input', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('25')
|
||||
})
|
||||
|
||||
it('should display different keywordNumber values', () => {
|
||||
@@ -54,8 +53,8 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
values.forEach((value) => {
|
||||
const { unmount } = render(<KeyWordNumber {...defaultProps} keywordNumber={value} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(value)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue(String(value))
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -82,21 +81,28 @@ describe('KeyWordNumber', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '30' } })
|
||||
|
||||
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('spinbutton')
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -117,32 +123,32 @@ describe('KeyWordNumber', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle minimum value (0)', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={0} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(0)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('0')
|
||||
})
|
||||
|
||||
it('should handle maximum value (50)', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={50} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(50)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('50')
|
||||
})
|
||||
|
||||
it('should handle value updates correctly', () => {
|
||||
const { rerender } = render(<KeyWordNumber {...defaultProps} keywordNumber={10} />)
|
||||
|
||||
let input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(10)
|
||||
let input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('10')
|
||||
|
||||
rerender(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
|
||||
input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('25')
|
||||
})
|
||||
|
||||
it('should handle rapid value changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
// Simulate rapid changes via input with different values
|
||||
fireEvent.change(input, { target: { value: '15' } })
|
||||
@@ -162,7 +168,7 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
it('should have accessible input', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
@@ -11,21 +10,6 @@ vi.mock('../use-get-requirements', () => ({
|
||||
default: (...args: unknown[]) => mockUseGetRequirements(...args),
|
||||
}))
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({
|
||||
src,
|
||||
alt,
|
||||
unoptimized: _unoptimized,
|
||||
...rest
|
||||
}: {
|
||||
src: string
|
||||
alt: string
|
||||
unoptimized?: boolean
|
||||
} & ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
React.createElement('img', { src, alt, ...rest })
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({
|
||||
id: 'test-app-id',
|
||||
name: 'Test App Name',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AppTypeIcon } from '@/app/components/app/type-selector'
|
||||
@@ -38,14 +37,13 @@ const RequirementIcon: FC<RequirementIconProps> = ({ iconUrl }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
<img
|
||||
className="size-5 rounded-md object-cover shadow-xs"
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={requirementIconSize}
|
||||
height={requirementIconSize}
|
||||
unoptimized
|
||||
onError={() => setFailedSource(iconUrl)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { act, render, screen } from '@testing-library/react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { vi } from 'vitest'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import HeaderWrapper from './header-wrapper'
|
||||
import HeaderWrapper from '../header-wrapper'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import Header from './index'
|
||||
import Header from '../index'
|
||||
|
||||
function createMockComponent(testId: string) {
|
||||
return () => <div data-testid={testId} />
|
||||
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { NOTICE_I18N } from '@/i18n-config/language'
|
||||
import MaintenanceNotice from './maintenance-notice'
|
||||
import MaintenanceNotice from '../maintenance-notice'
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
|
||||
X: ({ onClick }: { onClick?: () => void }) => <button type="button" aria-label="close notice" onClick={onClick} />,
|
||||
@@ -2,7 +2,7 @@ import type { LangGeniusVersionResponse } from '@/models/common'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import AccountAbout from './index'
|
||||
import AccountAbout from '../index'
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
@@ -8,8 +8,8 @@ import { useModalContext } from '@/context/modal-context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import { getDocDownloadUrl } from '@/service/common'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import Toast from '../../base/toast'
|
||||
import Compliance from './compliance'
|
||||
import Toast from '../../../base/toast'
|
||||
import Compliance from '../compliance'
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
@@ -10,13 +10,13 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import AppSelector from './index'
|
||||
import AppSelector from '../index'
|
||||
|
||||
vi.mock('../account-setting', () => ({
|
||||
vi.mock('../../account-setting', () => ({
|
||||
default: () => <div data-testid="account-setting">AccountSetting</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../account-about', () => ({
|
||||
vi.mock('../../account-about', () => ({
|
||||
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||
<div data-testid="account-about">
|
||||
Version
|
||||
@@ -5,7 +5,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/co
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import Support from './support'
|
||||
import Support from '../support'
|
||||
|
||||
const { mockZendeskKey } = vi.hoisted(() => ({
|
||||
mockZendeskKey: { value: 'test-key' },
|
||||
@@ -5,7 +5,7 @@ import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import { useWorkspacesContext } from '@/context/workspace-context'
|
||||
import { switchWorkspace } from '@/service/common'
|
||||
import WorkplaceSelector from './index'
|
||||
import WorkplaceSelector from '../index'
|
||||
|
||||
vi.mock('@/context/workspace-context', () => ({
|
||||
useWorkspacesContext: vi.fn(),
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AccountIntegrate } from '@/models/common'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useAccountIntegrates } from '@/service/use-common'
|
||||
import IntegrationsPage from './index'
|
||||
import IntegrationsPage from '../index'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useAccountIntegrates: vi.fn(),
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ACCOUNT_SETTING_TAB,
|
||||
DEFAULT_ACCOUNT_SETTING_TAB,
|
||||
isValidAccountSettingTab,
|
||||
} from './constants'
|
||||
} from '../constants'
|
||||
|
||||
describe('AccountSetting Constants', () => {
|
||||
it('should have correct ACCOUNT_SETTING_MODAL_ACTION', () => {
|
||||
@@ -0,0 +1,346 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect } from 'react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { ACCOUNT_SETTING_TAB } from '../constants'
|
||||
import AccountSetting from '../index'
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/app-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/app-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useAppContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
})),
|
||||
usePathname: vi.fn(() => '/'),
|
||||
useParams: vi.fn(() => ({})),
|
||||
useSearchParams: vi.fn(() => ({ get: vi.fn() })),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
pc: 'pc',
|
||||
},
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/billing-page', () => ({
|
||||
default: () => <div data-testid="billing-page">Billing Page</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/custom/custom-page', () => ({
|
||||
default: () => <div data-testid="custom-page">Custom Page</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/api-based-extension-page', () => ({
|
||||
default: () => <div data-testid="api-based-extension-page">API Based Extension Page</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({
|
||||
default: () => <div data-testid="data-source-page">Data Source Page</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/language-page', () => ({
|
||||
default: () => <div data-testid="language-page">Language Page</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/members-page', () => ({
|
||||
default: () => <div data-testid="members-page">Members Page</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
|
||||
default: ({ searchText }: { searchText: string }) => (
|
||||
<div data-testid="provider-page">
|
||||
{`provider-search:${searchText}`}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/menu-dialog', () => ({
|
||||
default: function MockMenuDialog({
|
||||
children,
|
||||
onClose,
|
||||
show,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
show?: boolean
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape')
|
||||
onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
if (!show)
|
||||
return null
|
||||
|
||||
return <div role="dialog">{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const baseAppContextValue: AppContextValue = {
|
||||
userProfile: {
|
||||
id: '1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
is_password_set: false,
|
||||
},
|
||||
mutateUserProfile: vi.fn(),
|
||||
currentWorkspace: {
|
||||
id: '1',
|
||||
name: 'Workspace',
|
||||
plan: '',
|
||||
status: '',
|
||||
created_at: 0,
|
||||
role: 'owner',
|
||||
providers: [],
|
||||
trial_credits: 0,
|
||||
trial_credits_used: 0,
|
||||
next_credit_reset_date: 0,
|
||||
},
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
langGeniusVersionInfo: {
|
||||
current_env: 'testing',
|
||||
current_version: '0.1.0',
|
||||
latest_version: '0.1.0',
|
||||
release_date: '',
|
||||
release_notes: '',
|
||||
version: '0.1.0',
|
||||
can_auto_update: false,
|
||||
},
|
||||
useSelector: vi.fn(),
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isValidatingCurrentWorkspace: false,
|
||||
}
|
||||
|
||||
describe('AccountSetting', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnTabChange = vi.fn()
|
||||
|
||||
const renderAccountSetting = (props: Partial<ComponentProps<typeof AccountSetting>> = {}) => {
|
||||
const queryClient = new QueryClient()
|
||||
const mergedProps: ComponentProps<typeof AccountSetting> = {
|
||||
onCancel: mockOnCancel,
|
||||
...props,
|
||||
}
|
||||
|
||||
const view = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AccountSetting {...mergedProps} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
return {
|
||||
...view,
|
||||
rerenderAccountSetting(nextProps: Partial<ComponentProps<typeof AccountSetting>>) {
|
||||
view.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AccountSetting {...mergedProps} {...nextProps} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
enableBilling: true,
|
||||
enableReplaceWebAppLogo: true,
|
||||
})
|
||||
vi.mocked(useAppContext).mockReturnValue(baseAppContextValue)
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the sidebar with correct menu items', () => {
|
||||
renderAccountSetting()
|
||||
|
||||
expect(screen.getByText('common.userProfile.settings')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.provider')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.members')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.billing')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.dataSource')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.apiBasedExtension')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('custom.custom')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.language')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('members-page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should respect the activeTab prop', () => {
|
||||
renderAccountSetting({ activeTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
|
||||
|
||||
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should sync the rendered page when activeTab changes', async () => {
|
||||
const { rerenderAccountSetting } = renderAccountSetting({
|
||||
activeTab: ACCOUNT_SETTING_TAB.DATA_SOURCE,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
|
||||
|
||||
rerenderAccountSetting({
|
||||
activeTab: ACCOUNT_SETTING_TAB.CUSTOM,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('custom-page')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide sidebar labels on mobile', () => {
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
|
||||
|
||||
renderAccountSetting()
|
||||
|
||||
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter items for dataset operator', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
...baseAppContextValue,
|
||||
isCurrentWorkspaceDatasetOperator: true,
|
||||
})
|
||||
|
||||
renderAccountSetting()
|
||||
|
||||
expect(screen.queryByTitle('common.settings.provider')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('common.settings.members')).not.toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.language')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing and custom tabs when disabled', () => {
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
enableBilling: false,
|
||||
enableReplaceWebAppLogo: false,
|
||||
})
|
||||
|
||||
renderAccountSetting()
|
||||
|
||||
expect(screen.queryByTitle('common.settings.billing')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('custom.custom')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should change active tab when clicking on a menu item', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderAccountSetting({ onTabChange: mockOnTabChange })
|
||||
|
||||
await user.click(screen.getByTitle('common.settings.provider'))
|
||||
|
||||
expect(mockOnTabChange).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.PROVIDER)
|
||||
expect(screen.getByTestId('provider-page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['common.settings.billing', 'billing-page'],
|
||||
['common.settings.dataSource', 'data-source-page'],
|
||||
['common.settings.apiBasedExtension', 'api-based-extension-page'],
|
||||
['custom.custom', 'custom-page'],
|
||||
['common.settings.language', 'language-page'],
|
||||
['common.settings.members', 'members-page'],
|
||||
])('should render the "%s" page when its sidebar item is selected', async (menuTitle, pageTestId) => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderAccountSetting()
|
||||
|
||||
await user.click(screen.getByTitle(menuTitle))
|
||||
|
||||
expect(screen.getByTestId(pageTestId)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should call onCancel when clicking the close button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderAccountSetting()
|
||||
|
||||
const closeControls = screen.getByText('ESC').parentElement
|
||||
|
||||
expect(closeControls).not.toBeNull()
|
||||
if (!closeControls)
|
||||
throw new Error('Close controls are missing')
|
||||
|
||||
await user.click(within(closeControls).getByRole('button'))
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when pressing Escape key', () => {
|
||||
renderAccountSetting()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update search value in the provider tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderAccountSetting()
|
||||
|
||||
await user.click(screen.getByTitle('common.settings.provider'))
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'test-search')
|
||||
|
||||
expect(input).toHaveValue('test-search')
|
||||
expect(screen.getByText('provider-search:test-search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle scroll event in panel', () => {
|
||||
renderAccountSetting()
|
||||
|
||||
const scrollContainer = screen.getByRole('dialog').querySelector('.overflow-y-auto')
|
||||
|
||||
expect(scrollContainer).toBeInTheDocument()
|
||||
if (scrollContainer) {
|
||||
fireEvent.scroll(scrollContainer, { target: { scrollTop: 100 } })
|
||||
expect(scrollContainer).toHaveClass('overflow-y-auto')
|
||||
|
||||
fireEvent.scroll(scrollContainer, { target: { scrollTop: 0 } })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import MenuDialog from './menu-dialog'
|
||||
import MenuDialog from '../menu-dialog'
|
||||
|
||||
describe('MenuDialog', () => {
|
||||
beforeEach(() => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Empty from './empty'
|
||||
import Empty from '../empty'
|
||||
|
||||
describe('Empty State', () => {
|
||||
describe('Rendering', () => {
|
||||
@@ -4,7 +4,7 @@ import type { ApiBasedExtension } from '@/models/common'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import ApiBasedExtensionPage from './index'
|
||||
import ApiBasedExtensionPage from '../index'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useApiBasedExtensions: vi.fn(),
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user