Compare commits

...

37 Commits

Author SHA1 Message Date
yyh
c18a8ab528 fix tests 2026-03-16 23:01:59 +08:00
yyh
0132837443 test(number-field): cover simplified workflow consumers 2026-03-16 22:49:55 +08:00
yyh
bfeac060a4 fix 2026-03-16 22:32:47 +08:00
yyh
5d955cd91d fix 2026-03-16 22:29:25 +08:00
yyh
045e6fe005 fix 2026-03-16 22:19:34 +08:00
autofix-ci[bot]
7f5c65d2eb [autofix.ci] apply automated fixes 2026-03-16 14:11:08 +00:00
yyh
fc070c6953 fix 2026-03-16 22:05:57 +08:00
yyh
6f59a7ad06 fix 2026-03-16 22:05:06 +08:00
yyh
3ee1081887 Merge branch 'refactor/base-ui-number-field' of https://github.com/langgenius/dify into refactor/base-ui-number-field 2026-03-16 21:57:24 +08:00
yyh
337c429447 fix 2026-03-16 21:57:12 +08:00
yyh
d4ee97fc19 Merge branch 'main' into refactor/base-ui-number-field 2026-03-16 21:56:56 +08:00
yyh
d54293106e fix 2026-03-16 21:53:57 +08:00
yyh
1c62b4607f fix 2026-03-16 21:45:04 +08:00
yyh
04348e3513 fix 2026-03-16 21:42:39 +08:00
yyh
0c0364d2f5 Merge branch 'refactor/base-ui-number-field' of https://github.com/langgenius/dify into refactor/base-ui-number-field 2026-03-16 21:36:18 +08:00
yyh
fb091b01f4 i18n 2026-03-16 21:35:44 +08:00
autofix-ci[bot]
796b2f5366 [autofix.ci] apply automated fixes 2026-03-16 13:35:29 +00:00
yyh
f0c7bd0f20 fix 2026-03-16 21:31:45 +08:00
yyh
cd6890bd1b fix 2026-03-16 21:29:06 +08:00
-LAN-
e445f69604 refactor(api): simplify response session eligibility (#33538) 2026-03-16 21:22:37 +08:00
yyh
bcd563178d fix(number-field): preserve clear behavior in controlled inputs 2026-03-16 21:21:53 +08:00
autofix-ci[bot]
6a31a98a37 [autofix.ci] apply automated fixes 2026-03-16 13:20:18 +00:00
yyh
7dba5e2d29 fix tests 2026-03-16 21:17:11 +08:00
yyh
755fd2f280 fix style 2026-03-16 21:16:04 +08:00
yyh
7c3e1c3c76 test(number-field): strengthen wrapper coverage 2026-03-16 21:10:20 +08:00
autofix-ci[bot]
e716bf1b70 [autofix.ci] apply automated fixes 2026-03-16 13:05:16 +00:00
yyh
bb6522210f refactor: migrate number inputs to Base UI number field 2026-03-16 21:01:59 +08:00
dependabot[bot]
c7f86dba09 chore(deps-dev): bump the dev group across 1 directory with 19 updates (#33525)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-16 20:31:58 +09:00
Coding On Star
6da802eb2a refactor(custom): reorganize web app brand module and raise coverage threshold (#33531)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-16 18:17:21 +08:00
yyh
c3ee83645f fix(web): migrate InputNumber to Base UI NumberField (#33520) 2026-03-16 17:59:30 +08:00
QuantumGhost
4a090876f1 chore(api): rename configuration EVENT_BUS_REDIS_CLUSTERS (#33528)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-16 17:50:15 +08:00
Stephen Zhou
598189d307 chore: fix dep alerts (#33527) 2026-03-16 17:08:36 +08:00
QuantumGhost
1f3fa95e2c chore: update plugin daemon version to 0.5.4-local in Docker compose files (#33526) 2026-03-16 17:06:32 +08:00
Coding On Star
0d72d99263 test: limit web diff coverage to current push range (#33523)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-03-16 16:59:25 +08:00
Blackoutta
57d476d4e2 feat: apply markdown rendering to HITL email, sanitize email subject and body (#32305)
This PR:

1. Fixes the bug that email body of `HumanInput` node are sent as-is, without markdown rendering or sanitization
2. Applies HTML sanitization to email subject and body
3. Removes `\r` and `\n` from email subject to prevent SMTP header injection

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-16 16:52:46 +08:00
Stephen Zhou
4822d550b6 chore: remove next img (#33517) 2026-03-16 16:48:22 +08:00
wangxiaolei
041d7ffe3d chore: compatiable resource_metadata return without scheme (#33506)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-16 15:44:48 +08:00
238 changed files with 4237 additions and 3296 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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}"

View File

@@ -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"]

View File

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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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(

View File

@@ -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"

View File

@@ -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"

View File

@@ -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)

View File

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

View File

@@ -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
View File

@@ -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]]

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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',

View File

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

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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' },

View File

@@ -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>

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
},
}

View File

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

View File

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

View File

@@ -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}

View File

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

View File

@@ -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>,
}))

View File

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

View File

@@ -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')
})
})
})

View File

@@ -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', () => {

View File

@@ -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

View 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}`)
})
})
})

View 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>
),
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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>
)

View File

@@ -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')
})
})

View File

@@ -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

View File

@@ -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>

View File

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

View File

@@ -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>
)

View File

@@ -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,
}

View File

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

View File

@@ -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>,

View File

@@ -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>

View File

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

View File

@@ -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}

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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')
})

View File

@@ -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

View File

@@ -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=""

View File

@@ -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' } })

View File

@@ -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>
)

View File

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

View File

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

View File

@@ -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')
})
})
})

View File

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

View File

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

View File

@@ -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',

View File

@@ -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)}
/>
)

View File

@@ -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(),

View File

@@ -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} />

View File

@@ -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} />,

View File

@@ -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(),

View File

@@ -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')>()

View File

@@ -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

View File

@@ -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' },

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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', () => {

View File

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

View File

@@ -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(() => {

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import Empty from './empty'
import Empty from '../empty'
describe('Empty State', () => {
describe('Rendering', () => {

View File

@@ -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