mirror of
https://github.com/langgenius/dify.git
synced 2026-03-16 12:47:03 +00:00
Compare commits
7 Commits
main
...
feat/evalu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd0dee739d | ||
|
|
4d19914fcb | ||
|
|
887c7710e9 | ||
|
|
7a722773c7 | ||
|
|
a763aff58b | ||
|
|
c1011f4e5c | ||
|
|
f7afa103a5 |
5
.github/workflows/main-ci.yml
vendored
5
.github/workflows/main-ci.yml
vendored
@@ -63,9 +63,8 @@ jobs:
|
||||
if: needs.check-changes.outputs.web-changed == 'true'
|
||||
uses: ./.github/workflows/web-tests.yml
|
||||
with:
|
||||
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 }}
|
||||
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 }}
|
||||
|
||||
style-check:
|
||||
name: Style Check
|
||||
|
||||
4
.github/workflows/web-tests.yml
vendored
4
.github/workflows/web-tests.yml
vendored
@@ -6,9 +6,6 @@ on:
|
||||
base_sha:
|
||||
required: false
|
||||
type: string
|
||||
diff_range_mode:
|
||||
required: false
|
||||
type: string
|
||||
head_sha:
|
||||
required: false
|
||||
type: string
|
||||
@@ -92,7 +89,6 @@ jobs:
|
||||
- name: Check app/components diff coverage
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
run: node ./scripts/check-components-diff-coverage.mjs
|
||||
|
||||
|
||||
@@ -737,25 +737,24 @@ SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000
|
||||
|
||||
|
||||
# Redis URL used for event bus between API and
|
||||
# Redis URL used for PubSub between API and
|
||||
# celery worker
|
||||
# defaults to url constructed from `REDIS_*`
|
||||
# configurations
|
||||
EVENT_BUS_REDIS_URL=
|
||||
# Event transport type. Options are:
|
||||
PUBSUB_REDIS_URL=
|
||||
# Pub/sub channel type for streaming events.
|
||||
# valid options are:
|
||||
#
|
||||
# - 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)
|
||||
# - pubsub: for normal Pub/Sub
|
||||
# - sharded: for sharded Pub/Sub
|
||||
#
|
||||
# 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 use sharded Pub/Sub AND redis cluster
|
||||
# for large deployments.
|
||||
PUBSUB_REDIS_CHANNEL_TYPE=pubsub
|
||||
# Whether to use Redis cluster mode while running
|
||||
# PubSub.
|
||||
# It's highly recommended to enable this for large deployments.
|
||||
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
||||
PUBSUB_REDIS_USE_CLUSTERS=false
|
||||
|
||||
# Whether to Enable human input timeout check task
|
||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||
|
||||
@@ -41,10 +41,10 @@ class RedisPubSubConfig(BaseSettings, RedisConfigDefaultsMixin):
|
||||
)
|
||||
|
||||
PUBSUB_REDIS_USE_CLUSTERS: bool = Field(
|
||||
validation_alias=AliasChoices("EVENT_BUS_REDIS_USE_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"),
|
||||
validation_alias=AliasChoices("EVENT_BUS_REDIS_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_USE_CLUSTERS."
|
||||
"Also accepts ENV: EVENT_BUS_REDIS_CLUSTERS."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
@@ -55,31 +55,15 @@ 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:
|
||||
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)
|
||||
urls.append(www_auth_resource_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}"
|
||||
|
||||
@@ -8,8 +8,6 @@ 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
|
||||
@@ -60,39 +58,6 @@ 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
|
||||
|
||||
@@ -133,43 +98,6 @@ class EmailDeliveryConfig(BaseModel):
|
||||
return templated_body
|
||||
return variable_pool.convert_template(templated_body).text
|
||||
|
||||
@classmethod
|
||||
def render_markdown_body(cls, body: str) -> str:
|
||||
"""Render markdown to safe HTML for email delivery."""
|
||||
sanitized_markdown = bleach.clean(
|
||||
body,
|
||||
tags=[],
|
||||
attributes={},
|
||||
strip=True,
|
||||
strip_comments=True,
|
||||
)
|
||||
rendered_html = markdown.markdown(
|
||||
sanitized_markdown,
|
||||
extensions=["nl2br", "tables"],
|
||||
extension_configs={"tables": {"use_align_attribute": True}},
|
||||
)
|
||||
return bleach.clean(
|
||||
rendered_html,
|
||||
tags=cls._ALLOWED_HTML_TAGS,
|
||||
attributes=cls._ALLOWED_HTML_ATTRIBUTES,
|
||||
protocols=cls._ALLOWED_PROTOCOLS,
|
||||
strip=True,
|
||||
strip_comments=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def sanitize_subject(cls, subject: str) -> str:
|
||||
"""Sanitize email subject to plain text and prevent CRLF injection."""
|
||||
sanitized_subject = bleach.clean(
|
||||
subject,
|
||||
tags=[],
|
||||
attributes={},
|
||||
strip=True,
|
||||
strip_comments=True,
|
||||
)
|
||||
sanitized_subject = cls._SUBJECT_NEWLINE_PATTERN.sub(" ", sanitized_subject)
|
||||
return " ".join(sanitized_subject.split())
|
||||
|
||||
|
||||
class _DeliveryMethodBase(BaseModel):
|
||||
"""Base delivery method configuration."""
|
||||
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"numpy~=1.26.4",
|
||||
"openpyxl~=3.1.5",
|
||||
"opik~=1.10.37",
|
||||
"litellm==1.82.2", # Pinned to avoid madoka dependency issue
|
||||
"litellm==1.82.2", # Pinned to avoid madoka dependency issue
|
||||
"opentelemetry-api==1.28.0",
|
||||
"opentelemetry-distro==0.49b0",
|
||||
"opentelemetry-exporter-otlp==1.28.0",
|
||||
@@ -91,7 +91,6 @@ 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.
|
||||
@@ -119,7 +118,7 @@ dev = [
|
||||
"pytest~=9.0.2",
|
||||
"pytest-benchmark~=5.2.3",
|
||||
"pytest-cov~=7.0.0",
|
||||
"pytest-env~=1.6.0",
|
||||
"pytest-env~=1.1.3",
|
||||
"pytest-mock~=3.15.1",
|
||||
"testcontainers~=4.14.1",
|
||||
"types-aiofiles~=25.1.0",
|
||||
@@ -252,7 +251,10 @@ ignore_errors = true
|
||||
|
||||
[tool.pyrefly]
|
||||
project-includes = ["."]
|
||||
project-excludes = [".venv", "migrations/"]
|
||||
project-excludes = [
|
||||
".venv",
|
||||
"migrations/",
|
||||
]
|
||||
python-platform = "linux"
|
||||
python-version = "3.11.0"
|
||||
infer-with-first-use = false
|
||||
|
||||
@@ -155,15 +155,13 @@ class EmailDeliveryTestHandler:
|
||||
context=context,
|
||||
recipient_email=recipient_email,
|
||||
)
|
||||
subject_template = render_email_template(method.config.subject, substitutions)
|
||||
subject = EmailDeliveryConfig.sanitize_subject(subject_template)
|
||||
subject = render_email_template(method.config.subject, substitutions)
|
||||
templated_body = EmailDeliveryConfig.render_body_template(
|
||||
body=method.config.body,
|
||||
url=substitutions.get("form_link"),
|
||||
variable_pool=context.variable_pool,
|
||||
)
|
||||
body = render_email_template(templated_body, substitutions)
|
||||
body = EmailDeliveryConfig.render_markdown_body(body)
|
||||
|
||||
mail.send(
|
||||
to=recipient_email,
|
||||
|
||||
@@ -111,7 +111,7 @@ def _render_body(
|
||||
url=form_link,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
return EmailDeliveryConfig.render_markdown_body(body)
|
||||
return body
|
||||
|
||||
|
||||
def _load_variable_pool(workflow_run_id: str | None) -> VariablePool | None:
|
||||
@@ -173,11 +173,10 @@ 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=subject,
|
||||
subject=job.subject,
|
||||
html=body,
|
||||
)
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ class DifyTestContainers:
|
||||
# Start Dify Plugin Daemon container for plugin management
|
||||
# Dify Plugin Daemon provides plugin lifecycle management and execution
|
||||
logger.info("Initializing Dify Plugin Daemon container...")
|
||||
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.4-local").with_network(
|
||||
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.3.0-local").with_network(
|
||||
self.network
|
||||
)
|
||||
self.dify_plugin_daemon.with_exposed_ports(5002)
|
||||
|
||||
@@ -22,7 +22,7 @@ from controllers.console.extension import (
|
||||
)
|
||||
|
||||
if _NEEDS_METHOD_VIEW_CLEANUP:
|
||||
del builtins.MethodView
|
||||
delattr(builtins, "MethodView")
|
||||
from models.account import AccountStatus
|
||||
from models.api_based_extension import APIBasedExtension
|
||||
|
||||
|
||||
@@ -801,27 +801,6 @@ 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(
|
||||
|
||||
@@ -14,64 +14,3 @@ def test_render_body_template_replaces_variable_values():
|
||||
result = config.render_body_template(body=config.body, url="https://example.com", variable_pool=variable_pool)
|
||||
|
||||
assert result == "Hello World https://example.com"
|
||||
|
||||
|
||||
def test_render_markdown_body_renders_markdown_to_html():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body("**Bold** and [link](https://example.com)")
|
||||
|
||||
assert "<strong>Bold</strong>" in rendered
|
||||
assert '<a href="https://example.com">link</a>' in rendered
|
||||
|
||||
|
||||
def test_render_markdown_body_sanitizes_unsafe_html():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body(
|
||||
'<script>alert("xss")</script><a href="javascript:alert(1)" onclick="alert(2)">Click</a>'
|
||||
)
|
||||
|
||||
assert "<script" not in rendered
|
||||
assert "<a" not in rendered
|
||||
assert "onclick" not in rendered
|
||||
assert "javascript:" not in rendered
|
||||
assert "Click" in rendered
|
||||
|
||||
|
||||
def test_render_markdown_body_sanitizes_markdown_link_with_javascript_href():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body("[bad](javascript:alert(1)) and [ok](https://example.com)")
|
||||
|
||||
assert "javascript:" not in rendered
|
||||
assert "<a>bad</a>" in rendered
|
||||
assert '<a href="https://example.com">ok</a>' in rendered
|
||||
|
||||
|
||||
def test_render_markdown_body_does_not_allow_raw_html_tags():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body("<b>raw html</b> and **markdown**")
|
||||
|
||||
assert "<b>" not in rendered
|
||||
assert "raw html" in rendered
|
||||
assert "<strong>markdown</strong>" in rendered
|
||||
|
||||
|
||||
def test_render_markdown_body_supports_table_syntax():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body("| h1 | h2 |\n| --- | ---: |\n| v1 | v2 |")
|
||||
|
||||
assert "<table>" in rendered
|
||||
assert "<thead>" in rendered
|
||||
assert "<tbody>" in rendered
|
||||
assert 'align="right"' in rendered
|
||||
assert "style=" not in rendered
|
||||
|
||||
|
||||
def test_sanitize_subject_removes_crlf():
|
||||
sanitized = EmailDeliveryConfig.sanitize_subject("Notice\r\nBCC:attacker@example.com")
|
||||
|
||||
assert "\r" not in sanitized
|
||||
assert "\n" not in sanitized
|
||||
assert sanitized == "Notice BCC:attacker@example.com"
|
||||
|
||||
|
||||
def test_sanitize_subject_removes_html_tags():
|
||||
sanitized = EmailDeliveryConfig.sanitize_subject("<b>Alert</b><img src=x onerror=1>")
|
||||
|
||||
assert "<" not in sanitized
|
||||
assert ">" not in sanitized
|
||||
assert sanitized == "Alert"
|
||||
|
||||
@@ -140,7 +140,7 @@ class TestLoginRequired:
|
||||
|
||||
# Remove ensure_sync to simulate Flask 1.x
|
||||
if hasattr(setup_app, "ensure_sync"):
|
||||
del setup_app.ensure_sync
|
||||
delattr(setup_app, "ensure_sync")
|
||||
|
||||
with setup_app.test_request_context():
|
||||
mock_user = MockUser("test_user", is_authenticated=True)
|
||||
|
||||
@@ -207,45 +207,6 @@ class TestEmailDeliveryTestHandler:
|
||||
assert kwargs["to"] == "test@example.com"
|
||||
assert "RENDERED_Subj" in kwargs["subject"]
|
||||
|
||||
def test_send_test_sanitizes_subject(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
service_module.FeatureService,
|
||||
"get_features",
|
||||
lambda _id: SimpleNamespace(human_input_email_delivery_enabled=True),
|
||||
)
|
||||
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
|
||||
mock_mail_send = MagicMock()
|
||||
monkeypatch.setattr(service_module.mail, "send", mock_mail_send)
|
||||
monkeypatch.setattr(
|
||||
service_module,
|
||||
"render_email_template",
|
||||
lambda template, substitutions: template.replace("{{ recipient_email }}", substitutions["recipient_email"]),
|
||||
)
|
||||
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
handler._resolve_recipients = MagicMock(return_value=["test@example.com"])
|
||||
|
||||
context = DeliveryTestContext(
|
||||
tenant_id="t1",
|
||||
app_id="a1",
|
||||
node_id="n1",
|
||||
node_title="title",
|
||||
rendered_content="content",
|
||||
recipients=[DeliveryTestEmailRecipient(email="test@example.com", form_token="token123")],
|
||||
)
|
||||
method = EmailDeliveryMethod(
|
||||
config=EmailDeliveryConfig(
|
||||
recipients=EmailRecipients(whole_workspace=False, items=[]),
|
||||
subject="<b>Notice</b>\r\nBCC:{{ recipient_email }}",
|
||||
body="Body",
|
||||
)
|
||||
)
|
||||
|
||||
handler.send_test(context=context, method=method)
|
||||
|
||||
_, kwargs = mock_mail_send.call_args
|
||||
assert kwargs["subject"] == "Notice BCC:test@example.com"
|
||||
|
||||
def test_resolve_recipients(self):
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
|
||||
|
||||
@@ -120,37 +120,4 @@ def test_dispatch_human_input_email_task_replaces_body_variables(monkeypatch: py
|
||||
session_factory=lambda: _DummySession(form),
|
||||
)
|
||||
|
||||
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"
|
||||
assert mail.sent[0]["html"] == "Body OK"
|
||||
|
||||
171
api/uv.lock
generated
171
api/uv.lock
generated
@@ -658,18 +658,6 @@ 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"
|
||||
@@ -720,16 +708,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3-stubs"
|
||||
version = "1.42.68"
|
||||
version = "1.41.3"
|
||||
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/4c/8c/dd4b0c95ff008bed5a35ab411452ece121b355539d2a0b6dcd62a0c47be5/boto3_stubs-1.42.68.tar.gz", hash = "sha256:96ad1020735619483fb9b4da7a5e694b460bf2e18f84a34d5d175d0ffe8c4653", size = 101372, upload-time = "2026-03-13T19:49:54.867Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1541,7 +1529,6 @@ dependencies = [
|
||||
{ name = "arize-phoenix-otel" },
|
||||
{ name = "azure-identity" },
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "bleach" },
|
||||
{ name = "boto3" },
|
||||
{ name = "bs4" },
|
||||
{ name = "cachetools" },
|
||||
@@ -1743,7 +1730,6 @@ 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" },
|
||||
@@ -1845,7 +1831,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.6.0" },
|
||||
{ name = "pytest-env", specifier = "~=1.1.3" },
|
||||
{ name = "pytest-mock", specifier = "~=3.15.1" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.4.0" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.8.0" },
|
||||
@@ -3157,14 +3143,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hypothesis"
|
||||
version = "6.151.9"
|
||||
version = "6.148.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sortedcontainers" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3178,17 +3164,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "import-linter"
|
||||
version = "2.11"
|
||||
version = "2.10"
|
||||
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/ba/66/55b697a17bb15c6cb88d97d73716813f5427281527b90f02cc0a600abc6e/import_linter-2.11.tar.gz", hash = "sha256:5abc3394797a54f9bae315e7242dc98715ba485f840ac38c6d3192c370d0085e", size = 1153682, upload-time = "2026-03-06T12:11:38.198Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3930,14 +3918,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mypy-boto3-bedrock-runtime"
|
||||
version = "1.42.42"
|
||||
version = "1.41.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5526,15 +5514,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-env"
|
||||
version = "1.6.0"
|
||||
version = "1.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6046,27 +6033,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.6"
|
||||
version = "0.15.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6105,14 +6092,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "scipy-stubs"
|
||||
version = "1.17.1.2"
|
||||
version = "1.16.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "optype", extra = ["numpy"] },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6801,14 +6788,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-cffi"
|
||||
version = "2.0.0.20260316"
|
||||
version = "1.17.0.20250915"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "types-setuptools" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6840,11 +6827,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-docutils"
|
||||
version = "0.22.3.20260316"
|
||||
version = "0.22.3.20260223"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6874,15 +6861,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-gevent"
|
||||
version = "25.9.0.20251228"
|
||||
version = "25.9.0.20251102"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "types-greenlet" },
|
||||
{ name = "types-psutil" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6908,11 +6895,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-jmespath"
|
||||
version = "1.1.0.20260124"
|
||||
version = "1.0.2.20250809"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6965,20 +6952,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-openpyxl"
|
||||
version = "3.1.5.20260316"
|
||||
version = "3.1.5.20250919"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-pexpect"
|
||||
version = "4.9.0.20260127"
|
||||
version = "4.9.0.20250916"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7001,11 +6988,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-psycopg2"
|
||||
version = "2.9.21.20260223"
|
||||
version = "2.9.21.20251012"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7022,11 +7009,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-pymysql"
|
||||
version = "1.1.0.20251220"
|
||||
version = "1.1.0.20250916"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7044,11 +7031,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-python-dateutil"
|
||||
version = "2.9.0.20260305"
|
||||
version = "2.9.0.20251115"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7062,11 +7049,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-pywin32"
|
||||
version = "311.0.0.20260316"
|
||||
version = "311.0.0.20251008"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7123,11 +7110,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-setuptools"
|
||||
version = "82.0.0.20260210"
|
||||
version = "80.9.0.20250822"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7162,28 +7149,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-tensorflow"
|
||||
version = "2.18.0.20260224"
|
||||
version = "2.18.0.20251008"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "types-protobuf" },
|
||||
{ name = "types-requests" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-tqdm"
|
||||
version = "4.67.3.20260303"
|
||||
version = "4.67.0.20250809"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "types-requests" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1546,25 +1546,24 @@ SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
|
||||
|
||||
|
||||
# Redis URL used for event bus between API and
|
||||
# Redis URL used for PubSub between API and
|
||||
# celery worker
|
||||
# defaults to url constructed from `REDIS_*`
|
||||
# configurations
|
||||
EVENT_BUS_REDIS_URL=
|
||||
# Event transport type. Options are:
|
||||
PUBSUB_REDIS_URL=
|
||||
# Pub/sub channel type for streaming events.
|
||||
# valid options are:
|
||||
#
|
||||
# - 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)
|
||||
# - pubsub: for normal Pub/Sub
|
||||
# - sharded: for sharded Pub/Sub
|
||||
#
|
||||
# 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 use sharded Pub/Sub AND redis cluster
|
||||
# for large deployments.
|
||||
PUBSUB_REDIS_CHANNEL_TYPE=pubsub
|
||||
# Whether to use Redis cluster mode while running
|
||||
# PubSub.
|
||||
# It's highly recommended to enable this for large deployments.
|
||||
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
||||
PUBSUB_REDIS_USE_CLUSTERS=false
|
||||
|
||||
# Whether to Enable human input timeout check task
|
||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||
|
||||
@@ -269,7 +269,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@@ -123,7 +123,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
|
||||
@@ -699,9 +699,9 @@ x-shared-env: &shared-api-worker-env
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000}
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL:-200}
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30}
|
||||
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}
|
||||
PUBSUB_REDIS_URL: ${PUBSUB_REDIS_URL:-}
|
||||
PUBSUB_REDIS_CHANNEL_TYPE: ${PUBSUB_REDIS_CHANNEL_TYPE:-pubsub}
|
||||
PUBSUB_REDIS_USE_CLUSTERS: ${PUBSUB_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.4-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
buildGitDiffRevisionArgs,
|
||||
getChangedBranchCoverage,
|
||||
getChangedStatementCoverage,
|
||||
getIgnoredChangedLinesFromSource,
|
||||
@@ -8,11 +7,6 @@ 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',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ appId: string }>
|
||||
}) => {
|
||||
const { appId } = await props.params
|
||||
|
||||
return <Evaluation resourceType="workflow" resourceId={appId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
RiDashboard2Line,
|
||||
RiFileList3Fill,
|
||||
RiFileList3Line,
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiTerminalBoxFill,
|
||||
RiTerminalBoxLine,
|
||||
RiTerminalWindowFill,
|
||||
@@ -67,40 +69,47 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
}>>([])
|
||||
|
||||
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
|
||||
const navConfig = [
|
||||
...(isCurrentWorkspaceEditor
|
||||
? [{
|
||||
name: t('appMenus.promptEng', { ns: 'common' }),
|
||||
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
}]
|
||||
: []
|
||||
),
|
||||
{
|
||||
name: t('appMenus.apiAccess', { ns: 'common' }),
|
||||
href: `/app/${appId}/develop`,
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
},
|
||||
...(isCurrentWorkspaceEditor
|
||||
? [{
|
||||
name: mode !== AppModeEnum.WORKFLOW
|
||||
? t('appMenus.logAndAnn', { ns: 'common' })
|
||||
: t('appMenus.logs', { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
icon: RiFileList3Line,
|
||||
selectedIcon: RiFileList3Fill,
|
||||
}]
|
||||
: []
|
||||
),
|
||||
{
|
||||
name: t('appMenus.overview', { ns: 'common' }),
|
||||
href: `/app/${appId}/overview`,
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
},
|
||||
]
|
||||
const navConfig = []
|
||||
|
||||
if (isCurrentWorkspaceEditor) {
|
||||
navConfig.push({
|
||||
name: t('appMenus.promptEng', { ns: 'common' }),
|
||||
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
})
|
||||
navConfig.push({
|
||||
name: t('appMenus.evaluation', { ns: 'common' }),
|
||||
href: `/app/${appId}/evaluation`,
|
||||
icon: RiFlaskLine,
|
||||
selectedIcon: RiFlaskFill,
|
||||
})
|
||||
}
|
||||
|
||||
navConfig.push({
|
||||
name: t('appMenus.apiAccess', { ns: 'common' }),
|
||||
href: `/app/${appId}/develop`,
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
})
|
||||
|
||||
if (isCurrentWorkspaceEditor) {
|
||||
navConfig.push({
|
||||
name: mode !== AppModeEnum.WORKFLOW
|
||||
? t('appMenus.logAndAnn', { ns: 'common' })
|
||||
: t('appMenus.logs', { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
icon: RiFileList3Line,
|
||||
selectedIcon: RiFileList3Fill,
|
||||
})
|
||||
}
|
||||
|
||||
navConfig.push({
|
||||
name: t('appMenus.overview', { ns: 'common' }),
|
||||
href: `/app/${appId}/overview`,
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
})
|
||||
return navConfig
|
||||
}, [t])
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ datasetId: string }>
|
||||
}) => {
|
||||
const { datasetId } = await props.params
|
||||
|
||||
return <Evaluation resourceType="pipeline" resourceId={datasetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
RiEqualizer2Line,
|
||||
RiFileTextFill,
|
||||
RiFileTextLine,
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiFocus2Fill,
|
||||
RiFocus2Line,
|
||||
} from '@remixicon/react'
|
||||
@@ -86,20 +88,30 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
]
|
||||
|
||||
if (datasetRes?.provider !== 'external') {
|
||||
baseNavigation.unshift({
|
||||
name: t('datasetMenus.pipeline', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/pipeline`,
|
||||
icon: PipelineLine as RemixiconComponentType,
|
||||
selectedIcon: PipelineFill as RemixiconComponentType,
|
||||
disabled: false,
|
||||
})
|
||||
baseNavigation.unshift({
|
||||
name: t('datasetMenus.documents', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/documents`,
|
||||
icon: RiFileTextLine,
|
||||
selectedIcon: RiFileTextFill,
|
||||
disabled: isButtonDisabledWithPipeline,
|
||||
})
|
||||
return [
|
||||
{
|
||||
name: t('datasetMenus.documents', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/documents`,
|
||||
icon: RiFileTextLine,
|
||||
selectedIcon: RiFileTextFill,
|
||||
disabled: isButtonDisabledWithPipeline,
|
||||
},
|
||||
{
|
||||
name: t('datasetMenus.pipeline', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/pipeline`,
|
||||
icon: PipelineLine as RemixiconComponentType,
|
||||
selectedIcon: PipelineFill as RemixiconComponentType,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
name: t('datasetMenus.evaluation', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/evaluation`,
|
||||
icon: RiFlaskLine,
|
||||
selectedIcon: RiFlaskFill,
|
||||
disabled: false,
|
||||
},
|
||||
...baseNavigation,
|
||||
]
|
||||
}
|
||||
|
||||
return baseNavigation
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useEffect } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
|
||||
|
||||
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetPage snippetId={snippetId} section="evaluation" />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,11 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetPage snippetId={snippetId} section="orchestrate" />
|
||||
}
|
||||
|
||||
export default Page
|
||||
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import Page from './page'
|
||||
|
||||
const mockRedirect = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
redirect: (path: string) => mockRedirect(path),
|
||||
}))
|
||||
|
||||
describe('snippet detail redirect page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should redirect legacy snippet detail routes to orchestrate', async () => {
|
||||
await Page({
|
||||
params: Promise.resolve({ snippetId: 'snippet-1' }),
|
||||
})
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
|
||||
})
|
||||
})
|
||||
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
redirect(`/snippets/${snippetId}/orchestrate`)
|
||||
}
|
||||
|
||||
export default Page
|
||||
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Apps from '@/app/components/apps'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <Apps pageType="snippets" />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@@ -165,6 +165,21 @@ describe('AppDetailNav', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom header and navigation when provided', () => {
|
||||
render(
|
||||
<AppDetailNav
|
||||
navigation={navigation}
|
||||
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
|
||||
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow canvas mode', () => {
|
||||
|
||||
@@ -27,12 +27,16 @@ export type IAppDetailNavProps = {
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
}
|
||||
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
iconType = 'app',
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
}: IAppDetailNavProps) => {
|
||||
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
|
||||
appSidebarExpand: state.appSidebarExpand,
|
||||
@@ -104,10 +108,11 @@ const AppDetailNav = ({
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{iconType === 'app' && (
|
||||
{renderHeader?.(appSidebarExpand)}
|
||||
{!renderHeader && iconType === 'app' && (
|
||||
<AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
{!renderHeader && iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
@@ -136,7 +141,8 @@ const AppDetailNav = ({
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{navigation.map((item, index) => {
|
||||
{renderNavigation?.(appSidebarExpand)}
|
||||
{!renderNavigation && navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
|
||||
@@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Mode', () => {
|
||||
it('should render as an interactive button when href is omitted', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
|
||||
|
||||
const buttonElement = screen.getByText('Orchestrate').closest('button')
|
||||
expect(buttonElement).not.toBeNull()
|
||||
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
|
||||
buttonElement?.click()
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
href: string
|
||||
href?: string
|
||||
iconMap: {
|
||||
selected: NavIcon
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
@@ -29,6 +31,8 @@ const NavLink = ({
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
active,
|
||||
onClick,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
@@ -39,8 +43,11 @@ const NavLink = ({
|
||||
|
||||
return res
|
||||
})()
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
const linkClassName = cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
@@ -70,13 +77,32 @@ const NavLink = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
|
||||
53
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
53
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type SnippetInfoProps = {
|
||||
expand: boolean
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const SnippetInfo = ({
|
||||
expand,
|
||||
snippet,
|
||||
}: SnippetInfoProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-col', expand ? '' : 'p-1')}>
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(!expand && 'ml-1')}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType="emoji"
|
||||
icon={snippet.icon}
|
||||
background={snippet.iconBackground}
|
||||
/>
|
||||
</div>
|
||||
{expand && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-text-secondary system-md-semibold">
|
||||
{snippet.name}
|
||||
</div>
|
||||
{snippet.status && (
|
||||
<div className="pt-1">
|
||||
<Badge>{snippet.status}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{expand && snippet.description && (
|
||||
<p className="line-clamp-3 text-text-tertiary system-xs-regular">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfo)
|
||||
@@ -218,7 +218,7 @@ describe('ParamConfigContent', () => {
|
||||
})
|
||||
|
||||
render(<ParamConfigContent />)
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement
|
||||
const input = screen.getByRole('spinbutton') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: '4' } })
|
||||
|
||||
const updatedFile = getLatestFileConfig()
|
||||
|
||||
@@ -184,8 +184,8 @@ describe('dataset-config/params-config', () => {
|
||||
await user.click(incrementButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
const [topKInput] = dialogScope.getAllByRole('textbox')
|
||||
expect(topKInput).toHaveValue('5')
|
||||
const [topKInput] = dialogScope.getAllByRole('spinbutton')
|
||||
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('textbox')
|
||||
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
|
||||
|
||||
// Assert
|
||||
expect(reopenedTopKInput).toHaveValue('5')
|
||||
expect(reopenedTopKInput).toHaveValue(5)
|
||||
})
|
||||
|
||||
it('should discard changes when cancel is clicked', async () => {
|
||||
@@ -217,8 +217,8 @@ describe('dataset-config/params-config', () => {
|
||||
await user.click(incrementButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
const [topKInput] = dialogScope.getAllByRole('textbox')
|
||||
expect(topKInput).toHaveValue('5')
|
||||
const [topKInput] = dialogScope.getAllByRole('spinbutton')
|
||||
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('textbox')
|
||||
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
|
||||
|
||||
// Assert
|
||||
expect(reopenedTopKInput).toHaveValue('4')
|
||||
expect(reopenedTopKInput).toHaveValue(4)
|
||||
})
|
||||
|
||||
it('should prevent saving when rerank model is required but invalid', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
@@ -116,10 +117,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="text-text-primary title-2xl-semi-bold">{t('newApp.startFromBlank', { ns: 'app' })}</span>
|
||||
<span className="title-2xl-semi-bold text-text-primary">{t('newApp.startFromBlank', { ns: 'app' })}</span>
|
||||
</div>
|
||||
<div className="mb-2 leading-6">
|
||||
<span className="text-text-secondary system-sm-semibold">{t('newApp.chooseAppType', { ns: 'app' })}</span>
|
||||
<span className="system-sm-semibold text-text-secondary">{t('newApp.chooseAppType', { ns: 'app' })}</span>
|
||||
</div>
|
||||
<div className="flex w-[660px] flex-col gap-4">
|
||||
<div>
|
||||
@@ -159,7 +160,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
className="flex cursor-pointer items-center border-0 bg-transparent p-0"
|
||||
onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)}
|
||||
>
|
||||
<span className="text-text-tertiary system-2xs-medium-uppercase">{t('newApp.forBeginners', { ns: 'app' })}</span>
|
||||
<span className="system-2xs-medium-uppercase text-text-tertiary">{t('newApp.forBeginners', { ns: 'app' })}</span>
|
||||
<RiArrowRightSLine className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -211,7 +212,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="text-text-secondary system-sm-semibold">{t('newApp.captionName', { ns: 'app' })}</label>
|
||||
<label className="system-sm-semibold text-text-secondary">{t('newApp.captionName', { ns: 'app' })}</label>
|
||||
</div>
|
||||
<Input
|
||||
value={name}
|
||||
@@ -242,8 +243,8 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<label className="text-text-secondary system-sm-semibold">{t('newApp.captionDescription', { ns: 'app' })}</label>
|
||||
<span className="ml-1 text-text-tertiary system-xs-regular">
|
||||
<label className="system-sm-semibold text-text-secondary">{t('newApp.captionDescription', { ns: 'app' })}</label>
|
||||
<span className="system-xs-regular ml-1 text-text-tertiary">
|
||||
(
|
||||
{t('newApp.optional', { ns: 'app' })}
|
||||
)
|
||||
@@ -259,7 +260,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="flex cursor-pointer items-center gap-1 text-text-tertiary system-xs-regular" onClick={onCreateFromTemplate}>
|
||||
<div className="system-xs-regular flex cursor-pointer items-center gap-1 text-text-tertiary" onClick={onCreateFromTemplate}>
|
||||
<span>{t('newApp.noIdeaTip', { ns: 'app' })}</span>
|
||||
<div className="p-[1px]">
|
||||
<RiArrowRightLine className="h-3.5 w-3.5" />
|
||||
@@ -333,8 +334,8 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
@@ -366,8 +367,8 @@ function AppPreview({ mode }: { mode: AppModeEnum }) {
|
||||
const previewInfo = modeToPreviewInfoMap[mode]
|
||||
return (
|
||||
<div className="px-8 py-4">
|
||||
<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">
|
||||
<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">
|
||||
<span>{previewInfo.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -388,7 +389,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`} />
|
||||
<img
|
||||
<Image
|
||||
className={show ? '' : 'hidden'}
|
||||
src={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`}
|
||||
alt="App Screen Shot"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
@@ -6,19 +6,15 @@ import { AppModeEnum } from '@/types/app'
|
||||
|
||||
import List from '../list'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockRouter = { replace: mockReplace }
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
useSearchParams: () => new URLSearchParams(''),
|
||||
}))
|
||||
|
||||
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
|
||||
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
|
||||
const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -36,6 +32,7 @@ const mockQueryState = {
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
}
|
||||
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
default: () => ({
|
||||
query: mockQueryState,
|
||||
@@ -45,6 +42,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
|
||||
let mockOnDSLFileDropped: ((file: File) => void) | null = null
|
||||
let mockDragging = false
|
||||
|
||||
vi.mock('../hooks/use-dsl-drag-drop', () => ({
|
||||
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
|
||||
mockOnDSLFileDropped = onDSLFileDropped
|
||||
@@ -59,6 +57,7 @@ const mockServiceState = {
|
||||
error: null as Error | null,
|
||||
hasNextPage: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
}
|
||||
|
||||
@@ -100,6 +99,7 @@ vi.mock('@/service/use-apps', () => ({
|
||||
useInfiniteAppList: () => ({
|
||||
data: defaultAppData,
|
||||
isLoading: mockServiceState.isLoading,
|
||||
isFetching: mockServiceState.isFetching,
|
||||
isFetchingNextPage: mockServiceState.isFetchingNextPage,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: mockServiceState.hasNextPage,
|
||||
@@ -133,13 +133,21 @@ vi.mock('next/dynamic', () => ({
|
||||
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
|
||||
}
|
||||
}
|
||||
|
||||
if (fnString.includes('create-from-dsl-modal')) {
|
||||
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
|
||||
if (!show)
|
||||
return null
|
||||
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'create-dsl-modal' },
|
||||
React.createElement('button', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
|
||||
React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return () => null
|
||||
},
|
||||
}))
|
||||
@@ -188,9 +196,8 @@ beforeAll(() => {
|
||||
} as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
// Render helper wrapping with shared nuqs testing helper.
|
||||
const renderList = (searchParams = '') => {
|
||||
return renderWithNuqs(<List />, { searchParams })
|
||||
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
|
||||
return renderWithNuqs(<List {...props} />, { searchParams })
|
||||
}
|
||||
|
||||
describe('List', () => {
|
||||
@@ -202,11 +209,13 @@ describe('List', () => {
|
||||
})
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
mockIsLoadingCurrentWorkspace.mockReturnValue(false)
|
||||
mockDragging = false
|
||||
mockOnDSLFileDropped = null
|
||||
mockServiceState.error = null
|
||||
mockServiceState.hasNextPage = false
|
||||
mockServiceState.isLoading = false
|
||||
mockServiceState.isFetching = false
|
||||
mockServiceState.isFetchingNextPage = false
|
||||
mockQueryState.tagIDs = []
|
||||
mockQueryState.keywords = ''
|
||||
@@ -215,372 +224,94 @@ describe('List', () => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tab slider with all app types', () => {
|
||||
describe('Apps Mode', () => {
|
||||
it('should render the apps route switch, dropdown filters, and app cards', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render search input', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.getByRole('link', { name: 'workflow.tabs.snippets' })).toHaveAttribute('href', '/snippets')
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created by me checkbox', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render new app card for editors', () => {
|
||||
renderList()
|
||||
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render footer when branding is disabled', () => {
|
||||
renderList()
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render drop DSL hint for editors', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should update URL when workflow tab is clicked', async () => {
|
||||
it('should update the category query when selecting an app type from the dropdown', async () => {
|
||||
const { onUrlUpdate } = renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
fireEvent.click(screen.getByText('app.studio.filters.types'))
|
||||
fireEvent.click(await screen.findByText('app.types.workflow'))
|
||||
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should update URL when all tab is clicked', async () => {
|
||||
const { onUrlUpdate } = renderList('?category=workflow')
|
||||
it('should keep the creators dropdown visual-only and not update app query state', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
fireEvent.click(screen.getByText('app.studio.filters.creators'))
|
||||
fireEvent.click(await screen.findByText('Evan'))
|
||||
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
// nuqs removes the default value ('all') from URL params
|
||||
expect(lastCall.searchParams.has('category')).toBe(false)
|
||||
expect(mockSetQuery).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render and close the DSL import modal when a file is dropped', () => {
|
||||
renderList()
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
mockOnDSLFileDropped(mockFile)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('close-dsl-modal'))
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should render search input field', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
describe('Snippets Mode', () => {
|
||||
it('should render the snippets create card and fake snippet card', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
expect(screen.getByText('snippet.create')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1')
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search input change', () => {
|
||||
renderList()
|
||||
it('should filter local snippets by the search input and show the snippet empty state', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
fireEvent.change(input, { target: { value: 'missing snippet' } })
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
expect(screen.queryByText('Tone Rewriter')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search clear button click', () => {
|
||||
mockQueryState.keywords = 'existing search'
|
||||
it('should not render app-only controls in snippets mode', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
renderList()
|
||||
|
||||
const clearButton = document.querySelector('.group')
|
||||
expect(clearButton).toBeInTheDocument()
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Filter', () => {
|
||||
it('should render tag filter component', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render checkbox with correct label', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.studio.filters.types')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.tag.placeholder')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle checkbox change', () => {
|
||||
renderList()
|
||||
it('should reserve the infinite-scroll anchor without fetching more pages', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-undefined')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Non-Editor User', () => {
|
||||
it('should not render new app card for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render drop DSL hint for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dataset Operator Behavior', () => {
|
||||
it('should not trigger redirect at component level for dataset operators', () => {
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Local Storage Refresh', () => {
|
||||
it('should call refetch when refresh key is set in localStorage', () => {
|
||||
localStorage.setItem('needRefreshAppList', '1')
|
||||
|
||||
renderList()
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = renderWithNuqs(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
|
||||
rerender(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards correctly', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with all filter options visible', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dragging State', () => {
|
||||
it('should show drop hint when DSL feature is enabled for editors', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dragging state overlay when dragging', () => {
|
||||
mockDragging = true
|
||||
const { container } = renderList()
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Type Tabs', () => {
|
||||
it('should render all app type tabs', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update URL for each app type tab click', async () => {
|
||||
const { onUrlUpdate } = renderList()
|
||||
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
|
||||
{ mode: AppModeEnum.CHAT, text: 'app.types.chatbot' },
|
||||
{ mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' },
|
||||
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
|
||||
]
|
||||
|
||||
for (const { mode, text } of appTypeTexts) {
|
||||
onUrlUpdate.mockClear()
|
||||
fireEvent.click(screen.getByText(text))
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(mode)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('App List Display', () => {
|
||||
it('should display all app cards from data', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display app names correctly', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Footer Visibility', () => {
|
||||
it('should render footer when branding is disabled', () => {
|
||||
renderList()
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DSL File Drop', () => {
|
||||
it('should handle DSL file drop and show modal', () => {
|
||||
renderList()
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
mockOnDSLFileDropped(mockFile)
|
||||
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close DSL modal when onClose is called', () => {
|
||||
renderList()
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
mockOnDSLFileDropped(mockFile)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-dsl-modal'))
|
||||
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close DSL modal and refetch when onSuccess is called', () => {
|
||||
renderList()
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
mockOnDSLFileDropped(mockFile)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('success-dsl-modal'))
|
||||
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Infinite Scroll', () => {
|
||||
it('should call fetchNextPage when intersection observer triggers', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call fetchNextPage when not intersecting', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
[{ isIntersecting: false } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call fetchNextPage when loading', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
mockServiceState.isLoading = true
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error State', () => {
|
||||
it('should handle error state in useEffect', () => {
|
||||
mockServiceState.error = new Error('Test error')
|
||||
const { container } = renderList()
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
15
web/app/components/apps/app-type-filter-shared.ts
Normal file
15
web/app/components/apps/app-type-filter-shared.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { parseAsStringLiteral } from 'nuqs'
|
||||
import { AppModes } from '@/types/app'
|
||||
|
||||
export const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
export const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
71
web/app/components/apps/app-type-filter.tsx
Normal file
71
web/app/components/apps/app-type-filter.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { isAppListCategory } from './app-type-filter-shared'
|
||||
|
||||
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
|
||||
|
||||
type AppTypeFilterProps = {
|
||||
activeTab: import('./app-type-filter-shared').AppListCategory
|
||||
onChange: (value: import('./app-type-filter-shared').AppListCategory) => void
|
||||
}
|
||||
|
||||
const AppTypeFilter = ({
|
||||
activeTab,
|
||||
onChange,
|
||||
}: AppTypeFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = useMemo(() => ([
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
|
||||
]), [t])
|
||||
|
||||
const activeOption = options.find(option => option.value === activeTab)
|
||||
const triggerLabel = activeTab === 'all' ? t('studio.filters.types', { ns: 'app' }) : activeOption?.text
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(chipClassName, activeTab !== 'all' && 'shadow-xs')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
|
||||
<span>{triggerLabel}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
|
||||
<DropdownMenuRadioGroup value={activeTab} onValueChange={value => isAppListCategory(value) && onChange(value)}>
|
||||
{options.map(option => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
|
||||
<span>{option.text}</span>
|
||||
<DropdownMenuRadioItemIndicator />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppTypeFilter
|
||||
128
web/app/components/apps/creators-filter.tsx
Normal file
128
web/app/components/apps/creators-filter.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuCheckboxItemIndicator,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CreatorOption = {
|
||||
id: string
|
||||
name: string
|
||||
isYou?: boolean
|
||||
avatarClassName: string
|
||||
}
|
||||
|
||||
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
|
||||
|
||||
const creatorOptions: CreatorOption[] = [
|
||||
{ id: 'evan', name: 'Evan', isYou: true, avatarClassName: 'bg-gradient-to-br from-[#ff9b3f] to-[#ff4d00]' },
|
||||
{ id: 'jack', name: 'Jack', avatarClassName: 'bg-gradient-to-br from-[#fde68a] to-[#d6d3d1]' },
|
||||
{ id: 'gigi', name: 'Gigi', avatarClassName: 'bg-gradient-to-br from-[#f9a8d4] to-[#a78bfa]' },
|
||||
{ id: 'alice', name: 'Alice', avatarClassName: 'bg-gradient-to-br from-[#93c5fd] to-[#4f46e5]' },
|
||||
{ id: 'mandy', name: 'Mandy', avatarClassName: 'bg-gradient-to-br from-[#374151] to-[#111827]' },
|
||||
]
|
||||
|
||||
const CreatorsFilter = () => {
|
||||
const { t } = useTranslation()
|
||||
const [selectedCreatorIds, setSelectedCreatorIds] = useState<string[]>([])
|
||||
const [keywords, setKeywords] = useState('')
|
||||
|
||||
const filteredCreators = useMemo(() => {
|
||||
const normalizedKeywords = keywords.trim().toLowerCase()
|
||||
if (!normalizedKeywords)
|
||||
return creatorOptions
|
||||
|
||||
return creatorOptions.filter(creator => creator.name.toLowerCase().includes(normalizedKeywords))
|
||||
}, [keywords])
|
||||
|
||||
const selectedCount = selectedCreatorIds.length
|
||||
const triggerLabel = selectedCount > 0
|
||||
? `${t('studio.filters.creators', { ns: 'app' })} +${selectedCount}`
|
||||
: t('studio.filters.creators', { ns: 'app' })
|
||||
|
||||
const toggleCreator = useCallback((creatorId: string) => {
|
||||
setSelectedCreatorIds((prev) => {
|
||||
if (prev.includes(creatorId))
|
||||
return prev.filter(id => id !== creatorId)
|
||||
return [...prev, creatorId]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resetCreators = useCallback(() => {
|
||||
setSelectedCreatorIds([])
|
||||
setKeywords('')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(chipClassName, selectedCount > 0 && 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span>{triggerLabel}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
|
||||
<div className="flex items-center gap-2 p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onClear={() => setKeywords('')}
|
||||
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={resetCreators}
|
||||
>
|
||||
{t('studio.filters.reset', { ns: 'app' })}
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={selectedCreatorIds.length === 0}
|
||||
onCheckedChange={resetCreators}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span>{t('studio.filters.allCreators', { ns: 'app' })}</span>
|
||||
<DropdownMenuCheckboxItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
{filteredCreators.map(creator => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={creator.id}
|
||||
checked={selectedCreatorIds.includes(creator.id)}
|
||||
onCheckedChange={() => toggleCreator(creator.id)}
|
||||
>
|
||||
<span className={cn('h-5 w-5 shrink-0 rounded-full border border-white', creator.avatarClassName)} />
|
||||
<span className="flex min-w-0 grow items-center justify-between gap-2">
|
||||
<span className="truncate">{creator.name}</span>
|
||||
{creator.isYou && (
|
||||
<span className="shrink-0 text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
|
||||
)}
|
||||
</span>
|
||||
<DropdownMenuCheckboxItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreatorsFilter
|
||||
@@ -14,10 +14,20 @@ import CreateAppModal from '../explore/create-app-modal'
|
||||
import TryApp from '../explore/try-app'
|
||||
import List from './list'
|
||||
|
||||
const Apps = () => {
|
||||
export type StudioPageType = 'apps' | 'snippets'
|
||||
|
||||
type AppsProps = {
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
|
||||
const Apps = ({
|
||||
pageType = 'apps',
|
||||
}: AppsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useDocumentTitle(t('menus.apps', { ns: 'common' }))
|
||||
useDocumentTitle(pageType === 'apps'
|
||||
? t('menus.apps', { ns: 'common' })
|
||||
: t('tabs.snippets', { ns: 'workflow' }))
|
||||
useEducationInit()
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
@@ -101,7 +111,7 @@ const Apps = () => {
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<List controlRefreshList={controlRefreshList} />
|
||||
<List controlRefreshList={controlRefreshList} pageType={pageType} />
|
||||
{isShowTryAppPanel && (
|
||||
<TryApp
|
||||
appId={currentTryAppParams?.appId || ''}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { StudioPageType } from '.'
|
||||
import type { SnippetListItem } from '@/models/snippet'
|
||||
import type { App } from '@/types/app'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import TagFilter from '@/app/components/base/tag-management/filter'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum, AppModes } from '@/types/app'
|
||||
import { getSnippetListMock } from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import AppTypeFilter from './app-type-filter'
|
||||
import { parseAsAppListCategory } from './app-type-filter-shared'
|
||||
import CreatorsFilter from './creators-filter'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import useAppsQueryState from './hooks/use-apps-query-state'
|
||||
@@ -33,25 +38,104 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
const StudioRouteSwitch = ({ pageType, appsLabel, snippetsLabel }: { pageType: StudioPageType, appsLabel: string, snippetsLabel: string }) => {
|
||||
return (
|
||||
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-[1px]">
|
||||
<Link
|
||||
href="/apps"
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
|
||||
pageType === 'apps' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
|
||||
pageType !== 'apps' && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{appsLabel}
|
||||
</Link>
|
||||
<Link
|
||||
href="/snippets"
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
|
||||
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
|
||||
pageType !== 'snippets' && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{snippetsLabel}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
const SnippetCreateCard = () => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity">
|
||||
<div className="grow rounded-t-xl p-2">
|
||||
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('create')}</div>
|
||||
<div className="mb-1 flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
|
||||
<span aria-hidden className="i-ri-sticky-note-add-line mr-2 h-4 w-4 shrink-0" />
|
||||
{t('newApp.startFromBlank', { ns: 'app' })}
|
||||
</div>
|
||||
<div className="flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
|
||||
<span aria-hidden className="i-ri-file-upload-line mr-2 h-4 w-4 shrink-0" />
|
||||
{t('importDSL', { ns: 'app' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetCard = ({
|
||||
snippet,
|
||||
}: {
|
||||
snippet: SnippetListItem
|
||||
}) => {
|
||||
return (
|
||||
<Link href={`/snippets/${snippet.id}/orchestrate`} className="group col-span-1">
|
||||
<article className="relative inline-flex h-[160px] w-full flex-col rounded-xl border border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg">
|
||||
{snippet.status && (
|
||||
<div className="absolute right-0 top-0 rounded-bl-lg rounded-tr-xl bg-background-default-dimmed px-2 py-1 text-[10px] font-medium uppercase leading-3 text-text-placeholder">
|
||||
{snippet.status}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-divider-regular text-xl text-white" style={{ background: snippet.iconBackground }}>
|
||||
<span aria-hidden>{snippet.icon}</span>
|
||||
</div>
|
||||
<div className="w-0 grow py-[1px]">
|
||||
<div className="truncate text-sm font-semibold leading-5 text-text-secondary" title={snippet.name}>
|
||||
{snippet.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[58px] px-[14px] text-xs leading-normal text-text-tertiary">
|
||||
<div className="line-clamp-2" title={snippet.description}>
|
||||
{snippet.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto flex items-center gap-1 px-[14px] pb-3 pt-2 text-xs leading-4 text-text-tertiary">
|
||||
<span className="truncate">{snippet.author}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{snippet.updatedAt}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{snippet.usage}</span>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
controlRefreshList?: number
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
|
||||
const List: FC<Props> = ({
|
||||
controlRefreshList = 0,
|
||||
pageType = 'apps',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isAppsPage = pageType === 'apps'
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
@@ -61,18 +145,21 @@ const List: FC<Props> = ({
|
||||
)
|
||||
|
||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
|
||||
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
|
||||
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
|
||||
const [searchKeywords, setSearchKeywords] = useState(keywords)
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [appKeywords, setAppKeywords] = useState(keywords)
|
||||
const [snippetKeywords, setSnippetKeywords] = useState('')
|
||||
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
|
||||
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
|
||||
const setKeywords = useCallback((keywords: string) => {
|
||||
setQuery(prev => ({ ...prev, keywords }))
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const setKeywords = useCallback((nextKeywords: string) => {
|
||||
setQuery(prev => ({ ...prev, keywords: nextKeywords }))
|
||||
}, [setQuery])
|
||||
const setTagIDs = useCallback((tagIDs: string[]) => {
|
||||
setQuery(prev => ({ ...prev, tagIDs }))
|
||||
|
||||
const setTagIDs = useCallback((nextTagIDs: string[]) => {
|
||||
setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
|
||||
}, [setQuery])
|
||||
|
||||
const handleDSLFileDropped = useCallback((file: File) => {
|
||||
@@ -83,15 +170,15 @@ const List: FC<Props> = ({
|
||||
const { dragging } = useDSLDragDrop({
|
||||
onDSLFileDropped: handleDSLFileDropped,
|
||||
containerRef,
|
||||
enabled: isCurrentWorkspaceEditor,
|
||||
enabled: isAppsPage && isCurrentWorkspaceEditor,
|
||||
})
|
||||
|
||||
const appListQueryParams = {
|
||||
page: 1,
|
||||
limit: 30,
|
||||
name: searchKeywords,
|
||||
name: appKeywords,
|
||||
tag_ids: tagIDs,
|
||||
is_created_by_me: isCreatedByMe,
|
||||
is_created_by_me: queryIsCreatedByMe,
|
||||
...(activeTab !== 'all' ? { mode: activeTab } : {}),
|
||||
}
|
||||
|
||||
@@ -104,48 +191,40 @@ const List: FC<Props> = ({
|
||||
hasNextPage,
|
||||
error,
|
||||
refetch,
|
||||
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
|
||||
} = useInfiniteAppList(appListQueryParams, {
|
||||
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (controlRefreshList > 0) {
|
||||
if (isAppsPage && controlRefreshList > 0)
|
||||
refetch()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controlRefreshList])
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const options = [
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="i-ri-apps-2-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="i-ri-exchange-2-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="i-ri-robot-3-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="i-ri-file-4-line mr-1 h-[14px] w-[14px]" /> },
|
||||
]
|
||||
}, [controlRefreshList, isAppsPage, refetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAppsPage)
|
||||
return
|
||||
|
||||
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
|
||||
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
|
||||
refetch()
|
||||
}
|
||||
}, [refetch])
|
||||
}, [isAppsPage, refetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
return
|
||||
const hasMore = hasNextPage ?? true
|
||||
|
||||
const hasMore = isAppsPage ? (hasNextPage ?? true) : false
|
||||
let observer: IntersectionObserver | undefined
|
||||
|
||||
if (error) {
|
||||
if (observer)
|
||||
observer.disconnect()
|
||||
observer?.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (anchorRef.current && containerRef.current) {
|
||||
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
|
||||
const containerHeight = containerRef.current.clientHeight
|
||||
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
|
||||
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
|
||||
@@ -153,110 +232,148 @@ const List: FC<Props> = ({
|
||||
}, {
|
||||
root: containerRef.current,
|
||||
rootMargin: `${dynamicMargin}px`,
|
||||
threshold: 0.1, // Trigger when 10% of the anchor element is visible
|
||||
threshold: 0.1,
|
||||
})
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
|
||||
}, [error, fetchNextPage, hasNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading])
|
||||
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchKeywords(keywords)
|
||||
const { run: handleAppSearch } = useDebounceFn((value: string) => {
|
||||
setAppKeywords(value)
|
||||
}, { wait: 500 })
|
||||
const handleKeywordsChange = (value: string) => {
|
||||
setKeywords(value)
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const { run: handleTagsUpdate } = useDebounceFn(() => {
|
||||
setTagIDs(tagFilterValue)
|
||||
const handleKeywordsChange = useCallback((value: string) => {
|
||||
if (isAppsPage) {
|
||||
setKeywords(value)
|
||||
handleAppSearch(value)
|
||||
return
|
||||
}
|
||||
|
||||
setSnippetKeywords(value)
|
||||
}, [handleAppSearch, isAppsPage, setKeywords])
|
||||
|
||||
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
|
||||
setTagIDs(value)
|
||||
}, { wait: 500 })
|
||||
const handleTagsChange = (value: string[]) => {
|
||||
|
||||
const handleTagsChange = useCallback((value: string[]) => {
|
||||
setTagFilterValue(value)
|
||||
handleTagsUpdate()
|
||||
}
|
||||
handleTagsUpdate(value)
|
||||
}, [handleTagsUpdate])
|
||||
|
||||
const handleCreatedByMeChange = useCallback(() => {
|
||||
const newValue = !isCreatedByMe
|
||||
setIsCreatedByMe(newValue)
|
||||
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
|
||||
}, [isCreatedByMe, setQuery])
|
||||
const appItems = useMemo<App[]>(() => {
|
||||
return (data?.pages ?? []).flatMap(({ data: apps }) => apps)
|
||||
}, [data?.pages])
|
||||
|
||||
const pages = data?.pages ?? []
|
||||
const hasAnyApp = (pages[0]?.total ?? 0) > 0
|
||||
// Show skeleton during initial load or when refetching with no previous data
|
||||
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
||||
const snippetItems = useMemo(() => getSnippetListMock(), [])
|
||||
|
||||
const filteredSnippetItems = useMemo(() => {
|
||||
const normalizedKeywords = snippetKeywords.trim().toLowerCase()
|
||||
if (!normalizedKeywords)
|
||||
return snippetItems
|
||||
|
||||
return snippetItems.filter(item =>
|
||||
item.name.toLowerCase().includes(normalizedKeywords)
|
||||
|| item.description.toLowerCase().includes(normalizedKeywords),
|
||||
)
|
||||
}, [snippetItems, snippetKeywords])
|
||||
|
||||
const showSkeleton = isAppsPage && (isLoading || (isFetching && data?.pages?.length === 0))
|
||||
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
|
||||
const hasAnySnippet = filteredSnippetItems.length > 0
|
||||
const currentKeywords = isAppsPage ? keywords : snippetKeywords
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
{dragging && (
|
||||
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2">
|
||||
</div>
|
||||
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2" />
|
||||
)}
|
||||
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
|
||||
<TabSliderNew
|
||||
value={activeTab}
|
||||
onChange={(nextValue) => {
|
||||
if (isAppListCategory(nextValue))
|
||||
setActiveTab(nextValue)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckboxWithLabel
|
||||
className="mr-2"
|
||||
label={t('showMyCreatedAppsOnly', { ns: 'app' })}
|
||||
isChecked={isCreatedByMe}
|
||||
onChange={handleCreatedByMeChange}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StudioRouteSwitch
|
||||
pageType={pageType}
|
||||
appsLabel={t('studio.apps', { ns: 'app' })}
|
||||
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
|
||||
/>
|
||||
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
|
||||
{isAppsPage && (
|
||||
<AppTypeFilter
|
||||
activeTab={activeTab}
|
||||
onChange={(value) => {
|
||||
void setActiveTab(value)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CreatorsFilter />
|
||||
{isAppsPage && (
|
||||
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="w-[200px]"
|
||||
value={keywords}
|
||||
placeholder={isAppsPage ? undefined : t('tabs.searchSnippets', { ns: 'workflow' })}
|
||||
value={currentKeywords}
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
onClear={() => handleKeywordsChange('')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
|
||||
!hasAnyApp && 'overflow-hidden',
|
||||
isAppsPage && !hasAnyApp && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
|
||||
<NewAppCard
|
||||
ref={newAppCardRef}
|
||||
isLoading={isLoadingCurrentWorkspace}
|
||||
onSuccess={refetch}
|
||||
selectedAppType={activeTab}
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
isAppsPage
|
||||
? (
|
||||
<NewAppCard
|
||||
ref={newAppCardRef}
|
||||
isLoading={isLoadingCurrentWorkspace}
|
||||
onSuccess={refetch}
|
||||
selectedAppType={activeTab}
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
)
|
||||
: <SnippetCreateCard />
|
||||
)}
|
||||
{(() => {
|
||||
if (showSkeleton)
|
||||
return <AppCardSkeleton count={6} />
|
||||
|
||||
if (hasAnyApp) {
|
||||
return pages.flatMap(({ data: apps }) => apps).map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||
))
|
||||
}
|
||||
{showSkeleton && <AppCardSkeleton count={6} />}
|
||||
|
||||
// No apps - show empty state
|
||||
return <Empty />
|
||||
})()}
|
||||
{isFetchingNextPage && (
|
||||
{!showSkeleton && isAppsPage && hasAnyApp && appItems.map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||
))}
|
||||
|
||||
{!showSkeleton && !isAppsPage && hasAnySnippet && filteredSnippetItems.map(snippet => (
|
||||
<SnippetCard key={snippet.id} snippet={snippet} />
|
||||
))}
|
||||
|
||||
{!showSkeleton && isAppsPage && !hasAnyApp && <Empty />}
|
||||
|
||||
{!showSkeleton && !isAppsPage && !hasAnySnippet && (
|
||||
<div className="col-span-full flex min-h-[240px] items-center justify-center rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-6 text-center text-sm text-text-tertiary">
|
||||
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAppsPage && isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCurrentWorkspaceEditor && (
|
||||
{isAppsPage && isCurrentWorkspaceEditor && (
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 py-4',
|
||||
dragging ? 'text-text-accent' : 'text-text-quaternary',
|
||||
)}
|
||||
role="region"
|
||||
aria-label={t('newApp.dropDSLToCreateApp', { ns: 'app' })}
|
||||
>
|
||||
@@ -264,17 +381,18 @@ const List: FC<Props> = ({
|
||||
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<Footer />
|
||||
)}
|
||||
<CheckModal />
|
||||
<div ref={anchorRef} className="h-0"> </div>
|
||||
{showTagManagementModal && (
|
||||
{isAppsPage && showTagManagementModal && (
|
||||
<TagManagementModal type="app" show={showTagManagementModal} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateFromDSLModal && (
|
||||
{isAppsPage && showCreateFromDSLModal && (
|
||||
<CreateFromDSLModal
|
||||
show={showCreateFromDSLModal}
|
||||
onClose={() => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* 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'
|
||||
@@ -20,6 +22,15 @@ vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropd
|
||||
default: () => <div data-testid="view-form-dropdown" />,
|
||||
}))
|
||||
|
||||
// Mock next/image to render a normal img tag for testing
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => {
|
||||
const { unoptimized: _, ...rest } = props
|
||||
return <img {...rest} />
|
||||
},
|
||||
}))
|
||||
|
||||
type GlobalPublicStoreMock = {
|
||||
systemFeatures: SystemFeatures
|
||||
setSystemFeatures: (systemFeatures: SystemFeatures) => void
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CheckboxList from '..'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('checkbox list component', () => {
|
||||
const options = [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'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'
|
||||
@@ -168,7 +169,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
|
||||
{searchQuery
|
||||
? (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<img alt="search menu" src={SearchMenu.src} width={32} />
|
||||
<Image alt="search menu" src={SearchMenu} width={32} />
|
||||
<span className="text-text-secondary system-sm-regular">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
|
||||
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import FileThumb from '../index'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('FileThumb Component', () => {
|
||||
const mockImageFile = {
|
||||
name: 'test-image.jpg',
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('NumberInputField', () => {
|
||||
|
||||
it('should render current number value', () => {
|
||||
render(<NumberInputField label="Count" />)
|
||||
expect(screen.getByRole('textbox')).toHaveValue('2')
|
||||
expect(screen.getByDisplayValue('2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update value when users click increment', () => {
|
||||
|
||||
@@ -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('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByText('Age')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('InputNumber Component', () => {
|
||||
|
||||
it('renders input with default values', () => {
|
||||
render(<InputNumber {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('InputNumber Component', () => {
|
||||
it('handles direct input changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: '42' } })
|
||||
expect(onChange).toHaveBeenCalledWith(42)
|
||||
@@ -69,25 +69,38 @@ describe('InputNumber Component', () => {
|
||||
it('handles empty input', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={1} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('does not call onChange when input is not parseable', () => {
|
||||
it('does not call onChange when parsed value is NaN', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
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('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: '11' } })
|
||||
|
||||
@@ -128,7 +141,7 @@ describe('InputNumber Component', () => {
|
||||
it('disables controls when disabled prop is true', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} disabled />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
@@ -198,16 +211,6 @@ describe('InputNumber Component', () => {
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses fallback step guard when step is any', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={10} max={10} step="any" />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
await user.click(incrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents decrement below min with custom amount', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
@@ -241,7 +244,7 @@ describe('InputNumber Component', () => {
|
||||
it('validates input against max constraint', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} max={10} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: '15' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
@@ -250,7 +253,7 @@ describe('InputNumber Component', () => {
|
||||
it('validates input against min constraint', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={5} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: '2' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
@@ -259,7 +262,7 @@ describe('InputNumber Component', () => {
|
||||
it('accepts input within min and max constraints', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={0} max={100} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: '50' } })
|
||||
expect(onChange).toHaveBeenCalledWith(50)
|
||||
@@ -293,25 +296,6 @@ describe('InputNumber Component', () => {
|
||||
expect(wrapper).toHaveClass(wrapClassName)
|
||||
})
|
||||
|
||||
it('applies wrapperClassName to outer div for Input compatibility', () => {
|
||||
const onChange = vi.fn()
|
||||
const wrapperClassName = 'custom-input-wrapper'
|
||||
render(<InputNumber onChange={onChange} wrapperClassName={wrapperClassName} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const wrapper = screen.getByTestId('input-number-wrapper')
|
||||
|
||||
expect(input).not.toHaveAttribute('wrapperClassName')
|
||||
expect(wrapper).toHaveClass(wrapperClassName)
|
||||
})
|
||||
|
||||
it('applies styleCss to the input element', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} styleCss={{ color: 'red' }} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveStyle({ color: 'rgb(255, 0, 0)' })
|
||||
})
|
||||
|
||||
it('applies controlWrapClassName to control buttons container', () => {
|
||||
const onChange = vi.fn()
|
||||
const controlWrapClassName = 'custom-control-wrap'
|
||||
@@ -343,7 +327,7 @@ describe('InputNumber Component', () => {
|
||||
it('handles zero as a valid input', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={-5} max={5} value={1} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(input, { target: { value: '0' } })
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import type { NumberFieldRoot as BaseNumberFieldRoot } from '@base-ui/react/number-field'
|
||||
import type { CSSProperties, FC, InputHTMLAttributes } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import type { InputProps } from '../input'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
NumberFieldUnit,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Input from '../input'
|
||||
|
||||
type InputNumberInputProps = Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
'defaultValue' | 'max' | 'min' | 'onChange' | 'size' | 'type' | 'value'
|
||||
>
|
||||
|
||||
export type InputNumberProps = InputNumberInputProps & {
|
||||
export type InputNumberProps = {
|
||||
unit?: string
|
||||
value?: number
|
||||
onChange: (value: number) => void
|
||||
@@ -25,69 +12,19 @@ export type InputNumberProps = InputNumberInputProps & {
|
||||
size?: 'regular' | 'large'
|
||||
max?: number
|
||||
min?: number
|
||||
step?: number | 'any'
|
||||
defaultValue?: number
|
||||
disabled?: boolean
|
||||
wrapClassName?: string
|
||||
wrapperClassName?: string
|
||||
styleCss?: CSSProperties
|
||||
controlWrapClassName?: string
|
||||
controlClassName?: string
|
||||
type?: 'number'
|
||||
}
|
||||
|
||||
const STEPPER_REASONS = new Set<BaseNumberFieldRoot.ChangeEventDetails['reason']>([
|
||||
'increment-press',
|
||||
'decrement-press',
|
||||
])
|
||||
|
||||
const isValueWithinBounds = (value: number, min?: number, max?: number) => {
|
||||
if (typeof min === 'number' && value < min)
|
||||
return false
|
||||
|
||||
if (typeof max === 'number' && value > max)
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const resolveStep = (amount?: number, step?: InputNumberProps['step']) => (
|
||||
amount ?? (step === 'any' || typeof step === 'number' ? step : undefined) ?? 1
|
||||
)
|
||||
|
||||
const exceedsStepBounds = ({
|
||||
value,
|
||||
reason,
|
||||
stepAmount,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
value?: number
|
||||
reason: BaseNumberFieldRoot.ChangeEventDetails['reason']
|
||||
stepAmount: number
|
||||
min?: number
|
||||
max?: number
|
||||
}) => {
|
||||
if (typeof value !== 'number')
|
||||
return false
|
||||
|
||||
if (reason === 'increment-press' && typeof max === 'number')
|
||||
return value + stepAmount > max
|
||||
|
||||
if (reason === 'decrement-press' && typeof min === 'number')
|
||||
return value - stepAmount < min
|
||||
|
||||
return false
|
||||
}
|
||||
} & Omit<InputProps, 'value' | 'onChange' | 'size' | 'min' | 'max' | 'defaultValue'>
|
||||
|
||||
export const InputNumber: FC<InputNumberProps> = (props) => {
|
||||
const {
|
||||
unit,
|
||||
className,
|
||||
wrapperClassName,
|
||||
styleCss,
|
||||
onChange,
|
||||
amount,
|
||||
amount = 1,
|
||||
value,
|
||||
size = 'regular',
|
||||
max,
|
||||
@@ -97,97 +34,96 @@ export const InputNumber: FC<InputNumberProps> = (props) => {
|
||||
controlWrapClassName,
|
||||
controlClassName,
|
||||
disabled,
|
||||
step,
|
||||
id,
|
||||
name,
|
||||
readOnly,
|
||||
required,
|
||||
type: _type,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const resolvedStep = resolveStep(amount, step)
|
||||
const stepAmount = typeof resolvedStep === 'number' ? resolvedStep : 1
|
||||
const isValidValue = useCallback((v: number) => {
|
||||
if (typeof max === 'number' && v > max)
|
||||
return false
|
||||
return !(typeof min === 'number' && v < min)
|
||||
}, [max, min])
|
||||
|
||||
const handleValueChange = useCallback((
|
||||
nextValue: number | null,
|
||||
eventDetails: BaseNumberFieldRoot.ChangeEventDetails,
|
||||
) => {
|
||||
if (value === undefined && STEPPER_REASONS.has(eventDetails.reason)) {
|
||||
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 (nextValue === null) {
|
||||
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
|
||||
}
|
||||
|
||||
if (exceedsStepBounds({
|
||||
value,
|
||||
reason: eventDetails.reason,
|
||||
stepAmount,
|
||||
min,
|
||||
max,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValueWithinBounds(nextValue, min, max))
|
||||
const parsed = Number(e.target.value)
|
||||
if (Number.isNaN(parsed))
|
||||
return
|
||||
|
||||
onChange(nextValue)
|
||||
}, [defaultValue, max, min, onChange, stepAmount, value])
|
||||
if (!isValidValue(parsed))
|
||||
return
|
||||
onChange(parsed)
|
||||
}, [isValidValue, onChange])
|
||||
|
||||
return (
|
||||
<div data-testid="input-number-wrapper" className={cn('flex w-full min-w-0', wrapClassName, wrapperClassName)}>
|
||||
<NumberField
|
||||
className="min-w-0 grow"
|
||||
value={value ?? null}
|
||||
min={min}
|
||||
<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}
|
||||
step={resolvedStep}
|
||||
min={min}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
id={id}
|
||||
name={name}
|
||||
allowOutOfRange
|
||||
onValueChange={handleValueChange}
|
||||
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)}
|
||||
>
|
||||
<NumberFieldGroup size={size}>
|
||||
<NumberFieldInput
|
||||
{...rest}
|
||||
size={size}
|
||||
style={styleCss}
|
||||
className={className}
|
||||
/>
|
||||
{unit && (
|
||||
<NumberFieldUnit size={size}>
|
||||
{unit}
|
||||
</NumberFieldUnit>
|
||||
)}
|
||||
<NumberFieldControls
|
||||
data-testid="input-number-controls"
|
||||
className={controlWrapClassName}
|
||||
>
|
||||
<NumberFieldIncrement
|
||||
aria-label="increment"
|
||||
size={size}
|
||||
className={controlClassName}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-arrow-up-s-line size-3" />
|
||||
</NumberFieldIncrement>
|
||||
<NumberFieldDecrement
|
||||
aria-label="decrement"
|
||||
size={size}
|
||||
className={controlClassName}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-arrow-down-s-line size-3" />
|
||||
</NumberFieldDecrement>
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
<button
|
||||
type="button"
|
||||
onClick={inc}
|
||||
disabled={disabled}
|
||||
aria-label="increment"
|
||||
className={cn(size === 'regular' ? 'pt-1' : 'pt-1.5', 'px-1.5 hover:bg-components-input-bg-hover', disabled && 'cursor-not-allowed hover:bg-transparent', controlClassName)}
|
||||
>
|
||||
<span className="i-ri-arrow-up-s-line size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dec}
|
||||
disabled={disabled}
|
||||
aria-label="decrement"
|
||||
className={cn(size === 'regular' ? 'pb-1' : 'pb-1.5', 'px-1.5 hover:bg-components-input-bg-hover', disabled && 'cursor-not-allowed hover:bg-transparent', controlClassName)}
|
||||
>
|
||||
<span className="i-ri-arrow-down-s-line size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import WithIconCardItem from './with-icon-card-item'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('WithIconCardItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 & {
|
||||
@@ -10,13 +11,18 @@ 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)}>
|
||||
<img
|
||||
{/*
|
||||
* unoptimized to "url parameter is not allowed" for external domains despite correct remotePatterns configuration.
|
||||
* https://github.com/vercel/next.js/issues/88873
|
||||
*/}
|
||||
<Image
|
||||
src={icon}
|
||||
className="!border-none object-contain"
|
||||
alt={iconAlt ?? ''}
|
||||
aria-hidden={iconAlt ? undefined : true}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
/>
|
||||
<div className="min-w-0 grow overflow-hidden text-text-secondary system-sm-medium [&_p]:!m-0 [&_p]:block [&_p]:w-full [&_p]:overflow-hidden [&_p]:text-ellipsis [&_p]:whitespace-nowrap">
|
||||
{children}
|
||||
|
||||
@@ -7,6 +7,10 @@ import { MarkdownWithDirective } from './index'
|
||||
|
||||
const FOUR_COLON_RE = /:{4}/
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
|
||||
}))
|
||||
|
||||
function expectDecorativeIcon(container: HTMLElement, src: string) {
|
||||
const icon = container.querySelector('img')
|
||||
expect(icon).toBeInTheDocument()
|
||||
|
||||
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CredentialSelector from '../index'
|
||||
|
||||
// Mock CredentialIcon since it's likely a complex component.
|
||||
// Mock CredentialIcon since it's likely a complex component or uses next/image
|
||||
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
|
||||
CredentialIcon: ({ name }: { name: string }) => <div data-testid="credential-icon">{name}</div>,
|
||||
}))
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('ParamItem', () => {
|
||||
it('should render InputNumber and Slider', () => {
|
||||
render(<ParamItem {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('spinbutton')).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('textbox')).toBeDisabled()
|
||||
expect(screen.getByRole('spinbutton')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable Slider when enable is false', () => {
|
||||
@@ -104,7 +104,7 @@ describe('ParamItem', () => {
|
||||
}
|
||||
|
||||
render(<StatefulParamItem />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '0.8')
|
||||
@@ -166,10 +166,14 @@ describe('ParamItem', () => {
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '10')
|
||||
})
|
||||
|
||||
it('should expose default minimum of 0 when min is not provided', () => {
|
||||
it('should use default step of 0.1 and min of 0 when not provided', () => {
|
||||
render(<ParamItem {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('ScoreThresholdItem', () => {
|
||||
it('should render InputNumber and Slider', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('spinbutton')).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('textbox')).toBeDisabled()
|
||||
expect(screen.getByRole('spinbutton')).toBeDisabled()
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
@@ -70,19 +70,23 @@ describe('ScoreThresholdItem', () => {
|
||||
describe('Value Clamping', () => {
|
||||
it('should clamp values to minimum of 0', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('min', '0')
|
||||
})
|
||||
|
||||
it('should clamp values to maximum of 1', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('max', '1')
|
||||
})
|
||||
|
||||
it('should use step of 0.01', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} value={0.5} />)
|
||||
expect(screen.getByRole('textbox')).toHaveValue('0.5')
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('step', '0.01')
|
||||
})
|
||||
|
||||
it('should call onChange with rounded value when input changes', async () => {
|
||||
@@ -103,7 +107,7 @@ describe('ScoreThresholdItem', () => {
|
||||
}
|
||||
|
||||
render(<StatefulScoreThresholdItem />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '0.55')
|
||||
@@ -134,8 +138,8 @@ describe('ScoreThresholdItem', () => {
|
||||
|
||||
it('should clamp to max=1 when value exceeds maximum', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} value={1.5} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('1')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('TopKItem', () => {
|
||||
it('should render InputNumber and Slider', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('spinbutton')).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('textbox')).toBeDisabled()
|
||||
expect(screen.getByRole('spinbutton')).toBeDisabled()
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
@@ -59,20 +59,23 @@ describe('TopKItem', () => {
|
||||
describe('Value Limits', () => {
|
||||
it('should use step of 1', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('2')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('step', '1')
|
||||
})
|
||||
|
||||
it('should use minimum of 1', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('min', '1')
|
||||
})
|
||||
|
||||
it('should use maximum from env (10)', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('max', '10')
|
||||
})
|
||||
|
||||
it('should render slider with max >= 5 so no scaling is applied', () => {
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
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'
|
||||
|
||||
describe('NumberField wrapper', () => {
|
||||
describe('Exports', () => {
|
||||
it('should map NumberField to the matching base primitive root', () => {
|
||||
expect(NumberField).toBe(BaseNumberField.Root)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variants', () => {
|
||||
it('should apply regular variant classes and forward className to group and input', () => {
|
||||
render(
|
||||
<NumberField defaultValue={12}>
|
||||
<NumberFieldGroup size="regular" className="custom-group" data-testid="group">
|
||||
<NumberFieldInput
|
||||
aria-label="Regular amount"
|
||||
placeholder="Regular placeholder"
|
||||
size="regular"
|
||||
className="custom-input"
|
||||
/>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
|
||||
const group = screen.getByTestId('group')
|
||||
const input = screen.getByRole('textbox', { name: 'Regular amount' })
|
||||
|
||||
expect(group).toHaveClass('radius-md')
|
||||
expect(group).toHaveClass('custom-group')
|
||||
expect(input).toHaveAttribute('placeholder', 'Regular placeholder')
|
||||
expect(input).toHaveClass('px-3')
|
||||
expect(input).toHaveClass('py-[7px]')
|
||||
expect(input).toHaveClass('custom-input')
|
||||
})
|
||||
|
||||
it('should apply large variant classes to grouped parts when large size is provided', () => {
|
||||
render(
|
||||
<NumberField defaultValue={24}>
|
||||
<NumberFieldGroup size="large" data-testid="group">
|
||||
<NumberFieldInput aria-label="Large amount" size="large" />
|
||||
<NumberFieldUnit size="large">ms</NumberFieldUnit>
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement aria-label="Increment amount" size="large" />
|
||||
<NumberFieldDecrement aria-label="Decrement amount" size="large" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
|
||||
const group = screen.getByTestId('group')
|
||||
const input = screen.getByRole('textbox', { name: 'Large amount' })
|
||||
const unit = screen.getByText('ms')
|
||||
const increment = screen.getByRole('button', { name: 'Increment amount' })
|
||||
const decrement = screen.getByRole('button', { name: 'Decrement amount' })
|
||||
|
||||
expect(group).toHaveClass('radius-lg')
|
||||
expect(input).toHaveClass('px-4')
|
||||
expect(input).toHaveClass('py-2')
|
||||
expect(unit).toHaveClass('flex')
|
||||
expect(unit).toHaveClass('items-center')
|
||||
expect(unit).toHaveClass('pr-2.5')
|
||||
expect(increment).toHaveClass('pt-1.5')
|
||||
expect(decrement).toHaveClass('pb-1.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Passthrough props', () => {
|
||||
it('should forward passthrough props and custom classes to controls and buttons', () => {
|
||||
render(
|
||||
<NumberField defaultValue={8}>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput aria-label="Amount" size="regular" />
|
||||
<NumberFieldControls className="custom-controls" data-testid="controls">
|
||||
<NumberFieldIncrement
|
||||
aria-label="Increment"
|
||||
size="regular"
|
||||
className="custom-increment"
|
||||
data-track-id="increment-track"
|
||||
/>
|
||||
<NumberFieldDecrement
|
||||
aria-label="Decrement"
|
||||
size="regular"
|
||||
className="custom-decrement"
|
||||
data-track-id="decrement-track"
|
||||
/>
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
|
||||
const controls = screen.getByTestId('controls')
|
||||
const increment = screen.getByRole('button', { name: 'Increment' })
|
||||
const decrement = screen.getByRole('button', { name: 'Decrement' })
|
||||
|
||||
expect(controls).toHaveClass('border-l')
|
||||
expect(controls).toHaveClass('custom-controls')
|
||||
expect(increment).toHaveClass('custom-increment')
|
||||
expect(increment).toHaveAttribute('data-track-id', 'increment-track')
|
||||
expect(decrement).toHaveClass('custom-decrement')
|
||||
expect(decrement).toHaveAttribute('data-track-id', 'decrement-track')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,211 +0,0 @@
|
||||
'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 { cn } from '@/utils/classnames'
|
||||
|
||||
export const NumberField = 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 motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
regular: 'radius-md',
|
||||
large: 'radius-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
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',
|
||||
)
|
||||
|
||||
type NumberFieldControlsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function NumberFieldControls({
|
||||
className,
|
||||
...props
|
||||
}: NumberFieldControlsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(numberFieldControlsVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const numberFieldControlButtonVariants = cva(
|
||||
[
|
||||
'flex items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors',
|
||||
'hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover',
|
||||
'disabled:cursor-not-allowed disabled:hover:bg-transparent',
|
||||
'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent',
|
||||
'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent',
|
||||
'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'
|
||||
>
|
||||
|
||||
type NumberFieldButtonProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Increment> & NumberFieldButtonVariantProps
|
||||
|
||||
export function NumberFieldIncrement({
|
||||
className,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldButtonProps) {
|
||||
return (
|
||||
<BaseNumberField.Increment
|
||||
className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NumberFieldDecrement({
|
||||
className,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldButtonProps) {
|
||||
return (
|
||||
<BaseNumberField.Decrement
|
||||
className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,179 +1,496 @@
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import type { Mock } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as React from 'react'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config'
|
||||
import { contactSalesUrl } 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(),
|
||||
}))
|
||||
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(),
|
||||
|
||||
// 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('@/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 setShowPricingModal = vi.fn()
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
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>)
|
||||
// Default mock setup
|
||||
;(useModalContext as Mock).mockReturnValue({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
})
|
||||
})
|
||||
|
||||
// Integration coverage for the page and its child custom brand section.
|
||||
describe('Rendering', () => {
|
||||
it('should render the custom brand configuration by default', () => {
|
||||
render(<CustomPage />)
|
||||
// Helper function to render with different provider contexts
|
||||
const renderWithContext = (overrides = {}) => {
|
||||
;(useProviderContext as Mock).mockReturnValue(
|
||||
createMockProviderContextValue(overrides),
|
||||
)
|
||||
return render(<CustomPage />)
|
||||
}
|
||||
|
||||
expect(screen.getByText('custom.webapp.removeBrand')).toBeInTheDocument()
|
||||
expect(screen.getByText('Chatflow App')).toBeInTheDocument()
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext()
|
||||
|
||||
// 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.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 show the upgrade banner and open pricing modal for sandbox billing', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
it('should not show contact section when plan is sandbox', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
planType: Plan.sandbox,
|
||||
}))
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
render(<CustomPage />)
|
||||
// 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
|
||||
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).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 the contact link for professional workspaces', () => {
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
it('should show only contact section for professional plan, not billing tip', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
planType: Plan.professional,
|
||||
}))
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
render(<CustomPage />)
|
||||
|
||||
const contactLink = screen.getByText('custom.customize.contactUs').closest('a')
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(contactLink).toHaveAttribute('href', contactSalesUrl)
|
||||
expect(contactLink).toHaveAttribute('target', '_blank')
|
||||
expect(contactLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('should show the contact link for team workspaces', () => {
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
enableBilling: true,
|
||||
planType: Plan.team,
|
||||
}))
|
||||
|
||||
render(<CustomPage />)
|
||||
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide both billing sections when billing is disabled', () => {
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
it('should show only contact section for team plan, not billing tip', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.team },
|
||||
})
|
||||
|
||||
// Assert
|
||||
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({
|
||||
enableBilling: false,
|
||||
planType: Plan.sandbox,
|
||||
}))
|
||||
|
||||
render(<CustomPage />)
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,158 +1,147 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import useWebAppBrand from '../hooks/use-web-app-brand'
|
||||
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 CustomWebAppBrand from '../index'
|
||||
|
||||
vi.mock('../hooks/use-web-app-brand', () => ({
|
||||
default: vi.fn(),
|
||||
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(),
|
||||
}))
|
||||
|
||||
const mockUseWebAppBrand = vi.mocked(useWebAppBrand)
|
||||
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 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 defaultPlanUsage = {
|
||||
buildApps: 0,
|
||||
teamMembers: 0,
|
||||
annotatedResponse: 0,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: 0,
|
||||
triggerEvents: 0,
|
||||
vectorSpace: 0,
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
// 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()
|
||||
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>)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('should hide the restore action when uploads are disabled or no logo is configured', () => {
|
||||
renderComponent({
|
||||
uploadDisabled: true,
|
||||
webappLogo: '',
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'custom.restore' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'custom.upload' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show the uploading button and failure message when upload state requires it', () => {
|
||||
renderComponent({
|
||||
uploading: true,
|
||||
uploadProgress: -1,
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
const { container } = renderComponent()
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(fileInput).toBeDisabled()
|
||||
})
|
||||
|
||||
// User interactions delegated to the hook callbacks.
|
||||
describe('Interactions', () => {
|
||||
it('should delegate switch changes to the hook handler', () => {
|
||||
const { hookState } = renderComponent()
|
||||
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>)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
renderComponent()
|
||||
const switchInput = screen.getByRole('switch')
|
||||
fireEvent.click(switchInput)
|
||||
|
||||
expect(hookState.handleSwitch).toHaveBeenCalledWith(true)
|
||||
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' })
|
||||
})
|
||||
|
||||
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' })
|
||||
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] } })
|
||||
|
||||
Object.defineProperty(fileInput, 'value', {
|
||||
configurable: true,
|
||||
value: 'stale-selection',
|
||||
writable: true,
|
||||
})
|
||||
await waitFor(() => expect(mockImageUpload).toHaveBeenCalled())
|
||||
await waitFor(() => screen.getByRole('button', { name: 'custom.apply' }))
|
||||
|
||||
fireEvent.click(fileInput)
|
||||
fireEvent.change(fileInput, {
|
||||
target: { files: [file] },
|
||||
})
|
||||
const cancelButton = screen.getByRole('button', { name: 'common.operation.cancel' })
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
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)
|
||||
})
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'custom.apply' })).toBeNull())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
@@ -1,64 +0,0 @@
|
||||
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
|
||||
@@ -1,385 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { updateCurrentWorkspace } from '@/service/common'
|
||||
|
||||
const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024
|
||||
const CUSTOM_CONFIG_URL = '/workspaces/custom-config'
|
||||
const WEB_APP_LOGO_UPLOAD_URL = '/workspaces/custom-config/webapp-logo/upload'
|
||||
|
||||
const useWebAppBrand = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const {
|
||||
currentWorkspace,
|
||||
mutateCurrentWorkspace,
|
||||
isCurrentWorkspaceManager,
|
||||
} = useAppContext()
|
||||
const [fileId, setFileId] = useState('')
|
||||
const [imgKey, setImgKey] = useState(() => Date.now())
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const isSandbox = enableBilling && plan.type === Plan.sandbox
|
||||
const uploading = uploadProgress > 0 && uploadProgress < 100
|
||||
const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || ''
|
||||
const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand
|
||||
const uploadDisabled = isSandbox || webappBrandRemoved || !isCurrentWorkspaceManager
|
||||
const workspaceLogo = systemFeatures.branding.enabled ? systemFeatures.branding.workspace_logo : ''
|
||||
|
||||
const persistWorkspaceBrand = async (body: Record<string, unknown>) => {
|
||||
await updateCurrentWorkspace({
|
||||
url: CUSTOM_CONFIG_URL,
|
||||
body,
|
||||
})
|
||||
mutateCurrentWorkspace()
|
||||
}
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
if (!file)
|
||||
return
|
||||
|
||||
if (file.size > MAX_LOGO_FILE_SIZE) {
|
||||
notify({ type: 'error', message: t('imageUploader.uploadFromComputerLimit', { ns: 'common', size: 5 }) })
|
||||
return
|
||||
}
|
||||
|
||||
imageUpload({
|
||||
file,
|
||||
onProgressCallback: setUploadProgress,
|
||||
onSuccessCallback: (res) => {
|
||||
setUploadProgress(100)
|
||||
setFileId(res.id)
|
||||
},
|
||||
onErrorCallback: (error) => {
|
||||
const errorMessage = getImageUploadErrorMessage(
|
||||
error,
|
||||
t('imageUploader.uploadFromComputerUploadError', { ns: 'common' }),
|
||||
t,
|
||||
)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
setUploadProgress(-1)
|
||||
},
|
||||
}, false, WEB_APP_LOGO_UPLOAD_URL)
|
||||
}
|
||||
|
||||
const handleApply = async () => {
|
||||
await persistWorkspaceBrand({
|
||||
remove_webapp_brand: webappBrandRemoved,
|
||||
replace_webapp_logo: fileId,
|
||||
})
|
||||
setFileId('')
|
||||
setImgKey(Date.now())
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
await persistWorkspaceBrand({
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleSwitch = async (checked: boolean) => {
|
||||
await persistWorkspaceBrand({
|
||||
remove_webapp_brand: checked,
|
||||
})
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setFileId('')
|
||||
setUploadProgress(0)
|
||||
}
|
||||
|
||||
return {
|
||||
fileId,
|
||||
imgKey,
|
||||
uploadProgress,
|
||||
uploading,
|
||||
webappLogo,
|
||||
webappBrandRemoved,
|
||||
uploadDisabled,
|
||||
workspaceLogo,
|
||||
isSandbox,
|
||||
isCurrentWorkspaceManager,
|
||||
handleApply,
|
||||
handleCancel,
|
||||
handleChange,
|
||||
handleRestore,
|
||||
handleSwitch,
|
||||
}
|
||||
}
|
||||
|
||||
export default useWebAppBrand
|
||||
@@ -1,33 +1,118 @@
|
||||
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 {
|
||||
fileId,
|
||||
imgKey,
|
||||
uploadProgress,
|
||||
uploading,
|
||||
webappLogo,
|
||||
webappBrandRemoved,
|
||||
uploadDisabled,
|
||||
workspaceLogo,
|
||||
currentWorkspace,
|
||||
mutateCurrentWorkspace,
|
||||
isCurrentWorkspaceManager,
|
||||
isSandbox,
|
||||
handleApply,
|
||||
handleCancel,
|
||||
handleChange,
|
||||
handleRestore,
|
||||
handleSwitch,
|
||||
} = useWebAppBrand()
|
||||
} = 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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
@@ -64,7 +149,7 @@ const CustomWebAppBrand = () => {
|
||||
className="relative mr-2"
|
||||
disabled={uploadDisabled}
|
||||
>
|
||||
<span className="i-ri-image-add-line mr-1 h-4 w-4" />
|
||||
<RiImageAddLine className="mr-1 h-4 w-4" />
|
||||
{
|
||||
(webappLogo || fileId)
|
||||
? t('change', { ns: 'custom' })
|
||||
@@ -87,7 +172,7 @@ const CustomWebAppBrand = () => {
|
||||
className="relative mr-2"
|
||||
disabled={true}
|
||||
>
|
||||
<span className="i-ri-loader-2-line mr-1 h-4 w-4 animate-spin" />
|
||||
<RiLoader2Line className="mr-1 h-4 w-4 animate-spin" />
|
||||
{t('uploading', { ns: 'custom' })}
|
||||
</Button>
|
||||
)
|
||||
@@ -123,18 +208,118 @@ const CustomWebAppBrand = () => {
|
||||
<Divider bgStyle="gradient" className="grow" />
|
||||
</div>
|
||||
<div className="relative mb-2 flex items-center gap-3">
|
||||
<ChatPreviewCard
|
||||
webappBrandRemoved={webappBrandRemoved}
|
||||
workspaceLogo={workspaceLogo}
|
||||
webappLogo={webappLogo}
|
||||
imgKey={imgKey}
|
||||
/>
|
||||
<WorkflowPreviewCard
|
||||
webappBrandRemoved={webappBrandRemoved}
|
||||
workspaceLogo={workspaceLogo}
|
||||
webappLogo={webappLogo}
|
||||
imgKey={imgKey}
|
||||
/>
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,13 @@ 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 }) => (
|
||||
@@ -43,7 +50,7 @@ describe('RetrievalMethodInfo', () => {
|
||||
})
|
||||
|
||||
it('should render correctly with full config', () => {
|
||||
const { container } = render(<RetrievalMethodInfo value={defaultConfig} />)
|
||||
render(<RetrievalMethodInfo value={defaultConfig} />)
|
||||
|
||||
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
|
||||
|
||||
@@ -52,7 +59,7 @@ describe('RetrievalMethodInfo', () => {
|
||||
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
|
||||
|
||||
// Check Icon
|
||||
const icon = container.querySelector('img')
|
||||
const icon = screen.getByTestId('method-icon')
|
||||
expect(icon).toHaveAttribute('src', 'vector-icon.png')
|
||||
|
||||
// Check Config Details
|
||||
@@ -80,18 +87,18 @@ describe('RetrievalMethodInfo', () => {
|
||||
it('should handle different retrieval methods', () => {
|
||||
// Test Hybrid
|
||||
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
|
||||
const { container, unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
|
||||
const { unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
|
||||
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'hybrid-icon.png')
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png')
|
||||
|
||||
unmount()
|
||||
|
||||
// Test FullText
|
||||
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
|
||||
const { container: fullTextContainer } = render(<RetrievalMethodInfo value={fullTextConfig} />)
|
||||
render(<RetrievalMethodInfo value={fullTextConfig} />)
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
|
||||
expect(fullTextContainer.querySelector('img')).toHaveAttribute('src', 'fulltext-icon.png')
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png')
|
||||
})
|
||||
|
||||
describe('getIcon utility', () => {
|
||||
@@ -125,17 +132,17 @@ describe('RetrievalMethodInfo', () => {
|
||||
|
||||
it('should render correctly with invertedIndex search method', () => {
|
||||
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
|
||||
const { container } = render(<RetrievalMethodInfo value={invertedIndexConfig} />)
|
||||
render(<RetrievalMethodInfo value={invertedIndexConfig} />)
|
||||
|
||||
// invertedIndex uses vector icon
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
|
||||
})
|
||||
|
||||
it('should render correctly with keywordSearch search method', () => {
|
||||
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
|
||||
const { container } = render(<RetrievalMethodInfo value={keywordSearchConfig} />)
|
||||
render(<RetrievalMethodInfo value={keywordSearchConfig} />)
|
||||
|
||||
// keywordSearch uses vector icon
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'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'
|
||||
@@ -27,7 +28,7 @@ const EconomicalRetrievalMethodConfig: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const type = value.search_method
|
||||
const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" />
|
||||
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" />
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<RadioCard
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'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'
|
||||
@@ -126,7 +127,7 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<span className="mr-0.5 text-text-secondary system-sm-semibold">{t('modelProvider.rerankModel.key', { ns: 'common' })}</span>
|
||||
<span className="system-sm-semibold mr-0.5 text-text-secondary">{t('modelProvider.rerankModel.key', { ns: 'common' })}</span>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className="w-[200px]">{t('modelProvider.rerankModel.tip', { ns: 'common' })}</div>
|
||||
@@ -156,7 +157,7 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
<div className="p-1">
|
||||
<AlertTriangle className="size-4 text-text-warning-secondary" />
|
||||
</div>
|
||||
<span className="text-text-primary system-xs-medium">
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
{t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -214,11 +215,11 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
isChosen={value.reranking_mode === option.value}
|
||||
onChosen={() => handleChangeRerankMode(option.value)}
|
||||
icon={(
|
||||
<img
|
||||
<Image
|
||||
src={
|
||||
option.value === RerankingModeEnum.WeightedScore
|
||||
? ProgressIndicator.src
|
||||
: Reranking.src
|
||||
? ProgressIndicator
|
||||
: Reranking
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
@@ -280,7 +281,7 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
<div className="p-1">
|
||||
<AlertTriangle className="size-4 text-text-warning-secondary" />
|
||||
</div>
|
||||
<span className="text-text-primary system-xs-medium">
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
{t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,14 @@ 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', () => ({
|
||||
@@ -971,9 +979,9 @@ describe('RuleDetail', () => {
|
||||
})
|
||||
|
||||
it('should render correct icon for indexing type', () => {
|
||||
const { container } = render(<RuleDetail indexingType="high_quality" />)
|
||||
render(<RuleDetail indexingType="high_quality" />)
|
||||
|
||||
const images = container.querySelectorAll('img')
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
expect(images.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -118,12 +119,12 @@ const RuleDetail: FC<RuleDetailProps> = ({ sourceData, indexingType, retrievalMe
|
||||
<FieldInfo
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={indexModeLabel}
|
||||
valueIcon={<img className="size-4" src={indexMethodIconSrc} alt="" />}
|
||||
valueIcon={<Image className="size-4" src={indexMethodIconSrc} alt="" />}
|
||||
/>
|
||||
<FieldInfo
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
displayedValue={retrievalLabel}
|
||||
valueIcon={<img className="size-4" src={retrievalIconSrc} alt="" />}
|
||||
valueIcon={<Image className="size-4" src={retrievalIconSrc} alt="" />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,12 +5,12 @@ import Research from './assets/research-mod.svg'
|
||||
import Selection from './assets/selection-mod.svg'
|
||||
|
||||
export const indexMethodIcon = {
|
||||
high_quality: GoldIcon.src,
|
||||
economical: Piggybank.src,
|
||||
high_quality: GoldIcon,
|
||||
economical: Piggybank,
|
||||
}
|
||||
|
||||
export const retrievalIcon = {
|
||||
vector: Selection.src,
|
||||
fullText: Research.src,
|
||||
hybrid: PatternRecognition.src,
|
||||
vector: Selection,
|
||||
fullText: Research,
|
||||
hybrid: PatternRecognition,
|
||||
}
|
||||
|
||||
@@ -47,19 +47,19 @@ describe('MaxLengthInput', () => {
|
||||
|
||||
it('should render number input', () => {
|
||||
render(<MaxLengthInput onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept value prop', () => {
|
||||
render(<MaxLengthInput value={500} onChange={vi.fn()} />)
|
||||
expect(screen.getByRole('textbox')).toHaveValue('500')
|
||||
expect(screen.getByDisplayValue('500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have min of 1', () => {
|
||||
render(<MaxLengthInput onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveAttribute('min', '1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,18 +75,18 @@ describe('OverlapInput', () => {
|
||||
|
||||
it('should render number input', () => {
|
||||
render(<OverlapInput onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept value prop', () => {
|
||||
render(<OverlapInput value={50} onChange={vi.fn()} />)
|
||||
expect(screen.getByRole('textbox')).toHaveValue('50')
|
||||
expect(screen.getByDisplayValue('50')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have min of 1', () => {
|
||||
render(<OverlapInput onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveAttribute('min', '1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,13 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OptionCard, OptionCardHeader } from '../option-card'
|
||||
|
||||
// Override global next/image auto-mock: tests assert on rendered <img> elements
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, ...props }: { src?: string, alt?: string, width?: number, height?: number }) => (
|
||||
<img src={src} alt={alt} {...props} />
|
||||
),
|
||||
}))
|
||||
|
||||
describe('OptionCardHeader', () => {
|
||||
const defaultProps = {
|
||||
icon: <span data-testid="icon">icon</span>,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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'
|
||||
@@ -25,7 +26,7 @@ type TextLabelProps = {
|
||||
}
|
||||
|
||||
const TextLabel: FC<TextLabelProps> = ({ children }) => {
|
||||
return <label className="text-text-secondary system-sm-semibold">{children}</label>
|
||||
return <label className="system-sm-semibold text-text-secondary">{children}</label>
|
||||
}
|
||||
|
||||
type GeneralChunkingOptionsProps = {
|
||||
@@ -96,7 +97,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
|
||||
<OptionCard
|
||||
className="mb-2 bg-background-section"
|
||||
title={t('stepTwo.general', { ns: 'datasetCreation' })}
|
||||
icon={<img width={20} height={20} src={SettingCog.src} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
|
||||
icon={<Image width={20} height={20} src={SettingCog} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
|
||||
activeHeaderClassName="bg-dataset-option-card-blue-gradient"
|
||||
description={t('stepTwo.generalTip', { ns: 'datasetCreation' })}
|
||||
isActive={isActive}
|
||||
@@ -147,7 +148,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
|
||||
onClick={() => onRuleToggle(rule.id)}
|
||||
>
|
||||
<Checkbox checked={rule.enabled} />
|
||||
<label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
|
||||
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
|
||||
{getRuleName(rule.id)}
|
||||
</label>
|
||||
</div>
|
||||
@@ -182,7 +183,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
|
||||
checked={currentDocForm === ChunkingMode.qa}
|
||||
disabled={hasCurrentDatasetDocForm}
|
||||
/>
|
||||
<label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
|
||||
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
|
||||
{t('stepTwo.useQALanguage', { ns: 'datasetCreation' })}
|
||||
</label>
|
||||
</div>
|
||||
@@ -201,7 +202,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="text-text-primary system-xs-medium">
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
{t('stepTwo.QATip', { ns: 'datasetCreation' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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'
|
||||
@@ -69,7 +70,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
return (
|
||||
<>
|
||||
{/* Index Mode */}
|
||||
<div className="mb-1 text-text-secondary system-md-semibold">
|
||||
<div className="system-md-semibold mb-1 text-text-secondary">
|
||||
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -97,7 +98,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
</div>
|
||||
)}
|
||||
description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })}
|
||||
icon={<img src={indexMethodIcon.high_quality} alt="" />}
|
||||
icon={<Image src={indexMethodIcon.high_quality} alt="" />}
|
||||
isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED}
|
||||
disabled={hasSetIndexType}
|
||||
onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)}
|
||||
@@ -142,7 +143,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
className="h-full"
|
||||
title={t('stepTwo.economical', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
|
||||
icon={<img src={indexMethodIcon.economical} alt="" />}
|
||||
icon={<Image src={indexMethodIcon.economical} alt="" />}
|
||||
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
|
||||
disabled={hasSetIndexType || docForm !== ChunkingMode.text}
|
||||
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
|
||||
@@ -159,7 +160,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
<div className="p-1">
|
||||
<AlertTriangle className="size-4 text-text-warning-secondary" />
|
||||
</div>
|
||||
<span className="text-text-primary system-xs-medium">
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
{t('stepTwo.highQualityTip', { ns: 'datasetCreation' })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -167,7 +168,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
|
||||
{/* Economical index setting tip */}
|
||||
{hasSetIndexType && indexType === IndexingType.ECONOMICAL && (
|
||||
<div className="mt-2 text-text-tertiary system-xs-medium">
|
||||
<div className="system-xs-medium mt-2 text-text-tertiary">
|
||||
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
|
||||
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
|
||||
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
|
||||
@@ -178,7 +179,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
{/* Embedding model */}
|
||||
{indexType === IndexingType.QUALIFIED && (
|
||||
<div className="mt-5">
|
||||
<div className={cn('mb-1 text-text-secondary system-md-semibold', datasetId && 'flex items-center justify-between')}>
|
||||
<div className={cn('system-md-semibold mb-1 text-text-secondary', datasetId && 'flex items-center justify-between')}>
|
||||
{t('form.embeddingModel', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<ModelSelector
|
||||
@@ -189,7 +190,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
onSelect={onEmbeddingModelChange}
|
||||
/>
|
||||
{isModelAndRetrievalConfigDisabled && (
|
||||
<div className="mt-2 text-text-tertiary system-xs-medium">
|
||||
<div className="system-xs-medium mt-2 text-text-tertiary">
|
||||
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
|
||||
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
|
||||
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
|
||||
@@ -206,10 +207,10 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
{!isModelAndRetrievalConfigDisabled
|
||||
? (
|
||||
<div className="mb-1">
|
||||
<div className="mb-0.5 text-text-secondary system-md-semibold">
|
||||
<div className="system-md-semibold mb-0.5 text-text-secondary">
|
||||
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<div className="text-text-tertiary body-xs-regular">
|
||||
<div className="body-xs-regular text-text-tertiary">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -223,7 +224,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={cn('mb-0.5 text-text-secondary system-md-semibold', 'flex items-center justify-between')}>
|
||||
<div className={cn('system-md-semibold mb-0.5 text-text-secondary', 'flex items-center justify-between')}>
|
||||
<div>{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComponentProps, FC, ReactNode } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const TriangleArrow: FC<ComponentProps<'svg'>> = props => (
|
||||
@@ -22,7 +23,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 && <img src={effectImg} className="absolute left-0 top-0 h-full w-full" alt="" width={56} height={56} />}
|
||||
{isActive && effectImg && <Image 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}
|
||||
@@ -33,8 +34,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="text-text-secondary system-md-semibold">{title}</div>
|
||||
<div className="text-text-tertiary system-xs-regular">{description}</div>
|
||||
<div className="system-md-semibold text-text-secondary">{title}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
@@ -25,7 +26,7 @@ type TextLabelProps = {
|
||||
}
|
||||
|
||||
const TextLabel: FC<TextLabelProps> = ({ children }) => {
|
||||
return <label className="text-text-secondary system-sm-semibold">{children}</label>
|
||||
return <label className="system-sm-semibold text-text-secondary">{children}</label>
|
||||
}
|
||||
|
||||
type ParentChildOptionsProps = {
|
||||
@@ -117,7 +118,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
</div>
|
||||
<RadioCard
|
||||
className="mt-1"
|
||||
icon={<img src={Note.src} alt="" />}
|
||||
icon={<Image src={Note} alt="" />}
|
||||
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
|
||||
isChosen={parentChildConfig.chunkForContext === 'paragraph'}
|
||||
@@ -139,7 +140,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
/>
|
||||
<RadioCard
|
||||
className="mt-2"
|
||||
icon={<img src={FileList.src} alt="" />}
|
||||
icon={<Image src={FileList} alt="" />}
|
||||
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
|
||||
onChosen={() => onChunkForContextChange('full-doc')}
|
||||
@@ -185,7 +186,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
onClick={() => onRuleToggle(rule.id)}
|
||||
>
|
||||
<Checkbox checked={rule.enabled} />
|
||||
<label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
|
||||
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
|
||||
{getRuleName(rule.id)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,14 @@ 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 }) => (
|
||||
@@ -176,16 +184,16 @@ describe('RuleDetail', () => {
|
||||
})
|
||||
|
||||
it('should show high_quality icon for qualified indexing', () => {
|
||||
const { container } = render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
|
||||
const images = container.querySelectorAll('img')
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg')
|
||||
})
|
||||
|
||||
it('should show economical icon for economical indexing', () => {
|
||||
const { container } = render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />)
|
||||
render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />)
|
||||
|
||||
const images = container.querySelectorAll('img')
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
expect(images[0]).toHaveAttribute('src', '/icons/economical.svg')
|
||||
})
|
||||
})
|
||||
@@ -248,38 +256,38 @@ describe('RuleDetail', () => {
|
||||
})
|
||||
|
||||
it('should show vector icon for semantic search', () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<RuleDetail
|
||||
indexingType={IndexingType.QUALIFIED}
|
||||
retrievalMethod={RETRIEVE_METHOD.semantic}
|
||||
/>,
|
||||
)
|
||||
|
||||
const images = container.querySelectorAll('img')
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
expect(images[1]).toHaveAttribute('src', '/icons/vector.svg')
|
||||
})
|
||||
|
||||
it('should show fullText icon for full text search', () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<RuleDetail
|
||||
indexingType={IndexingType.QUALIFIED}
|
||||
retrievalMethod={RETRIEVE_METHOD.fullText}
|
||||
/>,
|
||||
)
|
||||
|
||||
const images = container.querySelectorAll('img')
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg')
|
||||
})
|
||||
|
||||
it('should show hybrid icon for hybrid search', () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<RuleDetail
|
||||
indexingType={IndexingType.QUALIFIED}
|
||||
retrievalMethod={RETRIEVE_METHOD.hybrid}
|
||||
/>,
|
||||
)
|
||||
|
||||
const images = container.querySelectorAll('img')
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg')
|
||||
})
|
||||
})
|
||||
@@ -300,9 +308,9 @@ describe('RuleDetail', () => {
|
||||
})
|
||||
|
||||
it('should handle undefined retrievalMethod with defined indexingType', () => {
|
||||
const { container } = render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
|
||||
const images = container.querySelectorAll('img')
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
// When retrievalMethod is undefined, vector icon is used as default
|
||||
expect(images[1]).toHaveAttribute('src', '/icons/vector.svg')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -49,7 +50,7 @@ const RuleDetail = ({
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
|
||||
valueIcon={(
|
||||
<img
|
||||
<Image
|
||||
className="size-4"
|
||||
src={
|
||||
indexingType === IndexingType.ECONOMICAL
|
||||
@@ -64,7 +65,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={(
|
||||
<img
|
||||
<Image
|
||||
className="size-4"
|
||||
src={
|
||||
retrievalMethod === RETRIEVE_METHOD.fullText
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -100,7 +101,7 @@ const RuleDetail: FC<RuleDetailProps> = React.memo(({
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
|
||||
valueIcon={(
|
||||
<img
|
||||
<Image
|
||||
className="size-4"
|
||||
src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality}
|
||||
alt=""
|
||||
@@ -111,7 +112,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={(
|
||||
<img
|
||||
<Image
|
||||
className="size-4"
|
||||
src={getRetrievalIcon(retrievalMethod)}
|
||||
alt=""
|
||||
|
||||
@@ -905,8 +905,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// The TopKItem renders the visible number-field input as a textbox.
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
// The TopKItem should render an input
|
||||
const inputs = screen.getAllByRole('spinbutton')
|
||||
const topKInput = inputs[0]
|
||||
fireEvent.change(topKInput, { target: { value: '8' } })
|
||||
|
||||
@@ -924,8 +924,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// The ScoreThresholdItem renders the visible number-field input as a textbox.
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
// The ScoreThresholdItem should render an input
|
||||
const inputs = screen.getAllByRole('spinbutton')
|
||||
const scoreThresholdInput = inputs[1]
|
||||
fireEvent.change(scoreThresholdInput, { target: { value: '0.8' } })
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ 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'
|
||||
@@ -177,7 +178,7 @@ const QueryInput = ({
|
||||
}, [text, externalRetrievalSettings, externalKnowledgeBaseHitTestingMutation, onUpdateList, setExternalHitResult])
|
||||
|
||||
const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method
|
||||
const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" />
|
||||
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" />
|
||||
const TextAreaComp = useMemo(() => {
|
||||
return (
|
||||
<Textarea
|
||||
@@ -205,7 +206,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="text-text-secondary system-sm-semibold-uppercase">
|
||||
<span className="system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('input.title', { ns: 'datasetHitTesting' })}
|
||||
</span>
|
||||
{isExternal
|
||||
@@ -217,7 +218,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="text-components-button-secondary-text system-xs-medium">{t('settingTitle', { ns: 'datasetHitTesting' })}</span>
|
||||
<span className="system-xs-medium text-components-button-secondary-text">{t('settingTitle', { ns: 'datasetHitTesting' })}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -43,9 +43,8 @@ describe('InputCombined', () => {
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByDisplayValue('42')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue('42')
|
||||
})
|
||||
|
||||
it('should render date picker for time type', () => {
|
||||
@@ -97,7 +96,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '123' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
@@ -109,7 +108,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={999} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('999')
|
||||
expect(screen.getByDisplayValue('999')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply readOnly prop to number input', () => {
|
||||
@@ -118,7 +117,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
})
|
||||
})
|
||||
@@ -187,7 +186,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={null} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -209,7 +208,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveClass('rounded-l-md')
|
||||
})
|
||||
})
|
||||
@@ -231,7 +230,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('0')
|
||||
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle negative number', () => {
|
||||
@@ -240,7 +239,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('-100')
|
||||
expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in string', () => {
|
||||
@@ -264,7 +263,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,15 +129,15 @@ describe('IndexMethod', () => {
|
||||
|
||||
it('should pass keywordNumber to KeywordNumber component', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={25} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('25')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
})
|
||||
|
||||
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
|
||||
const handleKeywordChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '30' } })
|
||||
|
||||
expect(handleKeywordChange).toHaveBeenCalled()
|
||||
@@ -192,14 +192,14 @@ describe('IndexMethod', () => {
|
||||
|
||||
it('should handle keywordNumber of 0', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('0')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(0)
|
||||
})
|
||||
|
||||
it('should handle max keywordNumber', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={50} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('50')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(50)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,15 +38,15 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
it('should render input number field', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display correct keywordNumber value in input', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('25')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
})
|
||||
|
||||
it('should display different keywordNumber values', () => {
|
||||
@@ -54,8 +54,8 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
values.forEach((value) => {
|
||||
const { unmount } = render(<KeyWordNumber {...defaultProps} keywordNumber={value} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue(String(value))
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(value)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -82,7 +82,7 @@ describe('KeyWordNumber', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '30' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
@@ -92,7 +92,7 @@ describe('KeyWordNumber', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
// When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
|
||||
@@ -117,32 +117,32 @@ describe('KeyWordNumber', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle minimum value (0)', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={0} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('0')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(0)
|
||||
})
|
||||
|
||||
it('should handle maximum value (50)', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={50} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('50')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(50)
|
||||
})
|
||||
|
||||
it('should handle value updates correctly', () => {
|
||||
const { rerender } = render(<KeyWordNumber {...defaultProps} keywordNumber={10} />)
|
||||
|
||||
let input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('10')
|
||||
let input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(10)
|
||||
|
||||
rerender(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
|
||||
input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('25')
|
||||
input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
})
|
||||
|
||||
it('should handle rapid value changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
// Simulate rapid changes via input with different values
|
||||
fireEvent.change(input, { target: { value: '15' } })
|
||||
@@ -162,7 +162,7 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
it('should have accessible input', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
112
web/app/components/evaluation/__tests__/index.spec.tsx
Normal file
112
web/app/components/evaluation/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import Evaluation from '..'
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import { useEvaluationStore } from '../store'
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: () => ({
|
||||
data: [{
|
||||
provider: 'openai',
|
||||
models: [{ model: 'gpt-4o-mini' }],
|
||||
}],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
|
||||
<div data-testid="evaluation-model-selector">
|
||||
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'empty'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Evaluation', () => {
|
||||
beforeEach(() => {
|
||||
useEvaluationStore.setState({ resources: {} })
|
||||
})
|
||||
|
||||
it('should search, add metrics, and create a batch history record', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<Evaluation resourceType="workflow" resourceId="app-1" />)
|
||||
|
||||
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('openai:gpt-4o-mini')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
|
||||
expect(screen.getByTestId('evaluation-metric-loading')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
|
||||
target: { value: 'does-not-exist' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
|
||||
target: { value: 'faith' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Faithfulness/i }))
|
||||
expect(screen.getAllByText('Faithfulness').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.run' }))
|
||||
expect(screen.getByText('evaluation.batch.status.running')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1300)
|
||||
})
|
||||
|
||||
expect(screen.getByText('evaluation.batch.status.success')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workflow evaluation batch')).toBeInTheDocument()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should render time placeholders and hide the value row for empty operators', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-2'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
const timeField = config.fieldOptions.find(field => field.type === 'time')!
|
||||
let groupId = ''
|
||||
let itemId = ''
|
||||
|
||||
act(() => {
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
|
||||
|
||||
const group = useEvaluationStore.getState().resources['workflow:app-2'].conditions[0]
|
||||
groupId = group.id
|
||||
itemId = group.items[0].id
|
||||
|
||||
store.updateConditionField(resourceType, resourceId, groupId, itemId, timeField.id)
|
||||
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'before')
|
||||
})
|
||||
|
||||
let rerender: ReturnType<typeof render>['rerender']
|
||||
act(() => {
|
||||
({ rerender } = render(<Evaluation resourceType={resourceType} resourceId={resourceId} />))
|
||||
})
|
||||
|
||||
expect(screen.getByText('evaluation.conditions.selectTime')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'is_empty')
|
||||
rerender(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('evaluation.conditions.selectTime')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
96
web/app/components/evaluation/__tests__/store.spec.ts
Normal file
96
web/app/components/evaluation/__tests__/store.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import {
|
||||
getAllowedOperators,
|
||||
isCustomMetricConfigured,
|
||||
requiresConditionValue,
|
||||
useEvaluationStore,
|
||||
} from '../store'
|
||||
|
||||
describe('evaluation store', () => {
|
||||
beforeEach(() => {
|
||||
useEvaluationStore.setState({ resources: {} })
|
||||
})
|
||||
|
||||
it('should configure a custom metric mapping to a valid state', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-1'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.addCustomMetric(resourceType, resourceId)
|
||||
|
||||
const initialMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.kind === 'custom-workflow')
|
||||
expect(initialMetric).toBeDefined()
|
||||
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
|
||||
|
||||
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, config.workflowOptions[0].id)
|
||||
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, initialMetric!.customConfig!.mappings[0].id, {
|
||||
sourceFieldId: config.fieldOptions[0].id,
|
||||
targetVariableId: config.workflowOptions[0].targetVariables[0].id,
|
||||
})
|
||||
|
||||
const configuredMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
|
||||
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
|
||||
})
|
||||
|
||||
it('should add and remove builtin metrics', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-2'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[1].id)
|
||||
|
||||
const addedMetric = useEvaluationStore.getState().resources['workflow:app-2'].metrics.find(metric => metric.optionId === config.builtinMetrics[1].id)
|
||||
expect(addedMetric).toBeDefined()
|
||||
|
||||
store.removeMetric(resourceType, resourceId, addedMetric!.id)
|
||||
|
||||
expect(useEvaluationStore.getState().resources['workflow:app-2'].metrics.some(metric => metric.id === addedMetric!.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('should update condition groups and adapt operators to field types', () => {
|
||||
const resourceType = 'pipeline'
|
||||
const resourceId = 'dataset-1'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
|
||||
const initialGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
|
||||
store.setConditionGroupOperator(resourceType, resourceId, initialGroup.id, 'or')
|
||||
store.addConditionGroup(resourceType, resourceId)
|
||||
|
||||
const booleanField = config.fieldOptions.find(field => field.type === 'boolean')!
|
||||
const currentItem = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0].items[0]
|
||||
store.updateConditionField(resourceType, resourceId, initialGroup.id, currentItem.id, booleanField.id)
|
||||
|
||||
const updatedGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
|
||||
expect(updatedGroup.logicalOperator).toBe('or')
|
||||
expect(updatedGroup.items[0].operator).toBe('is')
|
||||
expect(getAllowedOperators(resourceType, booleanField.id)).toEqual(['is', 'is_not'])
|
||||
})
|
||||
|
||||
it('should support time fields and clear values for empty operators', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-3'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
|
||||
const timeField = config.fieldOptions.find(field => field.type === 'time')!
|
||||
const item = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
|
||||
|
||||
store.updateConditionField(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, timeField.id)
|
||||
store.updateConditionOperator(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, 'is_empty')
|
||||
|
||||
const updatedItem = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
|
||||
|
||||
expect(getAllowedOperators(resourceType, timeField.id)).toEqual(['is', 'before', 'after', 'is_empty', 'is_not_empty'])
|
||||
expect(requiresConditionValue('is_empty')).toBe(false)
|
||||
expect(updatedItem.value).toBeNull()
|
||||
})
|
||||
})
|
||||
1017
web/app/components/evaluation/index.tsx
Normal file
1017
web/app/components/evaluation/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
184
web/app/components/evaluation/mock.ts
Normal file
184
web/app/components/evaluation/mock.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type {
|
||||
ComparisonOperator,
|
||||
EvaluationFieldOption,
|
||||
EvaluationMockConfig,
|
||||
EvaluationResourceType,
|
||||
MetricOption,
|
||||
} from './types'
|
||||
|
||||
const judgeModels = [
|
||||
{
|
||||
id: 'gpt-4.1-mini',
|
||||
label: 'GPT-4.1 mini',
|
||||
provider: 'OpenAI',
|
||||
},
|
||||
{
|
||||
id: 'claude-3-7-sonnet',
|
||||
label: 'Claude 3.7 Sonnet',
|
||||
provider: 'Anthropic',
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.0-flash',
|
||||
label: 'Gemini 2.0 Flash',
|
||||
provider: 'Google',
|
||||
},
|
||||
]
|
||||
|
||||
const builtinMetrics: MetricOption[] = [
|
||||
{
|
||||
id: 'answer-correctness',
|
||||
label: 'Answer Correctness',
|
||||
description: 'Compares the response with the expected answer and scores factual alignment.',
|
||||
group: 'quality',
|
||||
badges: ['LLM', 'Built-in'],
|
||||
},
|
||||
{
|
||||
id: 'faithfulness',
|
||||
label: 'Faithfulness',
|
||||
description: 'Checks whether the answer stays grounded in the retrieved evidence.',
|
||||
group: 'quality',
|
||||
badges: ['LLM', 'Retrieval'],
|
||||
},
|
||||
{
|
||||
id: 'relevance',
|
||||
label: 'Relevance',
|
||||
description: 'Evaluates how directly the answer addresses the original request.',
|
||||
group: 'quality',
|
||||
badges: ['LLM'],
|
||||
},
|
||||
{
|
||||
id: 'latency',
|
||||
label: 'Latency',
|
||||
description: 'Captures runtime responsiveness for the full execution path.',
|
||||
group: 'operations',
|
||||
badges: ['System'],
|
||||
},
|
||||
{
|
||||
id: 'token-usage',
|
||||
label: 'Token Usage',
|
||||
description: 'Tracks prompt and completion token consumption for the run.',
|
||||
group: 'operations',
|
||||
badges: ['System'],
|
||||
},
|
||||
{
|
||||
id: 'tool-success-rate',
|
||||
label: 'Tool Success Rate',
|
||||
description: 'Measures whether each required tool invocation finishes without failure.',
|
||||
group: 'operations',
|
||||
badges: ['Workflow'],
|
||||
},
|
||||
]
|
||||
|
||||
const workflowOptions = [
|
||||
{
|
||||
id: 'workflow-precision-review',
|
||||
label: 'Precision Review Workflow',
|
||||
description: 'Custom evaluator for nuanced quality review.',
|
||||
targetVariables: [
|
||||
{ id: 'query', label: 'query' },
|
||||
{ id: 'answer', label: 'answer' },
|
||||
{ id: 'reference', label: 'reference' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workflow-risk-review',
|
||||
label: 'Risk Review Workflow',
|
||||
description: 'Custom evaluator for policy and escalation checks.',
|
||||
targetVariables: [
|
||||
{ id: 'input', label: 'input' },
|
||||
{ id: 'output', label: 'output' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const workflowFields: EvaluationFieldOption[] = [
|
||||
{ id: 'app.input.query', label: 'Query', group: 'App Input', type: 'string' },
|
||||
{ id: 'app.input.locale', label: 'Locale', group: 'App Input', type: 'enum', options: [{ value: 'en-US', label: 'en-US' }, { value: 'zh-Hans', label: 'zh-Hans' }] },
|
||||
{ id: 'app.output.answer', label: 'Answer', group: 'App Output', type: 'string' },
|
||||
{ id: 'app.output.score', label: 'Score', group: 'App Output', type: 'number' },
|
||||
{ id: 'app.output.published_at', label: 'Publication Date', group: 'App Output', type: 'time' },
|
||||
{ id: 'system.has_context', label: 'Has Context', group: 'System', type: 'boolean' },
|
||||
]
|
||||
|
||||
const pipelineFields: EvaluationFieldOption[] = [
|
||||
{ id: 'dataset.input.document_id', label: 'Document ID', group: 'Dataset', type: 'string' },
|
||||
{ id: 'dataset.input.chunk_count', label: 'Chunk Count', group: 'Dataset', type: 'number' },
|
||||
{ id: 'dataset.input.updated_at', label: 'Updated At', group: 'Dataset', type: 'time' },
|
||||
{ id: 'retrieval.output.hit_rate', label: 'Hit Rate', group: 'Retrieval', type: 'number' },
|
||||
{ id: 'retrieval.output.source', label: 'Source', group: 'Retrieval', type: 'enum', options: [{ value: 'bm25', label: 'BM25' }, { value: 'hybrid', label: 'Hybrid' }] },
|
||||
{ id: 'pipeline.output.published', label: 'Published', group: 'Output', type: 'boolean' },
|
||||
]
|
||||
|
||||
const snippetFields: EvaluationFieldOption[] = [
|
||||
{ id: 'snippet.input.blog_url', label: 'Blog URL', group: 'Snippet Input', type: 'string' },
|
||||
{ id: 'snippet.input.platforms', label: 'Platforms', group: 'Snippet Input', type: 'string' },
|
||||
{ id: 'snippet.output.content', label: 'Generated Content', group: 'Snippet Output', type: 'string' },
|
||||
{ id: 'snippet.output.length', label: 'Output Length', group: 'Snippet Output', type: 'number' },
|
||||
{ id: 'snippet.output.scheduled_at', label: 'Scheduled At', group: 'Snippet Output', type: 'time' },
|
||||
{ id: 'system.requires_review', label: 'Requires Review', group: 'System', type: 'boolean' },
|
||||
]
|
||||
|
||||
export const getComparisonOperators = (fieldType: EvaluationFieldOption['type']): ComparisonOperator[] => {
|
||||
if (fieldType === 'number')
|
||||
return ['is', 'is_not', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal', 'is_empty', 'is_not_empty']
|
||||
|
||||
if (fieldType === 'time')
|
||||
return ['is', 'before', 'after', 'is_empty', 'is_not_empty']
|
||||
|
||||
if (fieldType === 'boolean' || fieldType === 'enum')
|
||||
return ['is', 'is_not']
|
||||
|
||||
return ['contains', 'not_contains', 'is', 'is_not', 'is_empty', 'is_not_empty']
|
||||
}
|
||||
|
||||
export const getDefaultOperator = (fieldType: EvaluationFieldOption['type']): ComparisonOperator => {
|
||||
return getComparisonOperators(fieldType)[0]
|
||||
}
|
||||
|
||||
export const getEvaluationMockConfig = (resourceType: EvaluationResourceType): EvaluationMockConfig => {
|
||||
if (resourceType === 'pipeline') {
|
||||
return {
|
||||
judgeModels,
|
||||
builtinMetrics,
|
||||
workflowOptions,
|
||||
fieldOptions: pipelineFields,
|
||||
templateFileName: 'pipeline-evaluation-template.csv',
|
||||
batchRequirements: [
|
||||
'Include one row per retrieval scenario.',
|
||||
'Provide the expected source or target chunk for each case.',
|
||||
'Keep numeric metrics in plain number format.',
|
||||
],
|
||||
historySummaryLabel: 'Pipeline evaluation batch',
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceType === 'snippet') {
|
||||
return {
|
||||
judgeModels,
|
||||
builtinMetrics,
|
||||
workflowOptions,
|
||||
fieldOptions: snippetFields,
|
||||
templateFileName: 'snippet-evaluation-template.csv',
|
||||
batchRequirements: [
|
||||
'Include one row per snippet execution case.',
|
||||
'Provide the expected final content or acceptance rule.',
|
||||
'Keep optional fields empty when not used.',
|
||||
],
|
||||
historySummaryLabel: 'Snippet evaluation batch',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
judgeModels,
|
||||
builtinMetrics,
|
||||
workflowOptions,
|
||||
fieldOptions: workflowFields,
|
||||
templateFileName: 'workflow-evaluation-template.csv',
|
||||
batchRequirements: [
|
||||
'Include one row per workflow test case.',
|
||||
'Provide both user input and expected answer when available.',
|
||||
'Keep boolean columns as true or false.',
|
||||
],
|
||||
historySummaryLabel: 'Workflow evaluation batch',
|
||||
}
|
||||
}
|
||||
635
web/app/components/evaluation/store.ts
Normal file
635
web/app/components/evaluation/store.ts
Normal file
@@ -0,0 +1,635 @@
|
||||
import type {
|
||||
BatchTestRecord,
|
||||
ComparisonOperator,
|
||||
EvaluationFieldOption,
|
||||
EvaluationMetric,
|
||||
EvaluationResourceState,
|
||||
EvaluationResourceType,
|
||||
JudgmentConditionGroup,
|
||||
} from './types'
|
||||
import { create } from 'zustand'
|
||||
import { getComparisonOperators, getDefaultOperator, getEvaluationMockConfig } from './mock'
|
||||
|
||||
type EvaluationStore = {
|
||||
resources: Record<string, EvaluationResourceState>
|
||||
ensureResource: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
setJudgeModel: (resourceType: EvaluationResourceType, resourceId: string, judgeModelId: string) => void
|
||||
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string) => void
|
||||
addCustomMetric: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
removeMetric: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
|
||||
setCustomMetricWorkflow: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, workflowId: string) => void
|
||||
addCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
|
||||
updateCustomMetricMapping: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
metricId: string,
|
||||
mappingId: string,
|
||||
patch: { sourceFieldId?: string | null, targetVariableId?: string | null },
|
||||
) => void
|
||||
removeCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, mappingId: string) => void
|
||||
addConditionGroup: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
removeConditionGroup: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
|
||||
setConditionGroupOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, logicalOperator: 'and' | 'or') => void
|
||||
addConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
|
||||
removeConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string) => void
|
||||
updateConditionField: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, fieldId: string) => void
|
||||
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, operator: ComparisonOperator) => void
|
||||
updateConditionValue: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
groupId: string,
|
||||
itemId: string,
|
||||
value: string | number | boolean | null,
|
||||
) => void
|
||||
setBatchTab: (resourceType: EvaluationResourceType, resourceId: string, tab: EvaluationResourceState['activeBatchTab']) => void
|
||||
setUploadedFileName: (resourceType: EvaluationResourceType, resourceId: string, uploadedFileName: string | null) => void
|
||||
runBatchTest: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
}
|
||||
|
||||
const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
|
||||
const initialResourceCache: Record<string, EvaluationResourceState> = {}
|
||||
|
||||
const createId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
export const conditionOperatorsWithoutValue: ComparisonOperator[] = ['is_empty', 'is_not_empty']
|
||||
|
||||
export const requiresConditionValue = (operator: ComparisonOperator) => !conditionOperatorsWithoutValue.includes(operator)
|
||||
|
||||
const getConditionValue = (
|
||||
field: EvaluationFieldOption | undefined,
|
||||
operator: ComparisonOperator,
|
||||
previousValue: string | number | boolean | null = null,
|
||||
) => {
|
||||
if (!field || !requiresConditionValue(operator))
|
||||
return null
|
||||
|
||||
if (field.type === 'boolean')
|
||||
return typeof previousValue === 'boolean' ? previousValue : null
|
||||
|
||||
if (field.type === 'enum')
|
||||
return typeof previousValue === 'string' ? previousValue : null
|
||||
|
||||
if (field.type === 'number')
|
||||
return typeof previousValue === 'number' ? previousValue : null
|
||||
|
||||
return typeof previousValue === 'string' ? previousValue : null
|
||||
}
|
||||
|
||||
const buildConditionItem = (resourceType: EvaluationResourceType) => {
|
||||
const field = getEvaluationMockConfig(resourceType).fieldOptions[0]
|
||||
const operator = field ? getDefaultOperator(field.type) : 'contains'
|
||||
|
||||
return {
|
||||
id: createId('condition'),
|
||||
fieldId: field?.id ?? null,
|
||||
operator,
|
||||
value: getConditionValue(field, operator),
|
||||
}
|
||||
}
|
||||
|
||||
const buildInitialState = (resourceType: EvaluationResourceType): EvaluationResourceState => {
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const defaultMetric = config.builtinMetrics[0]
|
||||
|
||||
return {
|
||||
judgeModelId: null,
|
||||
metrics: defaultMetric
|
||||
? [{
|
||||
id: createId('metric'),
|
||||
optionId: defaultMetric.id,
|
||||
kind: 'builtin',
|
||||
label: defaultMetric.label,
|
||||
description: defaultMetric.description,
|
||||
badges: defaultMetric.badges,
|
||||
}]
|
||||
: [],
|
||||
conditions: [{
|
||||
id: createId('group'),
|
||||
logicalOperator: 'and',
|
||||
items: [buildConditionItem(resourceType)],
|
||||
}],
|
||||
activeBatchTab: 'input-fields',
|
||||
uploadedFileName: null,
|
||||
batchRecords: [],
|
||||
}
|
||||
}
|
||||
|
||||
const withResourceState = (
|
||||
resources: EvaluationStore['resources'],
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
) => {
|
||||
const resourceKey = buildResourceKey(resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resourceKey,
|
||||
resource: resources[resourceKey] ?? buildInitialState(resourceType),
|
||||
}
|
||||
}
|
||||
|
||||
const updateMetric = (
|
||||
metrics: EvaluationMetric[],
|
||||
metricId: string,
|
||||
updater: (metric: EvaluationMetric) => EvaluationMetric,
|
||||
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
|
||||
|
||||
const updateConditionGroup = (
|
||||
groups: JudgmentConditionGroup[],
|
||||
groupId: string,
|
||||
updater: (group: JudgmentConditionGroup) => JudgmentConditionGroup,
|
||||
) => groups.map(group => group.id === groupId ? updater(group) : group)
|
||||
|
||||
export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
|
||||
if (metric.kind !== 'custom-workflow')
|
||||
return true
|
||||
|
||||
if (!metric.customConfig?.workflowId)
|
||||
return false
|
||||
|
||||
return metric.customConfig.mappings.length > 0
|
||||
&& metric.customConfig.mappings.every(mapping => !!mapping.sourceFieldId && !!mapping.targetVariableId)
|
||||
}
|
||||
|
||||
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
|
||||
return !!state.judgeModelId
|
||||
&& state.metrics.length > 0
|
||||
&& state.metrics.every(isCustomMetricConfigured)
|
||||
&& state.conditions.some(group => group.items.length > 0)
|
||||
}
|
||||
|
||||
export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
|
||||
resources: {},
|
||||
ensureResource: (resourceType, resourceId) => {
|
||||
const resourceKey = buildResourceKey(resourceType, resourceId)
|
||||
if (get().resources[resourceKey])
|
||||
return
|
||||
|
||||
set(state => ({
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: buildInitialState(resourceType),
|
||||
},
|
||||
}))
|
||||
},
|
||||
setJudgeModel: (resourceType, resourceId, judgeModelId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
judgeModelId,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
addBuiltinMetric: (resourceType, resourceId, optionId) => {
|
||||
const option = getEvaluationMockConfig(resourceType).builtinMetrics.find(metric => metric.id === optionId)
|
||||
if (!option)
|
||||
return
|
||||
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
if (resource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin'))
|
||||
return state
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: [
|
||||
...resource.metrics,
|
||||
{
|
||||
id: createId('metric'),
|
||||
optionId: option.id,
|
||||
kind: 'builtin',
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
badges: option.badges,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
addCustomMetric: (resourceType, resourceId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: [
|
||||
...resource.metrics,
|
||||
{
|
||||
id: createId('metric'),
|
||||
optionId: createId('custom'),
|
||||
kind: 'custom-workflow',
|
||||
label: 'Custom Evaluator',
|
||||
description: 'Map workflow variables to your evaluation inputs.',
|
||||
badges: ['Workflow'],
|
||||
customConfig: {
|
||||
workflowId: null,
|
||||
mappings: [{
|
||||
id: createId('mapping'),
|
||||
sourceFieldId: null,
|
||||
targetVariableId: null,
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
removeMetric: (resourceType, resourceId, metricId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: resource.metrics.filter(metric => metric.id !== metricId),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflowId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
workflowId,
|
||||
mappings: metric.customConfig.mappings.map(mapping => ({
|
||||
...mapping,
|
||||
targetVariableId: null,
|
||||
})),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
addCustomMetricMapping: (resourceType, resourceId, metricId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: [
|
||||
...metric.customConfig.mappings,
|
||||
{
|
||||
id: createId('mapping'),
|
||||
sourceFieldId: null,
|
||||
targetVariableId: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
updateCustomMetricMapping: (resourceType, resourceId, metricId, mappingId, patch) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: metric.customConfig.mappings.map(mapping => mapping.id === mappingId ? { ...mapping, ...patch } : mapping),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
removeCustomMetricMapping: (resourceType, resourceId, metricId, mappingId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: metric.customConfig.mappings.filter(mapping => mapping.id !== mappingId),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
addConditionGroup: (resourceType, resourceId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: [
|
||||
...resource.conditions,
|
||||
{
|
||||
id: createId('group'),
|
||||
logicalOperator: 'and',
|
||||
items: [buildConditionItem(resourceType)],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
removeConditionGroup: (resourceType, resourceId, groupId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: resource.conditions.filter(group => group.id !== groupId),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
setConditionGroupOperator: (resourceType, resourceId, groupId, logicalOperator) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
logicalOperator,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
addConditionItem: (resourceType, resourceId, groupId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: [
|
||||
...group.items,
|
||||
buildConditionItem(resourceType),
|
||||
],
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
removeConditionItem: (resourceType, resourceId, groupId, itemId) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: group.items.filter(item => item.id !== itemId),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
updateConditionField: (resourceType, resourceId, groupId, itemId, fieldId) => {
|
||||
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
|
||||
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: group.items.map((item) => {
|
||||
if (item.id !== itemId)
|
||||
return item
|
||||
|
||||
return {
|
||||
...item,
|
||||
fieldId,
|
||||
operator: field ? getDefaultOperator(field.type) : item.operator,
|
||||
value: getConditionValue(field, field ? getDefaultOperator(field.type) : item.operator),
|
||||
}
|
||||
}),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
updateConditionOperator: (resourceType, resourceId, groupId, itemId, operator) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
const fieldOptions = getEvaluationMockConfig(resourceType).fieldOptions
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: group.items.map((item) => {
|
||||
if (item.id !== itemId)
|
||||
return item
|
||||
|
||||
const field = fieldOptions.find(option => option.id === item.fieldId)
|
||||
|
||||
return {
|
||||
...item,
|
||||
operator,
|
||||
value: getConditionValue(field, operator, item.value),
|
||||
}
|
||||
}),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
updateConditionValue: (resourceType, resourceId, groupId, itemId, value) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: group.items.map(item => item.id === itemId ? { ...item, value } : item),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
setBatchTab: (resourceType, resourceId, tab) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
activeBatchTab: tab,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
setUploadedFileName: (resourceType, resourceId, uploadedFileName) => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
uploadedFileName,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
runBatchTest: (resourceType, resourceId) => {
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const recordId = createId('batch')
|
||||
const nextRecord: BatchTestRecord = {
|
||||
id: recordId,
|
||||
fileName: get().resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileName ?? config.templateFileName,
|
||||
status: 'running',
|
||||
startedAt: new Date().toLocaleTimeString(),
|
||||
summary: config.historySummaryLabel,
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
activeBatchTab: 'history',
|
||||
batchRecords: [nextRecord, ...resource.batchRecords],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
window.setTimeout(() => {
|
||||
set((state) => {
|
||||
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: {
|
||||
...resource,
|
||||
batchRecords: resource.batchRecords.map(record => record.id === recordId
|
||||
? {
|
||||
...record,
|
||||
status: resource.metrics.length > 1 ? 'success' : 'failed',
|
||||
}
|
||||
: record),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}, 1200)
|
||||
},
|
||||
}))
|
||||
|
||||
export const useEvaluationResource = (resourceType: EvaluationResourceType, resourceId: string) => {
|
||||
const resourceKey = buildResourceKey(resourceType, resourceId)
|
||||
return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)))
|
||||
}
|
||||
|
||||
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
|
||||
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
|
||||
|
||||
if (!field)
|
||||
return ['contains'] as ComparisonOperator[]
|
||||
|
||||
return getComparisonOperators(field.type)
|
||||
}
|
||||
117
web/app/components/evaluation/types.ts
Normal file
117
web/app/components/evaluation/types.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
export type EvaluationResourceType = 'workflow' | 'pipeline' | 'snippet'
|
||||
|
||||
export type MetricKind = 'builtin' | 'custom-workflow'
|
||||
|
||||
export type BatchTestTab = 'input-fields' | 'history'
|
||||
|
||||
export type FieldType = 'string' | 'number' | 'boolean' | 'enum' | 'time'
|
||||
|
||||
export type ComparisonOperator
|
||||
= | 'contains'
|
||||
| 'not_contains'
|
||||
| 'is'
|
||||
| 'is_not'
|
||||
| 'is_empty'
|
||||
| 'is_not_empty'
|
||||
| 'greater_than'
|
||||
| 'less_than'
|
||||
| 'greater_or_equal'
|
||||
| 'less_or_equal'
|
||||
| 'before'
|
||||
| 'after'
|
||||
|
||||
export type JudgeModelOption = {
|
||||
id: string
|
||||
label: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type MetricOption = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
group: string
|
||||
badges: string[]
|
||||
}
|
||||
|
||||
export type EvaluationWorkflowOption = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
targetVariables: Array<{
|
||||
id: string
|
||||
label: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type EvaluationFieldOption = {
|
||||
id: string
|
||||
label: string
|
||||
group: string
|
||||
type: FieldType
|
||||
options?: Array<{
|
||||
value: string
|
||||
label: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type CustomMetricMapping = {
|
||||
id: string
|
||||
sourceFieldId: string | null
|
||||
targetVariableId: string | null
|
||||
}
|
||||
|
||||
export type CustomMetricConfig = {
|
||||
workflowId: string | null
|
||||
mappings: CustomMetricMapping[]
|
||||
}
|
||||
|
||||
export type EvaluationMetric = {
|
||||
id: string
|
||||
optionId: string
|
||||
kind: MetricKind
|
||||
label: string
|
||||
description: string
|
||||
badges: string[]
|
||||
customConfig?: CustomMetricConfig
|
||||
}
|
||||
|
||||
export type JudgmentConditionItem = {
|
||||
id: string
|
||||
fieldId: string | null
|
||||
operator: ComparisonOperator
|
||||
value: string | number | boolean | null
|
||||
}
|
||||
|
||||
export type JudgmentConditionGroup = {
|
||||
id: string
|
||||
logicalOperator: 'and' | 'or'
|
||||
items: JudgmentConditionItem[]
|
||||
}
|
||||
|
||||
export type BatchTestRecord = {
|
||||
id: string
|
||||
fileName: string
|
||||
status: 'running' | 'success' | 'failed'
|
||||
startedAt: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export type EvaluationResourceState = {
|
||||
judgeModelId: string | null
|
||||
metrics: EvaluationMetric[]
|
||||
conditions: JudgmentConditionGroup[]
|
||||
activeBatchTab: BatchTestTab
|
||||
uploadedFileName: string | null
|
||||
batchRecords: BatchTestRecord[]
|
||||
}
|
||||
|
||||
export type EvaluationMockConfig = {
|
||||
judgeModels: JudgeModelOption[]
|
||||
builtinMetrics: MetricOption[]
|
||||
workflowOptions: EvaluationWorkflowOption[]
|
||||
fieldOptions: EvaluationFieldOption[]
|
||||
templateFileName: string
|
||||
batchRequirements: string[]
|
||||
historySummaryLabel: string
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user