mirror of
https://github.com/langgenius/dify.git
synced 2026-03-17 21:37:03 +00:00
Compare commits
204 Commits
1.13.1
...
feat/model
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a06de6be78 | ||
|
|
7c6d0bedc0 | ||
|
|
3db1ba36e0 | ||
|
|
7e4d1ead0c | ||
|
|
d1961c261e | ||
|
|
a717519822 | ||
|
|
a592c53573 | ||
|
|
e103971bf6 | ||
|
|
0edbc5d672 | ||
|
|
111d82bbb4 | ||
|
|
37367c65c3 | ||
|
|
40e9c19f90 | ||
|
|
f5f87b73a8 | ||
|
|
5c53be707c | ||
|
|
be366278da | ||
|
|
bdaa139674 | ||
|
|
1921179614 | ||
|
|
d22ee2c44b | ||
|
|
daef541083 | ||
|
|
d39c06c08a | ||
|
|
fd100a868d | ||
|
|
75a9939094 | ||
|
|
775cf18704 | ||
|
|
30089c27b9 | ||
|
|
e6b6849791 | ||
|
|
c66820de6f | ||
|
|
548b20f70b | ||
|
|
8b62b99d9d | ||
|
|
4f6d880cf2 | ||
|
|
669d4e0b92 | ||
|
|
34159cbc3d | ||
|
|
006bd6fb2c | ||
|
|
40b3fcf65b | ||
|
|
3232548880 | ||
|
|
471106cef1 | ||
|
|
0c42c11d28 | ||
|
|
03c58d151a | ||
|
|
fcc8e79733 | ||
|
|
21cb5ae1b6 | ||
|
|
a985be6282 | ||
|
|
d0054e28fe | ||
|
|
a1410dc531 | ||
|
|
e2f433bab9 | ||
|
|
64a66f2adc | ||
|
|
e407d688d2 | ||
|
|
2f85c77a54 | ||
|
|
906852fbd6 | ||
|
|
729e18a7d6 | ||
|
|
4ed49f1d98 | ||
|
|
04f4627f9b | ||
|
|
c167ee199c | ||
|
|
339a8ca057 | ||
|
|
d39f243a4a | ||
|
|
911d52cafc | ||
|
|
fee6d13f44 | ||
|
|
cb8e20786a | ||
|
|
d27a737cd1 | ||
|
|
167fcc866d | ||
|
|
82ad93eb1a | ||
|
|
f0086888e3 | ||
|
|
ee2280851d | ||
|
|
e9d0c7bb2a | ||
|
|
06e1d59e1d | ||
|
|
bd2bb27faa | ||
|
|
c08b9a289b | ||
|
|
715a0fabfc | ||
|
|
5d07ccce59 | ||
|
|
45e4d47207 | ||
|
|
fa664ebe77 | ||
|
|
563d0c6892 | ||
|
|
af2a6b2de0 | ||
|
|
908e57b9f5 | ||
|
|
d72fbce31c | ||
|
|
6cb68b6de5 | ||
|
|
aeaf6d2ce9 | ||
|
|
ad4cb51983 | ||
|
|
3f27c8a9d2 | ||
|
|
c2def7a840 | ||
|
|
f18fd566ba | ||
|
|
0acc2eaa00 | ||
|
|
e0947a1ea8 | ||
|
|
e51162af0c | ||
|
|
08da390678 | ||
|
|
250450a54e | ||
|
|
5709a34a7f | ||
|
|
e8ade9ad64 | ||
|
|
34a5645d94 | ||
|
|
5e80a3f5de | ||
|
|
785e04816e | ||
|
|
2704688f59 | ||
|
|
7699b0d430 | ||
|
|
45c96dc254 | ||
|
|
3a957cc28b | ||
|
|
7ed7562be6 | ||
|
|
fda5d12107 | ||
|
|
0b2ded3227 | ||
|
|
369e4eb7b0 | ||
|
|
a4942139d2 | ||
|
|
83c15227f6 | ||
|
|
60f86f0520 | ||
|
|
b3c98e417d | ||
|
|
dfe389c017 | ||
|
|
b364b06e51 | ||
|
|
ce0197b107 | ||
|
|
164cefc65c | ||
|
|
f6d80b9fa7 | ||
|
|
e845fa7e6a | ||
|
|
bab7bd5ecc | ||
|
|
cfb02bceaf | ||
|
|
694ca840e1 | ||
|
|
2d979e2cec | ||
|
|
5cee7cf8ce | ||
|
|
0c17823c8b | ||
|
|
49c6696d08 | ||
|
|
292c98a8f3 | ||
|
|
0e0a6ad043 | ||
|
|
456c95adb1 | ||
|
|
1abbaf9fd5 | ||
|
|
1a26e1669b | ||
|
|
02444af2e3 | ||
|
|
56038e3684 | ||
|
|
eb9341e7ec | ||
|
|
e40b31b9c4 | ||
|
|
b89ee4807f | ||
|
|
9907cf9e06 | ||
|
|
208a31719f | ||
|
|
3d1ef1f7f5 | ||
|
|
24b14e2c1a | ||
|
|
53f122f717 | ||
|
|
fced2f9e65 | ||
|
|
0c08c4016d | ||
|
|
ff4e4a8d64 | ||
|
|
948efa129f | ||
|
|
e371bfd676 | ||
|
|
6d612c0909 | ||
|
|
56e0dc0ae6 | ||
|
|
975eca00c3 | ||
|
|
f049bafcc3 | ||
|
|
dd9c526447 | ||
|
|
922dc71e36 | ||
|
|
f03ec7f671 | ||
|
|
29f275442d | ||
|
|
c9532ffd43 | ||
|
|
840dc33b8b | ||
|
|
cae58a0649 | ||
|
|
1752edc047 | ||
|
|
7471c32612 | ||
|
|
2d333bbbe5 | ||
|
|
4af6788ce0 | ||
|
|
24b072def9 | ||
|
|
909c8c3350 | ||
|
|
80e9c8bee0 | ||
|
|
15b7b304d2 | ||
|
|
61e2672b59 | ||
|
|
5f4ed4c6f6 | ||
|
|
4a1032c628 | ||
|
|
423c97a47e | ||
|
|
a7e3fb2e33 | ||
|
|
ce34937a1c | ||
|
|
ad9ac6978e | ||
|
|
57c1ba3543 | ||
|
|
d7a5af2b9a | ||
|
|
d45edffaa3 | ||
|
|
530515b6ef | ||
|
|
f13f0d1f9a | ||
|
|
b597d52c11 | ||
|
|
34c42fe666 | ||
|
|
dc109c99f0 | ||
|
|
223b9d89c1 | ||
|
|
dd119eb44f | ||
|
|
970493fa85 | ||
|
|
ab87ac333a | ||
|
|
b8b70da9ad | ||
|
|
77d81aebe8 | ||
|
|
deb4cd3ece | ||
|
|
648d9ef1f9 | ||
|
|
5ed4797078 | ||
|
|
62631658e9 | ||
|
|
22a4100dd7 | ||
|
|
0f7ed6f67e | ||
|
|
4d9fcbec57 | ||
|
|
4d7a9bc798 | ||
|
|
d6d04ed657 | ||
|
|
f594a71dae | ||
|
|
04e0ab7eda | ||
|
|
784bda9c86 | ||
|
|
1af1fb6913 | ||
|
|
1f0c36e9f7 | ||
|
|
455ae65025 | ||
|
|
d44682e957 | ||
|
|
8c4afc0c18 | ||
|
|
539cbcae6a | ||
|
|
8d257fea7c | ||
|
|
c3364ac350 | ||
|
|
f991644989 | ||
|
|
29e344ac8b | ||
|
|
1ad9305732 | ||
|
|
17f38f171d | ||
|
|
802088c8eb | ||
|
|
cad6d94491 | ||
|
|
621d0fb2c9 | ||
|
|
a92fb3244b | ||
|
|
97508f8d7b | ||
|
|
70e677a6ac |
4
.github/workflows/web-tests.yml
vendored
4
.github/workflows/web-tests.yml
vendored
@@ -29,8 +29,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4]
|
||||
shardTotal: [4]
|
||||
shardIndex: [1, 2, 3, 4, 5, 6]
|
||||
shardTotal: [6]
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
@@ -78,7 +78,7 @@ class UserProfile(TypedDict):
|
||||
nickname: NotRequired[str]
|
||||
```
|
||||
|
||||
- For classes, declare member variables at the top of the class body (before `__init__`) so the class shape is obvious at a glance:
|
||||
- For classes, declare all member variables explicitly with types at the top of the class body (before `__init__`), even when the class is not a dataclass or Pydantic model, so the class shape is obvious at a glance:
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Literal, Protocol
|
||||
from typing import Literal, Protocol, cast
|
||||
from urllib.parse import quote_plus, urlunparse
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
@@ -12,16 +12,13 @@ class RedisConfigDefaults(Protocol):
|
||||
REDIS_PASSWORD: str | None
|
||||
REDIS_DB: int
|
||||
REDIS_USE_SSL: bool
|
||||
REDIS_USE_SENTINEL: bool | None
|
||||
REDIS_USE_CLUSTERS: bool
|
||||
|
||||
|
||||
class RedisConfigDefaultsMixin:
|
||||
def _redis_defaults(self: RedisConfigDefaults) -> RedisConfigDefaults:
|
||||
return self
|
||||
def _redis_defaults(config: object) -> RedisConfigDefaults:
|
||||
return cast(RedisConfigDefaults, config)
|
||||
|
||||
|
||||
class RedisPubSubConfig(BaseSettings, RedisConfigDefaultsMixin):
|
||||
class RedisPubSubConfig(BaseSettings):
|
||||
"""
|
||||
Configuration settings for event transport between API and workers.
|
||||
|
||||
@@ -74,7 +71,7 @@ class RedisPubSubConfig(BaseSettings, RedisConfigDefaultsMixin):
|
||||
)
|
||||
|
||||
def _build_default_pubsub_url(self) -> str:
|
||||
defaults = self._redis_defaults()
|
||||
defaults = _redis_defaults(self)
|
||||
if not defaults.REDIS_HOST or not defaults.REDIS_PORT:
|
||||
raise ValueError("PUBSUB_REDIS_URL must be set when default Redis URL cannot be constructed")
|
||||
|
||||
@@ -91,11 +88,9 @@ class RedisPubSubConfig(BaseSettings, RedisConfigDefaultsMixin):
|
||||
if userinfo:
|
||||
userinfo = f"{userinfo}@"
|
||||
|
||||
host = defaults.REDIS_HOST
|
||||
port = defaults.REDIS_PORT
|
||||
db = defaults.REDIS_DB
|
||||
|
||||
netloc = f"{userinfo}{host}:{port}"
|
||||
netloc = f"{userinfo}{defaults.REDIS_HOST}:{defaults.REDIS_PORT}"
|
||||
return urlunparse((scheme, netloc, f"/{db}", "", "", ""))
|
||||
|
||||
@property
|
||||
|
||||
@@ -473,9 +473,21 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
|
||||
else:
|
||||
# some historical data may have a provider record but not be set as valid
|
||||
provider_record.is_valid = True
|
||||
|
||||
if provider_record.credential_id is None:
|
||||
provider_record.credential_id = new_record.id
|
||||
provider_record.updated_at = naive_utc_now()
|
||||
|
||||
provider_model_credentials_cache = ProviderCredentialsCache(
|
||||
tenant_id=self.tenant_id,
|
||||
identity_id=provider_record.id,
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
|
||||
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
|
||||
@@ -196,6 +196,8 @@ class ProviderManager:
|
||||
|
||||
if preferred_provider_type_record:
|
||||
preferred_provider_type = ProviderType.value_of(preferred_provider_type_record.preferred_provider_type)
|
||||
elif dify_config.EDITION == "CLOUD" and system_configuration.enabled:
|
||||
preferred_provider_type = ProviderType.SYSTEM
|
||||
elif custom_configuration.provider or custom_configuration.models:
|
||||
preferred_provider_type = ProviderType.CUSTOM
|
||||
elif system_configuration.enabled:
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from dify_graph.file.models import File
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
from dify_graph.variables.segments import Segment
|
||||
|
||||
|
||||
class ArrayValidation(StrEnum):
|
||||
@@ -219,7 +219,7 @@ class SegmentType(StrEnum):
|
||||
return _ARRAY_ELEMENT_TYPES_MAPPING.get(self)
|
||||
|
||||
@staticmethod
|
||||
def get_zero_value(t: SegmentType):
|
||||
def get_zero_value(t: SegmentType) -> Segment:
|
||||
# Lazy import to avoid circular dependency
|
||||
from factories import variable_factory
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Protocol, cast
|
||||
|
||||
from fastopenapi.routers import FlaskRouter
|
||||
from flask_cors import CORS
|
||||
|
||||
@@ -9,6 +11,10 @@ from extensions.ext_blueprints import AUTHENTICATED_HEADERS, EXPOSED_HEADERS
|
||||
DOCS_PREFIX = "/fastopenapi"
|
||||
|
||||
|
||||
class SupportsIncludeRouter(Protocol):
|
||||
def include_router(self, router: object, *, prefix: str = "") -> None: ...
|
||||
|
||||
|
||||
def init_app(app: DifyApp) -> None:
|
||||
docs_enabled = dify_config.SWAGGER_UI_ENABLED
|
||||
docs_url = f"{DOCS_PREFIX}/docs" if docs_enabled else None
|
||||
@@ -36,7 +42,7 @@ def init_app(app: DifyApp) -> None:
|
||||
_ = remote_files
|
||||
_ = setup
|
||||
|
||||
router.include_router(console_router, prefix="/console/api")
|
||||
cast(SupportsIncludeRouter, router).include_router(console_router, prefix="/console/api")
|
||||
CORS(
|
||||
app,
|
||||
resources={r"/console/api/.*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}},
|
||||
|
||||
@@ -55,7 +55,7 @@ class TypeMismatchError(Exception):
|
||||
|
||||
|
||||
# Define the constant
|
||||
SEGMENT_TO_VARIABLE_MAP = {
|
||||
SEGMENT_TO_VARIABLE_MAP: Mapping[type[Segment], type[VariableBase]] = {
|
||||
ArrayAnySegment: ArrayAnyVariable,
|
||||
ArrayBooleanSegment: ArrayBooleanVariable,
|
||||
ArrayFileSegment: ArrayFileVariable,
|
||||
@@ -296,13 +296,11 @@ def segment_to_variable(
|
||||
raise UnsupportedSegmentTypeError(f"not supported segment type {segment_type}")
|
||||
|
||||
variable_class = SEGMENT_TO_VARIABLE_MAP[segment_type]
|
||||
return cast(
|
||||
VariableBase,
|
||||
variable_class(
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
value=segment.value,
|
||||
selector=list(selector),
|
||||
),
|
||||
return variable_class(
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
value_type=segment.value_type,
|
||||
value=segment.value,
|
||||
selector=list(selector),
|
||||
)
|
||||
|
||||
@@ -32,6 +32,11 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _stream_with_request_context(response: object) -> Any:
|
||||
"""Bridge Flask's loosely-typed streaming helper without leaking casts into callers."""
|
||||
return cast(Any, stream_with_context)(response)
|
||||
|
||||
|
||||
def escape_like_pattern(pattern: str) -> str:
|
||||
"""
|
||||
Escape special characters in a string for safe use in SQL LIKE patterns.
|
||||
@@ -286,22 +291,32 @@ def generate_text_hash(text: str) -> str:
|
||||
return sha256(hash_text.encode()).hexdigest()
|
||||
|
||||
|
||||
def compact_generate_response(response: Union[Mapping, Generator, RateLimitGenerator]) -> Response:
|
||||
if isinstance(response, dict):
|
||||
def compact_generate_response(
|
||||
response: Mapping[str, Any] | Generator[str, None, None] | RateLimitGenerator,
|
||||
) -> Response:
|
||||
if isinstance(response, Mapping):
|
||||
return Response(
|
||||
response=json.dumps(jsonable_encoder(response)),
|
||||
status=200,
|
||||
content_type="application/json; charset=utf-8",
|
||||
)
|
||||
else:
|
||||
stream_response = response
|
||||
|
||||
def generate() -> Generator:
|
||||
yield from response
|
||||
def generate() -> Generator[str, None, None]:
|
||||
yield from stream_response
|
||||
|
||||
return Response(stream_with_context(generate()), status=200, mimetype="text/event-stream")
|
||||
return Response(
|
||||
_stream_with_request_context(generate()),
|
||||
status=200,
|
||||
mimetype="text/event-stream",
|
||||
)
|
||||
|
||||
|
||||
def length_prefixed_response(magic_number: int, response: Union[Mapping, Generator, RateLimitGenerator]) -> Response:
|
||||
def length_prefixed_response(
|
||||
magic_number: int,
|
||||
response: Mapping[str, Any] | BaseModel | Generator[str | bytes, None, None] | RateLimitGenerator,
|
||||
) -> Response:
|
||||
"""
|
||||
This function is used to return a response with a length prefix.
|
||||
Magic number is a one byte number that indicates the type of the response.
|
||||
@@ -332,7 +347,7 @@ def length_prefixed_response(magic_number: int, response: Union[Mapping, Generat
|
||||
# | Magic Number 1byte | Reserved 1byte | Header Length 2bytes | Data Length 4bytes | Reserved 6bytes | Data
|
||||
return struct.pack("<BBHI", magic_number, 0, header_length, data_length) + b"\x00" * 6 + response
|
||||
|
||||
if isinstance(response, dict):
|
||||
if isinstance(response, Mapping):
|
||||
return Response(
|
||||
response=pack_response_with_length_prefix(json.dumps(jsonable_encoder(response)).encode("utf-8")),
|
||||
status=200,
|
||||
@@ -345,14 +360,20 @@ def length_prefixed_response(magic_number: int, response: Union[Mapping, Generat
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
def generate() -> Generator:
|
||||
for chunk in response:
|
||||
stream_response = response
|
||||
|
||||
def generate() -> Generator[bytes, None, None]:
|
||||
for chunk in stream_response:
|
||||
if isinstance(chunk, str):
|
||||
yield pack_response_with_length_prefix(chunk.encode("utf-8"))
|
||||
else:
|
||||
yield pack_response_with_length_prefix(chunk)
|
||||
|
||||
return Response(stream_with_context(generate()), status=200, mimetype="text/event-stream")
|
||||
return Response(
|
||||
_stream_with_request_context(generate()),
|
||||
status=200,
|
||||
mimetype="text/event-stream",
|
||||
)
|
||||
|
||||
|
||||
class TokenManager:
|
||||
|
||||
@@ -77,12 +77,14 @@ def login_required(func: Callable[P, R]) -> Callable[P, R | ResponseReturnValue]
|
||||
@wraps(func)
|
||||
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R | ResponseReturnValue:
|
||||
if request.method in EXEMPT_METHODS or dify_config.LOGIN_DISABLED:
|
||||
pass
|
||||
elif current_user is not None and not current_user.is_authenticated:
|
||||
return current_app.ensure_sync(func)(*args, **kwargs)
|
||||
|
||||
user = _get_user()
|
||||
if user is None or not user.is_authenticated:
|
||||
return current_app.login_manager.unauthorized() # type: ignore
|
||||
# we put csrf validation here for less conflicts
|
||||
# TODO: maybe find a better place for it.
|
||||
check_csrf_token(request, current_user.id)
|
||||
check_csrf_token(request, user.id)
|
||||
return current_app.ensure_sync(func)(*args, **kwargs)
|
||||
|
||||
return decorated_view
|
||||
|
||||
@@ -7,9 +7,10 @@ https://github.com/django/django/blob/main/django/utils/module_loading.py
|
||||
|
||||
import sys
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
|
||||
def cached_import(module_path: str, class_name: str):
|
||||
def cached_import(module_path: str, class_name: str) -> Any:
|
||||
"""
|
||||
Import a module and return the named attribute/class from it, with caching.
|
||||
|
||||
@@ -20,16 +21,14 @@ def cached_import(module_path: str, class_name: str):
|
||||
Returns:
|
||||
The imported attribute/class
|
||||
"""
|
||||
if not (
|
||||
(module := sys.modules.get(module_path))
|
||||
and (spec := getattr(module, "__spec__", None))
|
||||
and getattr(spec, "_initializing", False) is False
|
||||
):
|
||||
module = sys.modules.get(module_path)
|
||||
spec = getattr(module, "__spec__", None) if module is not None else None
|
||||
if module is None or getattr(spec, "_initializing", False):
|
||||
module = import_module(module_path)
|
||||
return getattr(module, class_name)
|
||||
|
||||
|
||||
def import_string(dotted_path: str):
|
||||
def import_string(dotted_path: str) -> Any:
|
||||
"""
|
||||
Import a dotted module path and return the attribute/class designated by
|
||||
the last name in the path. Raise ImportError if the import failed.
|
||||
|
||||
@@ -1,7 +1,48 @@
|
||||
import sys
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from typing import NotRequired
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import TypedDict
|
||||
else:
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
JsonObject = dict[str, object]
|
||||
JsonObjectList = list[JsonObject]
|
||||
|
||||
JSON_OBJECT_ADAPTER = TypeAdapter(JsonObject)
|
||||
JSON_OBJECT_LIST_ADAPTER = TypeAdapter(JsonObjectList)
|
||||
|
||||
|
||||
class AccessTokenResponse(TypedDict, total=False):
|
||||
access_token: str
|
||||
|
||||
|
||||
class GitHubEmailRecord(TypedDict, total=False):
|
||||
email: str
|
||||
primary: bool
|
||||
|
||||
|
||||
class GitHubRawUserInfo(TypedDict):
|
||||
id: int | str
|
||||
login: str
|
||||
name: NotRequired[str]
|
||||
email: NotRequired[str]
|
||||
|
||||
|
||||
class GoogleRawUserInfo(TypedDict):
|
||||
sub: str
|
||||
email: str
|
||||
|
||||
|
||||
ACCESS_TOKEN_RESPONSE_ADAPTER = TypeAdapter(AccessTokenResponse)
|
||||
GITHUB_RAW_USER_INFO_ADAPTER = TypeAdapter(GitHubRawUserInfo)
|
||||
GITHUB_EMAIL_RECORDS_ADAPTER = TypeAdapter(list[GitHubEmailRecord])
|
||||
GOOGLE_RAW_USER_INFO_ADAPTER = TypeAdapter(GoogleRawUserInfo)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -11,26 +52,38 @@ class OAuthUserInfo:
|
||||
email: str
|
||||
|
||||
|
||||
def _json_object(response: httpx.Response) -> JsonObject:
|
||||
return JSON_OBJECT_ADAPTER.validate_python(response.json())
|
||||
|
||||
|
||||
def _json_list(response: httpx.Response) -> JsonObjectList:
|
||||
return JSON_OBJECT_LIST_ADAPTER.validate_python(response.json())
|
||||
|
||||
|
||||
class OAuth:
|
||||
client_id: str
|
||||
client_secret: str
|
||||
redirect_uri: str
|
||||
|
||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
def get_authorization_url(self):
|
||||
def get_authorization_url(self, invite_token: str | None = None) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_access_token(self, code: str):
|
||||
def get_access_token(self, code: str) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_raw_user_info(self, token: str):
|
||||
def get_raw_user_info(self, token: str) -> JsonObject:
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_user_info(self, token: str) -> OAuthUserInfo:
|
||||
raw_info = self.get_raw_user_info(token)
|
||||
return self._transform_user_info(raw_info)
|
||||
|
||||
def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo:
|
||||
def _transform_user_info(self, raw_info: JsonObject) -> OAuthUserInfo:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@@ -40,7 +93,7 @@ class GitHubOAuth(OAuth):
|
||||
_USER_INFO_URL = "https://api.github.com/user"
|
||||
_EMAIL_INFO_URL = "https://api.github.com/user/emails"
|
||||
|
||||
def get_authorization_url(self, invite_token: str | None = None):
|
||||
def get_authorization_url(self, invite_token: str | None = None) -> str:
|
||||
params = {
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
@@ -50,7 +103,7 @@ class GitHubOAuth(OAuth):
|
||||
params["state"] = invite_token
|
||||
return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
def get_access_token(self, code: str):
|
||||
def get_access_token(self, code: str) -> str:
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
@@ -60,7 +113,7 @@ class GitHubOAuth(OAuth):
|
||||
headers = {"Accept": "application/json"}
|
||||
response = httpx.post(self._TOKEN_URL, data=data, headers=headers)
|
||||
|
||||
response_json = response.json()
|
||||
response_json = ACCESS_TOKEN_RESPONSE_ADAPTER.validate_python(_json_object(response))
|
||||
access_token = response_json.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
@@ -68,23 +121,24 @@ class GitHubOAuth(OAuth):
|
||||
|
||||
return access_token
|
||||
|
||||
def get_raw_user_info(self, token: str):
|
||||
def get_raw_user_info(self, token: str) -> JsonObject:
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
response = httpx.get(self._USER_INFO_URL, headers=headers)
|
||||
response.raise_for_status()
|
||||
user_info = response.json()
|
||||
user_info = GITHUB_RAW_USER_INFO_ADAPTER.validate_python(_json_object(response))
|
||||
|
||||
email_response = httpx.get(self._EMAIL_INFO_URL, headers=headers)
|
||||
email_info = email_response.json()
|
||||
primary_email: dict = next((email for email in email_info if email["primary"] == True), {})
|
||||
email_info = GITHUB_EMAIL_RECORDS_ADAPTER.validate_python(_json_list(email_response))
|
||||
primary_email = next((email for email in email_info if email.get("primary") is True), None)
|
||||
|
||||
return {**user_info, "email": primary_email.get("email", "")}
|
||||
return {**user_info, "email": primary_email.get("email", "") if primary_email else ""}
|
||||
|
||||
def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo:
|
||||
email = raw_info.get("email")
|
||||
def _transform_user_info(self, raw_info: JsonObject) -> OAuthUserInfo:
|
||||
payload = GITHUB_RAW_USER_INFO_ADAPTER.validate_python(raw_info)
|
||||
email = payload.get("email")
|
||||
if not email:
|
||||
email = f"{raw_info['id']}+{raw_info['login']}@users.noreply.github.com"
|
||||
return OAuthUserInfo(id=str(raw_info["id"]), name=raw_info["name"], email=email)
|
||||
email = f"{payload['id']}+{payload['login']}@users.noreply.github.com"
|
||||
return OAuthUserInfo(id=str(payload["id"]), name=str(payload.get("name", "")), email=email)
|
||||
|
||||
|
||||
class GoogleOAuth(OAuth):
|
||||
@@ -92,7 +146,7 @@ class GoogleOAuth(OAuth):
|
||||
_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
_USER_INFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
|
||||
|
||||
def get_authorization_url(self, invite_token: str | None = None):
|
||||
def get_authorization_url(self, invite_token: str | None = None) -> str:
|
||||
params = {
|
||||
"client_id": self.client_id,
|
||||
"response_type": "code",
|
||||
@@ -103,7 +157,7 @@ class GoogleOAuth(OAuth):
|
||||
params["state"] = invite_token
|
||||
return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
def get_access_token(self, code: str):
|
||||
def get_access_token(self, code: str) -> str:
|
||||
data = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
@@ -114,7 +168,7 @@ class GoogleOAuth(OAuth):
|
||||
headers = {"Accept": "application/json"}
|
||||
response = httpx.post(self._TOKEN_URL, data=data, headers=headers)
|
||||
|
||||
response_json = response.json()
|
||||
response_json = ACCESS_TOKEN_RESPONSE_ADAPTER.validate_python(_json_object(response))
|
||||
access_token = response_json.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
@@ -122,11 +176,12 @@ class GoogleOAuth(OAuth):
|
||||
|
||||
return access_token
|
||||
|
||||
def get_raw_user_info(self, token: str):
|
||||
def get_raw_user_info(self, token: str) -> JsonObject:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = httpx.get(self._USER_INFO_URL, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return _json_object(response)
|
||||
|
||||
def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo:
|
||||
return OAuthUserInfo(id=str(raw_info["sub"]), name="", email=raw_info["email"])
|
||||
def _transform_user_info(self, raw_info: JsonObject) -> OAuthUserInfo:
|
||||
payload = GOOGLE_RAW_USER_INFO_ADAPTER.validate_python(raw_info)
|
||||
return OAuthUserInfo(id=str(payload["sub"]), name="", email=payload["email"])
|
||||
|
||||
@@ -1,25 +1,57 @@
|
||||
import sys
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from flask_login import current_user
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.source import DataSourceOauthBinding
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import TypedDict
|
||||
else:
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
||||
class NotionPageSummary(TypedDict):
|
||||
page_id: str
|
||||
page_name: str
|
||||
page_icon: dict[str, str] | None
|
||||
parent_id: str
|
||||
type: Literal["page", "database"]
|
||||
|
||||
|
||||
class NotionSourceInfo(TypedDict):
|
||||
workspace_name: str | None
|
||||
workspace_icon: str | None
|
||||
workspace_id: str | None
|
||||
pages: list[NotionPageSummary]
|
||||
total: int
|
||||
|
||||
|
||||
SOURCE_INFO_STORAGE_ADAPTER = TypeAdapter(dict[str, object])
|
||||
NOTION_SOURCE_INFO_ADAPTER = TypeAdapter(NotionSourceInfo)
|
||||
NOTION_PAGE_SUMMARY_ADAPTER = TypeAdapter(NotionPageSummary)
|
||||
|
||||
|
||||
class OAuthDataSource:
|
||||
client_id: str
|
||||
client_secret: str
|
||||
redirect_uri: str
|
||||
|
||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
def get_authorization_url(self):
|
||||
def get_authorization_url(self) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_access_token(self, code: str):
|
||||
def get_access_token(self, code: str) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@@ -30,7 +62,7 @@ class NotionOAuth(OAuthDataSource):
|
||||
_NOTION_BLOCK_SEARCH = "https://api.notion.com/v1/blocks"
|
||||
_NOTION_BOT_USER = "https://api.notion.com/v1/users/me"
|
||||
|
||||
def get_authorization_url(self):
|
||||
def get_authorization_url(self) -> str:
|
||||
params = {
|
||||
"client_id": self.client_id,
|
||||
"response_type": "code",
|
||||
@@ -39,7 +71,7 @@ class NotionOAuth(OAuthDataSource):
|
||||
}
|
||||
return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
def get_access_token(self, code: str):
|
||||
def get_access_token(self, code: str) -> None:
|
||||
data = {"code": code, "grant_type": "authorization_code", "redirect_uri": self.redirect_uri}
|
||||
headers = {"Accept": "application/json"}
|
||||
auth = (self.client_id, self.client_secret)
|
||||
@@ -54,13 +86,12 @@ class NotionOAuth(OAuthDataSource):
|
||||
workspace_id = response_json.get("workspace_id")
|
||||
# get all authorized pages
|
||||
pages = self.get_authorized_pages(access_token)
|
||||
source_info = {
|
||||
"workspace_name": workspace_name,
|
||||
"workspace_icon": workspace_icon,
|
||||
"workspace_id": workspace_id,
|
||||
"pages": pages,
|
||||
"total": len(pages),
|
||||
}
|
||||
source_info = self._build_source_info(
|
||||
workspace_name=workspace_name,
|
||||
workspace_icon=workspace_icon,
|
||||
workspace_id=workspace_id,
|
||||
pages=pages,
|
||||
)
|
||||
# save data source binding
|
||||
data_source_binding = db.session.scalar(
|
||||
select(DataSourceOauthBinding).where(
|
||||
@@ -70,7 +101,7 @@ class NotionOAuth(OAuthDataSource):
|
||||
)
|
||||
)
|
||||
if data_source_binding:
|
||||
data_source_binding.source_info = source_info
|
||||
data_source_binding.source_info = SOURCE_INFO_STORAGE_ADAPTER.validate_python(source_info)
|
||||
data_source_binding.disabled = False
|
||||
data_source_binding.updated_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
@@ -78,25 +109,24 @@ class NotionOAuth(OAuthDataSource):
|
||||
new_data_source_binding = DataSourceOauthBinding(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
access_token=access_token,
|
||||
source_info=source_info,
|
||||
source_info=SOURCE_INFO_STORAGE_ADAPTER.validate_python(source_info),
|
||||
provider="notion",
|
||||
)
|
||||
db.session.add(new_data_source_binding)
|
||||
db.session.commit()
|
||||
|
||||
def save_internal_access_token(self, access_token: str):
|
||||
def save_internal_access_token(self, access_token: str) -> None:
|
||||
workspace_name = self.notion_workspace_name(access_token)
|
||||
workspace_icon = None
|
||||
workspace_id = current_user.current_tenant_id
|
||||
# get all authorized pages
|
||||
pages = self.get_authorized_pages(access_token)
|
||||
source_info = {
|
||||
"workspace_name": workspace_name,
|
||||
"workspace_icon": workspace_icon,
|
||||
"workspace_id": workspace_id,
|
||||
"pages": pages,
|
||||
"total": len(pages),
|
||||
}
|
||||
source_info = self._build_source_info(
|
||||
workspace_name=workspace_name,
|
||||
workspace_icon=workspace_icon,
|
||||
workspace_id=workspace_id,
|
||||
pages=pages,
|
||||
)
|
||||
# save data source binding
|
||||
data_source_binding = db.session.scalar(
|
||||
select(DataSourceOauthBinding).where(
|
||||
@@ -106,7 +136,7 @@ class NotionOAuth(OAuthDataSource):
|
||||
)
|
||||
)
|
||||
if data_source_binding:
|
||||
data_source_binding.source_info = source_info
|
||||
data_source_binding.source_info = SOURCE_INFO_STORAGE_ADAPTER.validate_python(source_info)
|
||||
data_source_binding.disabled = False
|
||||
data_source_binding.updated_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
@@ -114,13 +144,13 @@ class NotionOAuth(OAuthDataSource):
|
||||
new_data_source_binding = DataSourceOauthBinding(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
access_token=access_token,
|
||||
source_info=source_info,
|
||||
source_info=SOURCE_INFO_STORAGE_ADAPTER.validate_python(source_info),
|
||||
provider="notion",
|
||||
)
|
||||
db.session.add(new_data_source_binding)
|
||||
db.session.commit()
|
||||
|
||||
def sync_data_source(self, binding_id: str):
|
||||
def sync_data_source(self, binding_id: str) -> None:
|
||||
# save data source binding
|
||||
data_source_binding = db.session.scalar(
|
||||
select(DataSourceOauthBinding).where(
|
||||
@@ -134,23 +164,22 @@ class NotionOAuth(OAuthDataSource):
|
||||
if data_source_binding:
|
||||
# get all authorized pages
|
||||
pages = self.get_authorized_pages(data_source_binding.access_token)
|
||||
source_info = data_source_binding.source_info
|
||||
new_source_info = {
|
||||
"workspace_name": source_info["workspace_name"],
|
||||
"workspace_icon": source_info["workspace_icon"],
|
||||
"workspace_id": source_info["workspace_id"],
|
||||
"pages": pages,
|
||||
"total": len(pages),
|
||||
}
|
||||
data_source_binding.source_info = new_source_info
|
||||
source_info = NOTION_SOURCE_INFO_ADAPTER.validate_python(data_source_binding.source_info)
|
||||
new_source_info = self._build_source_info(
|
||||
workspace_name=source_info["workspace_name"],
|
||||
workspace_icon=source_info["workspace_icon"],
|
||||
workspace_id=source_info["workspace_id"],
|
||||
pages=pages,
|
||||
)
|
||||
data_source_binding.source_info = SOURCE_INFO_STORAGE_ADAPTER.validate_python(new_source_info)
|
||||
data_source_binding.disabled = False
|
||||
data_source_binding.updated_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
else:
|
||||
raise ValueError("Data source binding not found")
|
||||
|
||||
def get_authorized_pages(self, access_token: str):
|
||||
pages = []
|
||||
def get_authorized_pages(self, access_token: str) -> list[NotionPageSummary]:
|
||||
pages: list[NotionPageSummary] = []
|
||||
page_results = self.notion_page_search(access_token)
|
||||
database_results = self.notion_database_search(access_token)
|
||||
# get page detail
|
||||
@@ -187,7 +216,7 @@ class NotionOAuth(OAuthDataSource):
|
||||
"parent_id": parent_id,
|
||||
"type": "page",
|
||||
}
|
||||
pages.append(page)
|
||||
pages.append(NOTION_PAGE_SUMMARY_ADAPTER.validate_python(page))
|
||||
# get database detail
|
||||
for database_result in database_results:
|
||||
page_id = database_result["id"]
|
||||
@@ -220,11 +249,11 @@ class NotionOAuth(OAuthDataSource):
|
||||
"parent_id": parent_id,
|
||||
"type": "database",
|
||||
}
|
||||
pages.append(page)
|
||||
pages.append(NOTION_PAGE_SUMMARY_ADAPTER.validate_python(page))
|
||||
return pages
|
||||
|
||||
def notion_page_search(self, access_token: str):
|
||||
results = []
|
||||
def notion_page_search(self, access_token: str) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
next_cursor = None
|
||||
has_more = True
|
||||
|
||||
@@ -249,7 +278,7 @@ class NotionOAuth(OAuthDataSource):
|
||||
|
||||
return results
|
||||
|
||||
def notion_block_parent_page_id(self, access_token: str, block_id: str):
|
||||
def notion_block_parent_page_id(self, access_token: str, block_id: str) -> str:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Notion-Version": "2022-06-28",
|
||||
@@ -265,7 +294,7 @@ class NotionOAuth(OAuthDataSource):
|
||||
return self.notion_block_parent_page_id(access_token, parent[parent_type])
|
||||
return parent[parent_type]
|
||||
|
||||
def notion_workspace_name(self, access_token: str):
|
||||
def notion_workspace_name(self, access_token: str) -> str:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Notion-Version": "2022-06-28",
|
||||
@@ -279,8 +308,8 @@ class NotionOAuth(OAuthDataSource):
|
||||
return user_info["workspace_name"]
|
||||
return "workspace"
|
||||
|
||||
def notion_database_search(self, access_token: str):
|
||||
results = []
|
||||
def notion_database_search(self, access_token: str) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
next_cursor = None
|
||||
has_more = True
|
||||
|
||||
@@ -303,3 +332,19 @@ class NotionOAuth(OAuthDataSource):
|
||||
next_cursor = response_json.get("next_cursor", None)
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _build_source_info(
|
||||
*,
|
||||
workspace_name: str | None,
|
||||
workspace_icon: str | None,
|
||||
workspace_id: str | None,
|
||||
pages: list[NotionPageSummary],
|
||||
) -> NotionSourceInfo:
|
||||
return {
|
||||
"workspace_name": workspace_name,
|
||||
"workspace_icon": workspace_icon,
|
||||
"workspace_id": workspace_id,
|
||||
"pages": pages,
|
||||
"total": len(pages),
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ from .enums import AppTriggerStatus, AppTriggerType, CreatorUserRole, WorkflowTr
|
||||
from .model import Account
|
||||
from .types import EnumText, LongText, StringUUID
|
||||
|
||||
TriggerJsonObject = dict[str, object]
|
||||
TriggerCredentials = dict[str, str]
|
||||
|
||||
|
||||
class WorkflowTriggerLogDict(TypedDict):
|
||||
id: str
|
||||
@@ -89,10 +92,14 @@ class TriggerSubscription(TypeBase):
|
||||
String(255), nullable=False, comment="Provider identifier (e.g., plugin_id/provider_name)"
|
||||
)
|
||||
endpoint_id: Mapped[str] = mapped_column(String(255), nullable=False, comment="Subscription endpoint")
|
||||
parameters: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False, comment="Subscription parameters JSON")
|
||||
properties: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False, comment="Subscription properties JSON")
|
||||
parameters: Mapped[TriggerJsonObject] = mapped_column(
|
||||
sa.JSON, nullable=False, comment="Subscription parameters JSON"
|
||||
)
|
||||
properties: Mapped[TriggerJsonObject] = mapped_column(
|
||||
sa.JSON, nullable=False, comment="Subscription properties JSON"
|
||||
)
|
||||
|
||||
credentials: Mapped[dict[str, Any]] = mapped_column(
|
||||
credentials: Mapped[TriggerCredentials] = mapped_column(
|
||||
sa.JSON, nullable=False, comment="Subscription credentials JSON"
|
||||
)
|
||||
credential_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="oauth or api_key")
|
||||
@@ -200,8 +207,8 @@ class TriggerOAuthTenantClient(TypeBase):
|
||||
)
|
||||
|
||||
@property
|
||||
def oauth_params(self) -> Mapping[str, Any]:
|
||||
return cast(Mapping[str, Any], json.loads(self.encrypted_oauth_params or "{}"))
|
||||
def oauth_params(self) -> Mapping[str, object]:
|
||||
return cast(TriggerJsonObject, json.loads(self.encrypted_oauth_params or "{}"))
|
||||
|
||||
|
||||
class WorkflowTriggerLog(TypeBase):
|
||||
|
||||
@@ -19,7 +19,7 @@ from sqlalchemy import (
|
||||
orm,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, declared_attr, mapped_column
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from typing_extensions import deprecated
|
||||
|
||||
from core.trigger.constants import TRIGGER_INFO_METADATA_KEY, TRIGGER_PLUGIN_NODE_TYPE
|
||||
@@ -33,7 +33,7 @@ from dify_graph.enums import BuiltinNodeTypes, NodeType, WorkflowExecutionStatus
|
||||
from dify_graph.file.constants import maybe_file_object
|
||||
from dify_graph.file.models import File
|
||||
from dify_graph.variables import utils as variable_utils
|
||||
from dify_graph.variables.variables import FloatVariable, IntegerVariable, StringVariable
|
||||
from dify_graph.variables.variables import FloatVariable, IntegerVariable, RAGPipelineVariable, StringVariable
|
||||
from extensions.ext_storage import Storage
|
||||
from factories.variable_factory import TypeMismatchError, build_segment_with_type
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
@@ -59,6 +59,9 @@ from .types import EnumText, LongText, StringUUID
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SerializedWorkflowValue = dict[str, Any]
|
||||
SerializedWorkflowVariables = dict[str, SerializedWorkflowValue]
|
||||
|
||||
|
||||
class WorkflowContentDict(TypedDict):
|
||||
graph: Mapping[str, Any]
|
||||
@@ -405,7 +408,7 @@ class Workflow(Base): # bug
|
||||
|
||||
def rag_pipeline_user_input_form(self) -> list:
|
||||
# get user_input_form from start node
|
||||
variables: list[Any] = self.rag_pipeline_variables
|
||||
variables: list[SerializedWorkflowValue] = self.rag_pipeline_variables
|
||||
|
||||
return variables
|
||||
|
||||
@@ -448,17 +451,13 @@ class Workflow(Base): # bug
|
||||
def environment_variables(
|
||||
self,
|
||||
) -> Sequence[StringVariable | IntegerVariable | FloatVariable | SecretVariable]:
|
||||
# TODO: find some way to init `self._environment_variables` when instance created.
|
||||
if self._environment_variables is None:
|
||||
self._environment_variables = "{}"
|
||||
|
||||
# Use workflow.tenant_id to avoid relying on request user in background threads
|
||||
tenant_id = self.tenant_id
|
||||
|
||||
if not tenant_id:
|
||||
return []
|
||||
|
||||
environment_variables_dict: dict[str, Any] = json.loads(self._environment_variables or "{}")
|
||||
environment_variables_dict = cast(SerializedWorkflowVariables, json.loads(self._environment_variables or "{}"))
|
||||
results = [
|
||||
variable_factory.build_environment_variable_from_mapping(v) for v in environment_variables_dict.values()
|
||||
]
|
||||
@@ -536,11 +535,7 @@ class Workflow(Base): # bug
|
||||
|
||||
@property
|
||||
def conversation_variables(self) -> Sequence[VariableBase]:
|
||||
# TODO: find some way to init `self._conversation_variables` when instance created.
|
||||
if self._conversation_variables is None:
|
||||
self._conversation_variables = "{}"
|
||||
|
||||
variables_dict: dict[str, Any] = json.loads(self._conversation_variables)
|
||||
variables_dict = cast(SerializedWorkflowVariables, json.loads(self._conversation_variables or "{}"))
|
||||
results = [variable_factory.build_conversation_variable_from_mapping(v) for v in variables_dict.values()]
|
||||
return results
|
||||
|
||||
@@ -552,19 +547,20 @@ class Workflow(Base): # bug
|
||||
)
|
||||
|
||||
@property
|
||||
def rag_pipeline_variables(self) -> list[dict]:
|
||||
# TODO: find some way to init `self._conversation_variables` when instance created.
|
||||
if self._rag_pipeline_variables is None:
|
||||
self._rag_pipeline_variables = "{}"
|
||||
|
||||
variables_dict: dict[str, Any] = json.loads(self._rag_pipeline_variables)
|
||||
results = list(variables_dict.values())
|
||||
return results
|
||||
def rag_pipeline_variables(self) -> list[SerializedWorkflowValue]:
|
||||
variables_dict = cast(SerializedWorkflowVariables, json.loads(self._rag_pipeline_variables or "{}"))
|
||||
return [RAGPipelineVariable.model_validate(item).model_dump(mode="json") for item in variables_dict.values()]
|
||||
|
||||
@rag_pipeline_variables.setter
|
||||
def rag_pipeline_variables(self, values: list[dict]) -> None:
|
||||
def rag_pipeline_variables(self, values: Sequence[Mapping[str, Any] | RAGPipelineVariable]) -> None:
|
||||
self._rag_pipeline_variables = json.dumps(
|
||||
{item["variable"]: item for item in values},
|
||||
{
|
||||
rag_pipeline_variable.variable: rag_pipeline_variable.model_dump(mode="json")
|
||||
for rag_pipeline_variable in (
|
||||
item if isinstance(item, RAGPipelineVariable) else RAGPipelineVariable.model_validate(item)
|
||||
for item in values
|
||||
)
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@@ -802,44 +798,36 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo
|
||||
|
||||
__tablename__ = "workflow_node_executions"
|
||||
|
||||
@declared_attr.directive
|
||||
@classmethod
|
||||
def __table_args__(cls) -> Any:
|
||||
return (
|
||||
PrimaryKeyConstraint("id", name="workflow_node_execution_pkey"),
|
||||
Index(
|
||||
"workflow_node_execution_workflow_run_id_idx",
|
||||
"workflow_run_id",
|
||||
),
|
||||
Index(
|
||||
"workflow_node_execution_node_run_idx",
|
||||
"tenant_id",
|
||||
"app_id",
|
||||
"workflow_id",
|
||||
"triggered_from",
|
||||
"node_id",
|
||||
),
|
||||
Index(
|
||||
"workflow_node_execution_id_idx",
|
||||
"tenant_id",
|
||||
"app_id",
|
||||
"workflow_id",
|
||||
"triggered_from",
|
||||
"node_execution_id",
|
||||
),
|
||||
Index(
|
||||
# The first argument is the index name,
|
||||
# which we leave as `None`` to allow auto-generation by the ORM.
|
||||
None,
|
||||
cls.tenant_id,
|
||||
cls.workflow_id,
|
||||
cls.node_id,
|
||||
# MyPy may flag the following line because it doesn't recognize that
|
||||
# the `declared_attr` decorator passes the receiving class as the first
|
||||
# argument to this method, allowing us to reference class attributes.
|
||||
cls.created_at.desc(),
|
||||
),
|
||||
)
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id", name="workflow_node_execution_pkey"),
|
||||
Index(
|
||||
"workflow_node_execution_workflow_run_id_idx",
|
||||
"workflow_run_id",
|
||||
),
|
||||
Index(
|
||||
"workflow_node_execution_node_run_idx",
|
||||
"tenant_id",
|
||||
"app_id",
|
||||
"workflow_id",
|
||||
"triggered_from",
|
||||
"node_id",
|
||||
),
|
||||
Index(
|
||||
"workflow_node_execution_id_idx",
|
||||
"tenant_id",
|
||||
"app_id",
|
||||
"workflow_id",
|
||||
"triggered_from",
|
||||
"node_execution_id",
|
||||
),
|
||||
Index(
|
||||
None,
|
||||
"tenant_id",
|
||||
"workflow_id",
|
||||
"node_id",
|
||||
sa.desc("created_at"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
configs/middleware/cache/redis_pubsub_config.py
|
||||
controllers/console/app/annotation.py
|
||||
controllers/console/app/app.py
|
||||
controllers/console/app/app_import.py
|
||||
@@ -138,8 +137,6 @@ dify_graph/nodes/trigger_webhook/node.py
|
||||
dify_graph/nodes/variable_aggregator/variable_aggregator_node.py
|
||||
dify_graph/nodes/variable_assigner/v1/node.py
|
||||
dify_graph/nodes/variable_assigner/v2/node.py
|
||||
dify_graph/variables/types.py
|
||||
extensions/ext_fastopenapi.py
|
||||
extensions/logstore/repositories/logstore_api_workflow_run_repository.py
|
||||
extensions/otel/instrumentation.py
|
||||
extensions/otel/runtime.py
|
||||
@@ -156,19 +153,7 @@ extensions/storage/oracle_oci_storage.py
|
||||
extensions/storage/supabase_storage.py
|
||||
extensions/storage/tencent_cos_storage.py
|
||||
extensions/storage/volcengine_tos_storage.py
|
||||
factories/variable_factory.py
|
||||
libs/external_api.py
|
||||
libs/gmpy2_pkcs10aep_cipher.py
|
||||
libs/helper.py
|
||||
libs/login.py
|
||||
libs/module_loading.py
|
||||
libs/oauth.py
|
||||
libs/oauth_data_source.py
|
||||
models/trigger.py
|
||||
models/workflow.py
|
||||
repositories/sqlalchemy_api_workflow_node_execution_repository.py
|
||||
repositories/sqlalchemy_api_workflow_run_repository.py
|
||||
repositories/sqlalchemy_execution_extra_content_repository.py
|
||||
schedule/queue_monitor_task.py
|
||||
services/account_service.py
|
||||
services/audio_service.py
|
||||
|
||||
@@ -8,7 +8,7 @@ using SQLAlchemy 2.0 style queries for WorkflowNodeExecutionModel operations.
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
from typing import Protocol, cast
|
||||
|
||||
from sqlalchemy import asc, delete, desc, func, select
|
||||
from sqlalchemy.engine import CursorResult
|
||||
@@ -22,6 +22,20 @@ from repositories.api_workflow_node_execution_repository import (
|
||||
)
|
||||
|
||||
|
||||
class _WorkflowNodeExecutionSnapshotRow(Protocol):
|
||||
id: str
|
||||
node_execution_id: str | None
|
||||
node_id: str
|
||||
node_type: str
|
||||
title: str
|
||||
index: int
|
||||
status: WorkflowNodeExecutionStatus
|
||||
elapsed_time: float | None
|
||||
created_at: datetime
|
||||
finished_at: datetime | None
|
||||
execution_metadata: str | None
|
||||
|
||||
|
||||
class DifyAPISQLAlchemyWorkflowNodeExecutionRepository(DifyAPIWorkflowNodeExecutionRepository):
|
||||
"""
|
||||
SQLAlchemy implementation of DifyAPIWorkflowNodeExecutionRepository.
|
||||
@@ -40,6 +54,8 @@ class DifyAPISQLAlchemyWorkflowNodeExecutionRepository(DifyAPIWorkflowNodeExecut
|
||||
- Thread-safe database operations using session-per-request pattern
|
||||
"""
|
||||
|
||||
_session_maker: sessionmaker[Session]
|
||||
|
||||
def __init__(self, session_maker: sessionmaker[Session]):
|
||||
"""
|
||||
Initialize the repository with a sessionmaker.
|
||||
@@ -156,12 +172,12 @@ class DifyAPISQLAlchemyWorkflowNodeExecutionRepository(DifyAPIWorkflowNodeExecut
|
||||
)
|
||||
|
||||
with self._session_maker() as session:
|
||||
rows = session.execute(stmt).all()
|
||||
rows = cast(Sequence[_WorkflowNodeExecutionSnapshotRow], session.execute(stmt).all())
|
||||
|
||||
return [self._row_to_snapshot(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def _row_to_snapshot(row: object) -> WorkflowNodeExecutionSnapshot:
|
||||
def _row_to_snapshot(row: _WorkflowNodeExecutionSnapshotRow) -> WorkflowNodeExecutionSnapshot:
|
||||
metadata: dict[str, object] = {}
|
||||
execution_metadata = getattr(row, "execution_metadata", None)
|
||||
if execution_metadata:
|
||||
|
||||
@@ -30,7 +30,7 @@ from core.plugin.impl.debugging import PluginDebuggingClient
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.provider import Provider, ProviderCredential
|
||||
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
|
||||
from models.provider_ids import GenericProviderID
|
||||
from services.enterprise.plugin_manager_service import (
|
||||
PluginManagerService,
|
||||
@@ -534,6 +534,13 @@ class PluginService:
|
||||
plugin_id = plugin.plugin_id
|
||||
logger.info("Deleting credentials for plugin: %s", plugin_id)
|
||||
|
||||
session.execute(
|
||||
delete(TenantPreferredModelProvider).where(
|
||||
TenantPreferredModelProvider.tenant_id == tenant_id,
|
||||
TenantPreferredModelProvider.provider_name.like(f"{plugin_id}/%"),
|
||||
)
|
||||
)
|
||||
|
||||
# Delete provider credentials that match this plugin
|
||||
credential_ids = session.scalars(
|
||||
select(ProviderCredential.id).where(
|
||||
|
||||
@@ -734,7 +734,7 @@ def test_create_provider_credential_creates_provider_record_when_missing() -> No
|
||||
def test_create_provider_credential_marks_existing_provider_as_valid() -> None:
|
||||
configuration = _build_provider_configuration()
|
||||
session = Mock()
|
||||
provider_record = SimpleNamespace(is_valid=False)
|
||||
provider_record = SimpleNamespace(id="provider-1", is_valid=False, credential_id="existing-cred")
|
||||
|
||||
with _patched_session(session):
|
||||
with patch.object(ProviderConfiguration, "_check_provider_credential_name_exists", return_value=False):
|
||||
@@ -743,6 +743,25 @@ def test_create_provider_credential_marks_existing_provider_as_valid() -> None:
|
||||
configuration.create_provider_credential({"api_key": "raw"}, "Main")
|
||||
|
||||
assert provider_record.is_valid is True
|
||||
assert provider_record.credential_id == "existing-cred"
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_create_provider_credential_auto_activates_when_no_active_credential() -> None:
|
||||
configuration = _build_provider_configuration()
|
||||
session = Mock()
|
||||
provider_record = SimpleNamespace(id="provider-1", is_valid=False, credential_id=None, updated_at=None)
|
||||
|
||||
with _patched_session(session):
|
||||
with patch.object(ProviderConfiguration, "_check_provider_credential_name_exists", return_value=False):
|
||||
with patch.object(ProviderConfiguration, "validate_provider_credentials", return_value={"api_key": "enc"}):
|
||||
with patch.object(ProviderConfiguration, "_get_provider_record", return_value=provider_record):
|
||||
with patch("core.entities.provider_configuration.ProviderCredentialsCache"):
|
||||
with patch.object(ProviderConfiguration, "switch_preferred_provider_type"):
|
||||
configuration.create_provider_credential({"api_key": "raw"}, "Main")
|
||||
|
||||
assert provider_record.is_valid is True
|
||||
assert provider_record.credential_id is not None
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
|
||||
@@ -295,24 +295,7 @@ describe('Pricing Modal Flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 6. Close Handling ───────────────────────────────────────────────────
|
||||
describe('Close handling', () => {
|
||||
it('should call onCancel when pressing ESC key', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// ahooks useKeyPress listens on document for keydown events
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
keyCode: 27,
|
||||
bubbles: true,
|
||||
}))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 7. Pricing URL ─────────────────────────────────────────────────────
|
||||
// ─── 6. Pricing URL ─────────────────────────────────────────────────────
|
||||
describe('Pricing page URL', () => {
|
||||
it('should render pricing link with correct URL', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
let mockTheme = 'light'
|
||||
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
@@ -19,16 +21,16 @@ vi.mock('@/context/i18n', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
default: () => ({ theme: mockTheme }),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config', () => ({
|
||||
renderI18nObject: (obj: Record<string, string>, locale: string) => obj[locale] || obj.en_US || '',
|
||||
}))
|
||||
|
||||
vi.mock('@/types/app', () => ({
|
||||
Theme: { dark: 'dark', light: 'light' },
|
||||
}))
|
||||
vi.mock('@/types/app', async () => {
|
||||
return vi.importActual<typeof import('@/types/app')>('@/types/app')
|
||||
})
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(a => typeof a === 'string' && a).join(' '),
|
||||
@@ -100,6 +102,7 @@ type CardPayload = Parameters<typeof Card>[0]['payload']
|
||||
describe('Plugin Card Rendering Integration', () => {
|
||||
beforeEach(() => {
|
||||
cleanup()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
const makePayload = (overrides = {}) => ({
|
||||
@@ -194,9 +197,7 @@ describe('Plugin Card Rendering Integration', () => {
|
||||
})
|
||||
|
||||
it('uses dark icon when theme is dark and icon_dark is provided', () => {
|
||||
vi.doMock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'dark' }),
|
||||
}))
|
||||
mockTheme = 'dark'
|
||||
|
||||
const payload = makePayload({
|
||||
icon: 'https://example.com/icon-light.png',
|
||||
@@ -204,7 +205,7 @@ describe('Plugin Card Rendering Integration', () => {
|
||||
})
|
||||
|
||||
render(<Card payload={payload} />)
|
||||
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-icon')).toHaveTextContent('https://example.com/icon-dark.png')
|
||||
})
|
||||
|
||||
it('shows loading placeholder when isLoading is true', () => {
|
||||
|
||||
@@ -160,7 +160,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
|
||||
isShow={isShowDeleteConfirm}
|
||||
onClose={() => setIsShowDeleteConfirm(false)}
|
||||
>
|
||||
<div className="title-2xl-semi-bold mb-3 text-text-primary">{t('avatar.deleteTitle', { ns: 'common' })}</div>
|
||||
<div className="mb-3 text-text-primary title-2xl-semi-bold">{t('avatar.deleteTitle', { ns: 'common' })}</div>
|
||||
<p className="mb-8 text-text-secondary">{t('avatar.deleteDescription', { ns: 'common' })}</p>
|
||||
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
|
||||
@@ -209,14 +209,14 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
||||
</div>
|
||||
{step === STEP.start && (
|
||||
<>
|
||||
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.title', { ns: 'common' })}</div>
|
||||
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.title', { ns: 'common' })}</div>
|
||||
<div className="space-y-0.5 pb-2 pt-1">
|
||||
<div className="body-md-medium text-text-warning">{t('account.changeEmail.authTip', { ns: 'common' })}</div>
|
||||
<div className="body-md-regular text-text-secondary">
|
||||
<div className="text-text-warning body-md-medium">{t('account.changeEmail.authTip', { ns: 'common' })}</div>
|
||||
<div className="text-text-secondary body-md-regular">
|
||||
<Trans
|
||||
i18nKey="account.changeEmail.content1"
|
||||
ns="common"
|
||||
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
|
||||
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
|
||||
values={{ email }}
|
||||
/>
|
||||
</div>
|
||||
@@ -241,19 +241,19 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
||||
)}
|
||||
{step === STEP.verifyOrigin && (
|
||||
<>
|
||||
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.verifyEmail', { ns: 'common' })}</div>
|
||||
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.verifyEmail', { ns: 'common' })}</div>
|
||||
<div className="space-y-0.5 pb-2 pt-1">
|
||||
<div className="body-md-regular text-text-secondary">
|
||||
<div className="text-text-secondary body-md-regular">
|
||||
<Trans
|
||||
i18nKey="account.changeEmail.content2"
|
||||
ns="common"
|
||||
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
|
||||
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
|
||||
values={{ email }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3">
|
||||
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
|
||||
<Input
|
||||
className="!w-full"
|
||||
placeholder={t('account.changeEmail.codePlaceholder', { ns: 'common' })}
|
||||
@@ -278,25 +278,25 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary">
|
||||
<div className="mt-3 flex items-center gap-1 text-text-tertiary system-xs-regular">
|
||||
<span>{t('account.changeEmail.resendTip', { ns: 'common' })}</span>
|
||||
{time > 0 && (
|
||||
<span>{t('account.changeEmail.resendCount', { ns: 'common', count: time })}</span>
|
||||
)}
|
||||
{!time && (
|
||||
<span onClick={sendCodeToOriginEmail} className="system-xs-medium cursor-pointer text-text-accent-secondary">{t('account.changeEmail.resend', { ns: 'common' })}</span>
|
||||
<span onClick={sendCodeToOriginEmail} className="cursor-pointer text-text-accent-secondary system-xs-medium">{t('account.changeEmail.resend', { ns: 'common' })}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{step === STEP.newEmail && (
|
||||
<>
|
||||
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.newEmail', { ns: 'common' })}</div>
|
||||
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.newEmail', { ns: 'common' })}</div>
|
||||
<div className="space-y-0.5 pb-2 pt-1">
|
||||
<div className="body-md-regular text-text-secondary">{t('account.changeEmail.content3', { ns: 'common' })}</div>
|
||||
<div className="text-text-secondary body-md-regular">{t('account.changeEmail.content3', { ns: 'common' })}</div>
|
||||
</div>
|
||||
<div className="pt-3">
|
||||
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('account.changeEmail.emailLabel', { ns: 'common' })}</div>
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.emailLabel', { ns: 'common' })}</div>
|
||||
<Input
|
||||
className="!w-full"
|
||||
placeholder={t('account.changeEmail.emailPlaceholder', { ns: 'common' })}
|
||||
@@ -305,10 +305,10 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
||||
destructive={newEmailExited || unAvailableEmail}
|
||||
/>
|
||||
{newEmailExited && (
|
||||
<div className="body-xs-regular mt-1 py-0.5 text-text-destructive">{t('account.changeEmail.existingEmail', { ns: 'common' })}</div>
|
||||
<div className="mt-1 py-0.5 text-text-destructive body-xs-regular">{t('account.changeEmail.existingEmail', { ns: 'common' })}</div>
|
||||
)}
|
||||
{unAvailableEmail && (
|
||||
<div className="body-xs-regular mt-1 py-0.5 text-text-destructive">{t('account.changeEmail.unAvailableEmail', { ns: 'common' })}</div>
|
||||
<div className="mt-1 py-0.5 text-text-destructive body-xs-regular">{t('account.changeEmail.unAvailableEmail', { ns: 'common' })}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
@@ -331,19 +331,19 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
||||
)}
|
||||
{step === STEP.verifyNew && (
|
||||
<>
|
||||
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.verifyNew', { ns: 'common' })}</div>
|
||||
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.verifyNew', { ns: 'common' })}</div>
|
||||
<div className="space-y-0.5 pb-2 pt-1">
|
||||
<div className="body-md-regular text-text-secondary">
|
||||
<div className="text-text-secondary body-md-regular">
|
||||
<Trans
|
||||
i18nKey="account.changeEmail.content4"
|
||||
ns="common"
|
||||
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
|
||||
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
|
||||
values={{ email: mail }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3">
|
||||
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
|
||||
<Input
|
||||
className="!w-full"
|
||||
placeholder={t('account.changeEmail.codePlaceholder', { ns: 'common' })}
|
||||
@@ -368,13 +368,13 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary">
|
||||
<div className="mt-3 flex items-center gap-1 text-text-tertiary system-xs-regular">
|
||||
<span>{t('account.changeEmail.resendTip', { ns: 'common' })}</span>
|
||||
{time > 0 && (
|
||||
<span>{t('account.changeEmail.resendCount', { ns: 'common', count: time })}</span>
|
||||
)}
|
||||
{!time && (
|
||||
<span onClick={sendCodeToNewEmail} className="system-xs-medium cursor-pointer text-text-accent-secondary">{t('account.changeEmail.resend', { ns: 'common' })}</span>
|
||||
<span onClick={sendCodeToNewEmail} className="cursor-pointer text-text-accent-secondary system-xs-medium">{t('account.changeEmail.resend', { ns: 'common' })}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function AccountPage() {
|
||||
imageUrl={icon_url}
|
||||
/>
|
||||
</div>
|
||||
<div className="system-sm-medium mt-[3px] text-text-secondary">{item.name}</div>
|
||||
<div className="mt-[3px] text-text-secondary system-sm-medium">{item.name}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -153,12 +153,12 @@ export default function AccountPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="pb-3 pt-2">
|
||||
<h4 className="title-2xl-semi-bold text-text-primary">{t('account.myAccount', { ns: 'common' })}</h4>
|
||||
<h4 className="text-text-primary title-2xl-semi-bold">{t('account.myAccount', { ns: 'common' })}</h4>
|
||||
</div>
|
||||
<div className="mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6">
|
||||
<AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={mutateUserProfile} size="3xl" />
|
||||
<div className="ml-4">
|
||||
<p className="system-xl-semibold text-text-primary">
|
||||
<p className="text-text-primary system-xl-semibold">
|
||||
{userProfile.name}
|
||||
{isEducationAccount && (
|
||||
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
|
||||
@@ -167,16 +167,16 @@ export default function AccountPage() {
|
||||
</PremiumBadge>
|
||||
)}
|
||||
</p>
|
||||
<p className="system-xs-regular text-text-tertiary">{userProfile.email}</p>
|
||||
<p className="text-text-tertiary system-xs-regular">{userProfile.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<div className={titleClassName}>{t('account.name', { ns: 'common' })}</div>
|
||||
<div className="mt-2 flex w-full items-center justify-between gap-2">
|
||||
<div className="system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled ">
|
||||
<div className="flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled system-sm-regular">
|
||||
<span className="pl-1">{userProfile.name}</span>
|
||||
</div>
|
||||
<div className="system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text" onClick={handleEditName}>
|
||||
<div className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text system-sm-medium" onClick={handleEditName}>
|
||||
{t('operation.edit', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,11 +184,11 @@ export default function AccountPage() {
|
||||
<div className="mb-8">
|
||||
<div className={titleClassName}>{t('account.email', { ns: 'common' })}</div>
|
||||
<div className="mt-2 flex w-full items-center justify-between gap-2">
|
||||
<div className="system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled ">
|
||||
<div className="flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled system-sm-regular">
|
||||
<span className="pl-1">{userProfile.email}</span>
|
||||
</div>
|
||||
{systemFeatures.enable_change_email && (
|
||||
<div className="system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text" onClick={() => setShowUpdateEmail(true)}>
|
||||
<div className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text system-sm-medium" onClick={() => setShowUpdateEmail(true)}>
|
||||
{t('operation.change', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
@@ -198,8 +198,8 @@ export default function AccountPage() {
|
||||
systemFeatures.enable_email_password_login && (
|
||||
<div className="mb-8 flex justify-between gap-2">
|
||||
<div>
|
||||
<div className="system-sm-semibold mb-1 text-text-secondary">{t('account.password', { ns: 'common' })}</div>
|
||||
<div className="body-xs-regular mb-2 text-text-tertiary">{t('account.passwordTip', { ns: 'common' })}</div>
|
||||
<div className="mb-1 text-text-secondary system-sm-semibold">{t('account.password', { ns: 'common' })}</div>
|
||||
<div className="mb-2 text-text-tertiary body-xs-regular">{t('account.passwordTip', { ns: 'common' })}</div>
|
||||
</div>
|
||||
<Button onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
@@ -226,7 +226,7 @@ export default function AccountPage() {
|
||||
onClose={() => setEditNameModalVisible(false)}
|
||||
className="!w-[420px] !p-6"
|
||||
>
|
||||
<div className="title-2xl-semi-bold mb-6 text-text-primary">{t('account.editName', { ns: 'common' })}</div>
|
||||
<div className="mb-6 text-text-primary title-2xl-semi-bold">{t('account.editName', { ns: 'common' })}</div>
|
||||
<div className={titleClassName}>{t('account.name', { ns: 'common' })}</div>
|
||||
<Input
|
||||
className="mt-2"
|
||||
@@ -256,7 +256,7 @@ export default function AccountPage() {
|
||||
}}
|
||||
className="!w-[420px] !p-6"
|
||||
>
|
||||
<div className="title-2xl-semi-bold mb-6 text-text-primary">{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</div>
|
||||
<div className="mb-6 text-text-primary title-2xl-semi-bold">{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</div>
|
||||
{userProfile.is_password_set && (
|
||||
<>
|
||||
<div className={titleClassName}>{t('account.currentPassword', { ns: 'common' })}</div>
|
||||
@@ -279,7 +279,7 @@ export default function AccountPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="system-sm-semibold mt-8 text-text-secondary">
|
||||
<div className="mt-8 text-text-secondary system-sm-semibold">
|
||||
{userProfile.is_password_set ? t('account.newPassword', { ns: 'common' }) : t('account.password', { ns: 'common' })}
|
||||
</div>
|
||||
<div className="relative mt-2">
|
||||
@@ -298,7 +298,7 @@ export default function AccountPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-sm-semibold mt-8 text-text-secondary">{t('account.confirmPassword', { ns: 'common' })}</div>
|
||||
<div className="mt-8 text-text-secondary system-sm-semibold">{t('account.confirmPassword', { ns: 'common' })}</div>
|
||||
<div className="relative mt-2">
|
||||
<Input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
|
||||
@@ -94,7 +94,7 @@ const CSVUploader: FC<Props> = ({
|
||||
/>
|
||||
<div ref={dropRef}>
|
||||
{!file && (
|
||||
<div className={cn('system-sm-regular flex h-20 items-center rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg', dragging && 'border border-components-dropzone-border-accent bg-components-dropzone-bg-accent')}>
|
||||
<div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg system-sm-regular', dragging && 'border border-components-dropzone-border-accent bg-components-dropzone-bg-accent')}>
|
||||
<div className="flex w-full items-center justify-center space-x-2">
|
||||
<CSVIcon className="shrink-0" />
|
||||
<div className="text-text-tertiary">
|
||||
|
||||
@@ -2,25 +2,19 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import HasNotSetAPI from './has-not-set-api'
|
||||
|
||||
describe('HasNotSetAPI WarningMask', () => {
|
||||
it('should show default title when trial not finished', () => {
|
||||
render(<HasNotSetAPI isTrailFinished={false} onSetting={vi.fn()} />)
|
||||
describe('HasNotSetAPI', () => {
|
||||
it('should render the empty state copy', () => {
|
||||
render(<HasNotSetAPI onSetting={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('appDebug.notSetAPIKey.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.notSetAPIKey.description')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.noModelProviderConfigured')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.noModelProviderConfiguredTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show trail finished title when flag is true', () => {
|
||||
render(<HasNotSetAPI isTrailFinished onSetting={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('appDebug.notSetAPIKey.trailFinished')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSetting when primary button clicked', () => {
|
||||
it('should call onSetting when manage models button is clicked', () => {
|
||||
const onSetting = vi.fn()
|
||||
render(<HasNotSetAPI isTrailFinished={false} onSetting={onSetting} />)
|
||||
render(<HasNotSetAPI onSetting={onSetting} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appDebug.notSetAPIKey.settingBtn' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appDebug.manageModels' }))
|
||||
expect(onSetting).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,38 +2,38 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import WarningMask from '.'
|
||||
|
||||
export type IHasNotSetAPIProps = {
|
||||
isTrailFinished: boolean
|
||||
onSetting: () => void
|
||||
}
|
||||
|
||||
const icon = (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 6.00001L14 2.00001M14 2.00001H9.99999M14 2.00001L8 8M6.66667 2H5.2C4.0799 2 3.51984 2 3.09202 2.21799C2.71569 2.40973 2.40973 2.71569 2.21799 3.09202C2 3.51984 2 4.07989 2 5.2V10.8C2 11.9201 2 12.4802 2.21799 12.908C2.40973 13.2843 2.71569 13.5903 3.09202 13.782C3.51984 14 4.07989 14 5.2 14H10.8C11.9201 14 12.4802 14 12.908 13.782C13.2843 13.5903 13.5903 13.2843 13.782 12.908C14 12.4802 14 11.9201 14 10.8V9.33333" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
|
||||
)
|
||||
|
||||
const HasNotSetAPI: FC<IHasNotSetAPIProps> = ({
|
||||
isTrailFinished,
|
||||
onSetting,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<WarningMask
|
||||
title={isTrailFinished ? t('notSetAPIKey.trailFinished', { ns: 'appDebug' }) : t('notSetAPIKey.title', { ns: 'appDebug' })}
|
||||
description={t('notSetAPIKey.description', { ns: 'appDebug' })}
|
||||
footer={(
|
||||
<Button variant="primary" className="flex space-x-2" onClick={onSetting}>
|
||||
<span>{t('notSetAPIKey.settingBtn', { ns: 'appDebug' })}</span>
|
||||
{icon}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<div className="flex grow flex-col items-center justify-center pb-[120px]">
|
||||
<div className="flex w-full max-w-[400px] flex-col gap-2 px-4 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px]">
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg backdrop-blur-[5px]">
|
||||
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-text-secondary system-md-semibold">{t('noModelProviderConfigured', { ns: 'appDebug' })}</div>
|
||||
<div className="text-text-tertiary system-xs-regular">{t('noModelProviderConfiguredTip', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-fit items-center gap-1 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 shadow-xs backdrop-blur-[5px]"
|
||||
onClick={onSetting}
|
||||
>
|
||||
<span className="text-components-button-secondary-accent-text system-sm-medium">{t('manageModels', { ns: 'appDebug' })}</span>
|
||||
<span className="i-ri-arrow-right-line h-4 w-4 text-components-button-secondary-accent-text" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(HasNotSetAPI)
|
||||
|
||||
@@ -178,7 +178,7 @@ const Prompt: FC<ISimplePromptInput> = ({
|
||||
{!noTitle && (
|
||||
<div className="flex h-11 items-center justify-between pl-3 pr-2.5">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="h2 system-sm-semibold-uppercase text-text-secondary">{mode !== AppModeEnum.COMPLETION ? t('chatSubTitle', { ns: 'appDebug' }) : t('completionSubTitle', { ns: 'appDebug' })}</div>
|
||||
<div className="h2 text-text-secondary system-sm-semibold-uppercase">{mode !== AppModeEnum.COMPLETION ? t('chatSubTitle', { ns: 'appDebug' }) : t('completionSubTitle', { ns: 'appDebug' })}</div>
|
||||
{!readonly && (
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
|
||||
@@ -96,7 +96,7 @@ const Editor: FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn(editorHeight, ' min-h-[102px] overflow-y-auto px-4 text-sm text-gray-700')}>
|
||||
<div className={cn(editorHeight, 'min-h-[102px] overflow-y-auto px-4 text-sm text-gray-700')}>
|
||||
<PromptEditor
|
||||
className={editorHeight}
|
||||
value={value}
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { FormValue } from '@/app/components/header/account-setting/model-pr
|
||||
import type { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import type { AppModeEnum, CompletionParams, Model, ModelModeType } from '@/types/app'
|
||||
import { useSessionStorageState } from 'ahooks'
|
||||
import useBoolean from 'ahooks/lib/useBoolean'
|
||||
import {
|
||||
useBoolean,
|
||||
useSessionStorageState,
|
||||
} from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -224,7 +226,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[0px]">
|
||||
<div className="system-sm-semibold-uppercase mb-1.5 text-text-secondary">{t('codegen.instruction', { ns: 'appDebug' })}</div>
|
||||
<div className="mb-1.5 text-text-secondary system-sm-semibold-uppercase">{t('codegen.instruction', { ns: 'appDebug' })}</div>
|
||||
<InstructionEditor
|
||||
editorKey={editorKey}
|
||||
value={instruction}
|
||||
@@ -248,7 +250,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Generator className="h-4 w-4" />
|
||||
<span className="text-xs font-semibold ">{t('codegen.generate', { ns: 'appDebug' })}</span>
|
||||
<span className="text-xs font-semibold">{t('codegen.generate', { ns: 'appDebug' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('dataset-config/params-config', () => {
|
||||
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
|
||||
const dialogScope = within(dialog)
|
||||
|
||||
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
|
||||
const incrementButtons = dialogScope.getAllByRole('button', { name: /increment/i })
|
||||
await user.click(incrementButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -213,7 +213,7 @@ describe('dataset-config/params-config', () => {
|
||||
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
|
||||
const dialogScope = within(dialog)
|
||||
|
||||
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
|
||||
const incrementButtons = dialogScope.getAllByRole('button', { name: /increment/i })
|
||||
await user.click(incrementButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -210,7 +210,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
<div className="overflow-y-auto border-b border-divider-regular p-6 pb-[68px] pt-5">
|
||||
<div className={cn(rowClass, 'items-center')}>
|
||||
<div className={labelClass}>
|
||||
<div className="system-sm-semibold text-text-secondary">{t('form.name', { ns: 'datasetSettings' })}</div>
|
||||
<div className="text-text-secondary system-sm-semibold">{t('form.name', { ns: 'datasetSettings' })}</div>
|
||||
</div>
|
||||
<Input
|
||||
value={localeCurrentDataset.name}
|
||||
@@ -221,7 +221,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
</div>
|
||||
<div className={cn(rowClass)}>
|
||||
<div className={labelClass}>
|
||||
<div className="system-sm-semibold text-text-secondary">{t('form.desc', { ns: 'datasetSettings' })}</div>
|
||||
<div className="text-text-secondary system-sm-semibold">{t('form.desc', { ns: 'datasetSettings' })}</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Textarea
|
||||
@@ -234,7 +234,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
</div>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className="system-sm-semibold text-text-secondary">{t('form.permissions', { ns: 'datasetSettings' })}</div>
|
||||
<div className="text-text-secondary system-sm-semibold">{t('form.permissions', { ns: 'datasetSettings' })}</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<PermissionSelector
|
||||
@@ -250,7 +250,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
{!!(currentDataset && currentDataset.indexing_technique) && (
|
||||
<div className={cn(rowClass)}>
|
||||
<div className={labelClass}>
|
||||
<div className="system-sm-semibold text-text-secondary">{t('form.indexMethod', { ns: 'datasetSettings' })}</div>
|
||||
<div className="text-text-secondary system-sm-semibold">{t('form.indexMethod', { ns: 'datasetSettings' })}</div>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<IndexMethod
|
||||
@@ -267,7 +267,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
{indexMethod === IndexingType.QUALIFIED && (
|
||||
<div className={cn(rowClass)}>
|
||||
<div className={labelClass}>
|
||||
<div className="system-sm-semibold text-text-secondary">{t('form.embeddingModel', { ns: 'datasetSettings' })}</div>
|
||||
<div className="text-text-secondary system-sm-semibold">{t('form.embeddingModel', { ns: 'datasetSettings' })}</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="h-8 w-full rounded-lg bg-components-input-bg-normal opacity-60">
|
||||
|
||||
@@ -212,7 +212,7 @@ describe('RetrievalSection', () => {
|
||||
currentDataset={dataset}
|
||||
/>,
|
||||
)
|
||||
const [topKIncrement] = screen.getAllByLabelText('increment')
|
||||
const [topKIncrement] = screen.getAllByRole('button', { name: /increment/i })
|
||||
await userEvent.click(topKIncrement)
|
||||
|
||||
// Assert
|
||||
@@ -267,7 +267,7 @@ describe('RetrievalSection', () => {
|
||||
docLink={path => path || ''}
|
||||
/>,
|
||||
)
|
||||
const [topKIncrement] = screen.getAllByLabelText('increment')
|
||||
const [topKIncrement] = screen.getAllByRole('button', { name: /increment/i })
|
||||
await userEvent.click(topKIncrement)
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ModelAndParameter } from '../types'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type {
|
||||
FormValue,
|
||||
ModelProvider,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
PreferredProviderTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import ModelParameterTrigger from './model-parameter-trigger'
|
||||
|
||||
const mockUseDebugConfigurationContext = vi.fn()
|
||||
const mockUseDebugWithMultipleModelContext = vi.fn()
|
||||
const mockUseLanguage = vi.fn()
|
||||
const mockUseProviderContext = vi.fn()
|
||||
const mockUseCredentialPanelState = vi.fn()
|
||||
|
||||
type RenderTriggerProps = {
|
||||
open: boolean
|
||||
@@ -35,8 +47,12 @@ vi.mock('./context', () => ({
|
||||
useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useLanguage: () => mockUseLanguage(),
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({
|
||||
useCredentialPanelState: () => mockUseCredentialPanelState(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
|
||||
@@ -84,6 +100,41 @@ const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): Mo
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createModelProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
help: {
|
||||
title: { en_US: 'Help', zh_Hans: 'Help' },
|
||||
url: { en_US: 'https://example.com', zh_Hans: 'https://example.com' },
|
||||
},
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
supported_model_types: [ModelTypeEnum.textGeneration],
|
||||
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||
provider_credential_schema: {
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
model_credential_schema: {
|
||||
model: {
|
||||
label: { en_US: 'Model', zh_Hans: 'Model' },
|
||||
placeholder: { en_US: 'Select model', zh_Hans: 'Select model' },
|
||||
},
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_id: 'cred-1',
|
||||
current_credential_name: 'Primary Key',
|
||||
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Primary Key' }],
|
||||
},
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
|
||||
quota_configurations: [],
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderComponent = (props: Partial<{ modelAndParameter: ModelAndParameter }> = {}) => {
|
||||
const defaultProps = {
|
||||
modelAndParameter: createModelAndParameter(),
|
||||
@@ -106,8 +157,19 @@ describe('ModelParameterTrigger', () => {
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
})
|
||||
|
||||
mockUseLanguage.mockReturnValue('en_US')
|
||||
mockUseProviderContext.mockReturnValue(createMockProviderContextValue({
|
||||
modelProviders: [createModelProvider()],
|
||||
}))
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
variant: 'api-active',
|
||||
priority: 'apiKey',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: true,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: 'Primary Key',
|
||||
credits: 10,
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
@@ -311,23 +373,66 @@ describe('ModelParameterTrigger', () => {
|
||||
expect(screen.getByTestId('model-parameter-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "Select Model" text when no provider/model', () => {
|
||||
renderComponent()
|
||||
it('should render "Select Model" text when no provider or model is configured', () => {
|
||||
renderComponent({
|
||||
modelAndParameter: createModelAndParameter({
|
||||
provider: '',
|
||||
model: '',
|
||||
}),
|
||||
})
|
||||
|
||||
// When currentProvider and currentModel are null, shows "Select Model"
|
||||
expect(screen.getByText('common.modelProvider.selectModel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('language context', () => {
|
||||
it('should use language from useLanguage hook', () => {
|
||||
mockUseLanguage.mockReturnValue('zh_Hans')
|
||||
|
||||
it('should render configured model id and incompatible tooltip when model is missing from the provider list', () => {
|
||||
renderComponent()
|
||||
|
||||
// The language is used for MODEL_STATUS_TEXT tooltip
|
||||
// We verify the hook is called
|
||||
expect(mockUseLanguage).toHaveBeenCalled()
|
||||
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.incompatibleTip')
|
||||
})
|
||||
|
||||
it('should render configure required tooltip for no-configure status', () => {
|
||||
const { unmount } = renderComponent()
|
||||
const triggerContent = capturedModalProps?.renderTrigger({
|
||||
open: false,
|
||||
currentProvider: { provider: 'openai' },
|
||||
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.noConfigure },
|
||||
})
|
||||
|
||||
unmount()
|
||||
render(<>{triggerContent}</>)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.configureRequired')
|
||||
})
|
||||
|
||||
it('should render disabled tooltip for disabled status', () => {
|
||||
const { unmount } = renderComponent()
|
||||
const triggerContent = capturedModalProps?.renderTrigger({
|
||||
open: false,
|
||||
currentProvider: { provider: 'openai' },
|
||||
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.disabled },
|
||||
})
|
||||
|
||||
unmount()
|
||||
render(<>{triggerContent}</>)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.disabled')
|
||||
})
|
||||
|
||||
it('should apply expanded and warning styles when the trigger is open for a non-active status', () => {
|
||||
const { unmount } = renderComponent()
|
||||
const triggerContent = capturedModalProps?.renderTrigger({
|
||||
open: true,
|
||||
currentProvider: { provider: 'openai' },
|
||||
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.noConfigure },
|
||||
})
|
||||
|
||||
unmount()
|
||||
const { container } = render(<>{triggerContent}</>)
|
||||
|
||||
expect(container.firstChild).toHaveClass('bg-state-base-hover')
|
||||
expect(container.firstChild).toHaveClass('!bg-[#FFFAEB]')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ModelAndParameter } from '../types'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
|
||||
import { CubeOutline } from '@/app/components/base/icons/src/vender/line/shapes'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
|
||||
MODEL_STATUS_TEXT,
|
||||
ModelStatusEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
DERIVED_MODEL_STATUS_BADGE_I18N,
|
||||
DERIVED_MODEL_STATUS_TOOLTIP_I18N,
|
||||
deriveModelStatus,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/derive-model-status'
|
||||
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
|
||||
import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import { useCredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state'
|
||||
import { useDebugConfigurationContext } from '@/context/debug-configuration'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useDebugWithMultipleModelContext } from './context'
|
||||
|
||||
type ModelParameterTriggerProps = {
|
||||
@@ -34,8 +32,10 @@ const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({
|
||||
onMultipleModelConfigsChange,
|
||||
onDebugWithMultipleModelChange,
|
||||
} = useDebugWithMultipleModelContext()
|
||||
const language = useLanguage()
|
||||
const { modelProviders } = useProviderContext()
|
||||
const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id)
|
||||
const providerMeta = modelProviders.find(provider => provider.provider === modelAndParameter.provider)
|
||||
const credentialState = useCredentialPanelState(providerMeta)
|
||||
|
||||
const handleSelectModel = ({ modelId, provider }: { modelId: string, provider: string }) => {
|
||||
const newModelConfigs = [...multipleModelConfigs]
|
||||
@@ -69,55 +69,77 @@ const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({
|
||||
open,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
}) => (
|
||||
<div
|
||||
className={`
|
||||
flex h-8 max-w-[200px] cursor-pointer items-center rounded-lg px-2
|
||||
${open && 'bg-state-base-hover'}
|
||||
${currentModel && currentModel.status !== ModelStatusEnum.active && '!bg-[#FFFAEB]'}
|
||||
`}
|
||||
>
|
||||
{
|
||||
currentProvider && (
|
||||
<ModelIcon
|
||||
className="mr-1 !h-4 !w-4"
|
||||
provider={currentProvider}
|
||||
modelName={currentModel?.model}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!currentProvider && (
|
||||
<div className="mr-1 flex h-4 w-4 items-center justify-center rounded">
|
||||
<CubeOutline className="h-4 w-4 text-text-accent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentModel && (
|
||||
<ModelName
|
||||
className="mr-0.5 text-text-secondary"
|
||||
modelItem={currentModel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!currentModel && (
|
||||
<div className="mr-0.5 truncate text-[13px] font-medium text-text-accent">
|
||||
{t('modelProvider.selectModel', { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine className={`h-3 w-3 ${(currentModel && currentProvider) ? 'text-text-tertiary' : 'text-text-accent'}`} />
|
||||
{
|
||||
currentModel && currentModel.status !== ModelStatusEnum.active && (
|
||||
<Tooltip popupContent={MODEL_STATUS_TEXT[currentModel.status][language]}>
|
||||
<AlertTriangle className="h-4 w-4 text-[#F79009]" />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
}) => {
|
||||
const status = deriveModelStatus(
|
||||
modelAndParameter.model,
|
||||
modelAndParameter.provider,
|
||||
providerMeta,
|
||||
currentModel ?? undefined,
|
||||
credentialState,
|
||||
)
|
||||
const iconProvider = currentProvider || providerMeta
|
||||
const statusLabelKey = DERIVED_MODEL_STATUS_BADGE_I18N[status as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N]
|
||||
const statusTooltipKey = DERIVED_MODEL_STATUS_TOOLTIP_I18N[status as keyof typeof DERIVED_MODEL_STATUS_TOOLTIP_I18N]
|
||||
const isEmpty = status === 'empty'
|
||||
const isActive = status === 'active'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex h-8 max-w-[200px] cursor-pointer items-center rounded-lg px-2
|
||||
${open && 'bg-state-base-hover'}
|
||||
${!isEmpty && !isActive && '!bg-[#FFFAEB]'}
|
||||
`}
|
||||
>
|
||||
{
|
||||
iconProvider && !isEmpty && (
|
||||
<ModelIcon
|
||||
className="mr-1 !h-4 !w-4"
|
||||
provider={iconProvider}
|
||||
modelName={currentModel?.model || modelAndParameter.model}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
(!iconProvider || isEmpty) && (
|
||||
<div className="mr-1 flex h-4 w-4 items-center justify-center rounded">
|
||||
<span className="i-custom-vender-line-shapes-cube-outline h-4 w-4 text-text-accent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentModel && (
|
||||
<ModelName
|
||||
className="mr-0.5 text-text-secondary"
|
||||
modelItem={currentModel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!currentModel && !isEmpty && (
|
||||
<div className="mr-0.5 truncate text-[13px] font-medium text-text-secondary">
|
||||
{modelAndParameter.model}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
isEmpty && (
|
||||
<div className="mr-0.5 truncate text-[13px] font-medium text-text-accent">
|
||||
{t('modelProvider.selectModel', { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<span className={`i-ri-arrow-down-s-line h-3 w-3 ${isEmpty ? 'text-text-accent' : 'text-text-tertiary'}`} />
|
||||
{
|
||||
!isEmpty && !isActive && statusLabelKey && (
|
||||
<Tooltip popupContent={t((statusTooltipKey || statusLabelKey) as 'modelProvider.selector.incompatible', { ns: 'common' })}>
|
||||
<span className="i-custom-vender-line-alertsAndFeedback-alert-triangle h-4 w-4 text-[#F79009]" />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
1021
web/app/components/app/configuration/debug/index.spec.tsx
Normal file
1021
web/app/components/app/configuration/debug/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import TooltipPlus from '@/app/components/base/tooltip'
|
||||
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG, IS_CE_EDITION } from '@/config'
|
||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@@ -394,7 +394,7 @@ const Debug: FC<IDebug> = ({
|
||||
<>
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center justify-between px-4 pb-2 pt-3">
|
||||
<div className="system-xl-semibold text-text-primary">{t('inputs.title', { ns: 'appDebug' })}</div>
|
||||
<div className="text-text-primary system-xl-semibold">{t('inputs.title', { ns: 'appDebug' })}</div>
|
||||
<div className="flex items-center">
|
||||
{
|
||||
debugWithMultipleModel
|
||||
@@ -505,6 +505,26 @@ const Debug: FC<IDebug> = ({
|
||||
{
|
||||
!debugWithMultipleModel && (
|
||||
<div className="flex grow flex-col" ref={ref}>
|
||||
{/* No model provider configured */}
|
||||
{(!modelConfig.provider || !isAPIKeySet) && (
|
||||
<HasNotSetAPIKEY onSetting={onSetting} />
|
||||
)}
|
||||
{/* No model selected */}
|
||||
{modelConfig.provider && isAPIKeySet && !modelConfig.model_id && (
|
||||
<div className="flex grow flex-col items-center justify-center pb-[120px]">
|
||||
<div className="flex w-full max-w-[400px] flex-col gap-2 px-4 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px]">
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg backdrop-blur-[5px]">
|
||||
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-text-secondary system-md-semibold">{t('noModelSelected', { ns: 'appDebug' })}</div>
|
||||
<div className="text-text-tertiary system-xs-regular">{t('noModelSelectedTip', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Chat */}
|
||||
{mode !== AppModeEnum.COMPLETION && (
|
||||
<div className="h-0 grow overflow-hidden">
|
||||
@@ -539,7 +559,7 @@ const Debug: FC<IDebug> = ({
|
||||
{!completionRes && !isResponding && (
|
||||
<div className="flex grow flex-col items-center justify-center gap-2">
|
||||
<RiSparklingFill className="h-12 w-12 text-text-empty-state-icon" />
|
||||
<div className="system-sm-regular text-text-quaternary">{t('noResult', { ns: 'appDebug' })}</div>
|
||||
<div className="text-text-quaternary system-sm-regular">{t('noResult', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -570,7 +590,6 @@ const Debug: FC<IDebug> = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
{!isAPIKeySet && !readonly && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -966,10 +966,10 @@ const Configuration: FC = () => {
|
||||
<div className="bg-default-subtle absolute left-0 top-0 h-14 w-full">
|
||||
<div className="flex h-14 items-center justify-between px-6">
|
||||
<div className="flex items-center">
|
||||
<div className="system-xl-semibold text-text-primary">{t('orchestrate', { ns: 'appDebug' })}</div>
|
||||
<div className="text-text-primary system-xl-semibold">{t('orchestrate', { ns: 'appDebug' })}</div>
|
||||
<div className="flex h-[14px] items-center space-x-1 text-xs">
|
||||
{isAdvancedMode && (
|
||||
<div className="system-xs-medium-uppercase ml-1 flex h-5 items-center rounded-md border border-components-button-secondary-border px-1.5 uppercase text-text-tertiary">{t('promptMode.advanced', { ns: 'appDebug' })}</div>
|
||||
<div className="ml-1 flex h-5 items-center rounded-md border border-components-button-secondary-border px-1.5 uppercase text-text-tertiary system-xs-medium-uppercase">{t('promptMode.advanced', { ns: 'appDebug' })}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1030,8 +1030,8 @@ const Configuration: FC = () => {
|
||||
<Config />
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div className="relative flex h-full w-1/2 grow flex-col overflow-y-auto " style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }}>
|
||||
<div className="flex grow flex-col rounded-tl-2xl border-l-[0.5px] border-t-[0.5px] border-components-panel-border bg-chatbot-bg ">
|
||||
<div className="relative flex h-full w-1/2 grow flex-col overflow-y-auto" style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }}>
|
||||
<div className="flex grow flex-col rounded-tl-2xl border-l-[0.5px] border-t-[0.5px] border-components-panel-border bg-chatbot-bg">
|
||||
<Debug
|
||||
isAPIKeySet={isAPIKeySet}
|
||||
onSetting={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}
|
||||
|
||||
@@ -217,7 +217,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
|
||||
<AppIcon
|
||||
size="large"
|
||||
onClick={() => { setShowEmojiPicker(true) }}
|
||||
className="!h-9 !w-9 cursor-pointer rounded-lg border-[0.5px] border-components-panel-border "
|
||||
className="!h-9 !w-9 cursor-pointer rounded-lg border-[0.5px] border-components-panel-border"
|
||||
icon={localeData.icon}
|
||||
background={localeData.icon_background}
|
||||
/>
|
||||
|
||||
@@ -232,7 +232,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
>
|
||||
<div className="title-2xl-semi-bold flex items-center justify-between pb-3 pl-6 pr-5 pt-6 text-text-primary">
|
||||
<div className="flex items-center justify-between pb-3 pl-6 pr-5 pt-6 text-text-primary title-2xl-semi-bold">
|
||||
{t('importFromDSL', { ns: 'app' })}
|
||||
<div
|
||||
className="flex h-8 w-8 cursor-pointer items-center"
|
||||
@@ -241,7 +241,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-md-semibold flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary">
|
||||
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary system-md-semibold">
|
||||
{
|
||||
tabs.map(tab => (
|
||||
<div
|
||||
@@ -275,7 +275,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
{
|
||||
currentTab === CreateFromDSLModalTab.FROM_URL && (
|
||||
<div>
|
||||
<div className="system-md-semibold mb-1 text-text-secondary">DSL URL</div>
|
||||
<div className="mb-1 text-text-secondary system-md-semibold">DSL URL</div>
|
||||
<Input
|
||||
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
|
||||
value={dslUrlValue}
|
||||
@@ -309,8 +309,8 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
className="w-[480px]"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
|
||||
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
|
||||
<div className="system-md-regular flex grow flex-col text-text-secondary">
|
||||
<div className="text-text-primary title-2xl-semi-bold">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
|
||||
<div className="flex grow flex-col text-text-secondary system-md-regular">
|
||||
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
||||
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
|
||||
<br />
|
||||
|
||||
@@ -121,7 +121,7 @@ const Uploader: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
{file && (
|
||||
<div className={cn('group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', ' hover:bg-components-panel-on-panel-item-bg-hover')}>
|
||||
<div className={cn('group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', 'hover:bg-components-panel-on-panel-item-bg-hover')}>
|
||||
<div className="flex items-center justify-center p-3">
|
||||
<YamlIcon className="h-6 w-6 shrink-0" />
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ const statusTdRender = (statusCount: StatusCount) => {
|
||||
|
||||
if (statusCount.paused > 0) {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
|
||||
<Indicator color="yellow" />
|
||||
<span className="text-util-colors-warning-warning-600">Pending</span>
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@ const statusTdRender = (statusCount: StatusCount) => {
|
||||
}
|
||||
else if (statusCount.partial_success + statusCount.failed === 0) {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
|
||||
<Indicator color="green" />
|
||||
<span className="text-util-colors-green-green-600">Success</span>
|
||||
</div>
|
||||
@@ -112,7 +112,7 @@ const statusTdRender = (statusCount: StatusCount) => {
|
||||
}
|
||||
else if (statusCount.failed === 0) {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
|
||||
<Indicator color="green" />
|
||||
<span className="text-util-colors-green-green-600">Partial Success</span>
|
||||
</div>
|
||||
@@ -120,7 +120,7 @@ const statusTdRender = (statusCount: StatusCount) => {
|
||||
}
|
||||
else {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
|
||||
<Indicator color="red" />
|
||||
<span className="text-util-colors-red-red-600">
|
||||
{statusCount.failed}
|
||||
@@ -562,9 +562,9 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
{/* Panel Header */}
|
||||
<div className="flex shrink-0 items-center gap-2 rounded-t-xl bg-components-panel-bg pb-2 pl-4 pr-3 pt-3">
|
||||
<div className="shrink-0">
|
||||
<div className="system-xs-semibold-uppercase mb-0.5 text-text-primary">{isChatMode ? t('detail.conversationId', { ns: 'appLog' }) : t('detail.time', { ns: 'appLog' })}</div>
|
||||
<div className="mb-0.5 text-text-primary system-xs-semibold-uppercase">{isChatMode ? t('detail.conversationId', { ns: 'appLog' }) : t('detail.time', { ns: 'appLog' })}</div>
|
||||
{isChatMode && (
|
||||
<div className="system-2xs-regular-uppercase flex items-center text-text-secondary">
|
||||
<div className="flex items-center text-text-secondary system-2xs-regular-uppercase">
|
||||
<Tooltip
|
||||
popupContent={detail.id}
|
||||
>
|
||||
@@ -574,7 +574,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
</div>
|
||||
)}
|
||||
{!isChatMode && (
|
||||
<div className="system-2xs-regular-uppercase text-text-secondary">{formatTime(detail.created_at, t('dateTimeFormat', { ns: 'appLog' }) as string)}</div>
|
||||
<div className="text-text-secondary system-2xs-regular-uppercase">{formatTime(detail.created_at, t('dateTimeFormat', { ns: 'appLog' }) as string)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex grow flex-wrap items-center justify-end gap-y-1">
|
||||
@@ -600,7 +600,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
? (
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex h-[18px] items-center space-x-3">
|
||||
<div className="system-xs-semibold-uppercase text-text-tertiary">{t('table.header.output', { ns: 'appLog' })}</div>
|
||||
<div className="text-text-tertiary system-xs-semibold-uppercase">{t('table.header.output', { ns: 'appLog' })}</div>
|
||||
<div
|
||||
className="h-px grow"
|
||||
style={{
|
||||
@@ -692,7 +692,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="py-3 text-center">
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
<div className="text-text-tertiary system-xs-regular">
|
||||
{t('detail.loading', { ns: 'appLog' })}
|
||||
...
|
||||
</div>
|
||||
@@ -950,7 +950,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
|
||||
)}
|
||||
popupClassName={(isHighlight && !isChatMode) ? '' : '!hidden'}
|
||||
>
|
||||
<div className={cn(isEmptyStyle ? 'text-text-quaternary' : 'text-text-secondary', !isHighlight ? '' : 'bg-orange-100', 'system-sm-regular overflow-hidden text-ellipsis whitespace-nowrap')}>
|
||||
<div className={cn(isEmptyStyle ? 'text-text-quaternary' : 'text-text-secondary', !isHighlight ? '' : 'bg-orange-100', 'overflow-hidden text-ellipsis whitespace-nowrap system-sm-regular')}>
|
||||
{value || '-'}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -963,7 +963,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
|
||||
return (
|
||||
<div className="relative mt-2 grow overflow-x-auto">
|
||||
<table className={cn('w-full min-w-[440px] border-collapse border-0')}>
|
||||
<thead className="system-xs-medium-uppercase text-text-tertiary">
|
||||
<thead className="text-text-tertiary system-xs-medium-uppercase">
|
||||
<tr>
|
||||
<td className="w-5 whitespace-nowrap rounded-l-lg bg-background-section-burn pl-2 pr-1"></td>
|
||||
<td className="whitespace-nowrap bg-background-section-burn py-1.5 pl-3">{isChatMode ? t('table.header.summary', { ns: 'appLog' }) : t('table.header.input', { ns: 'appLog' })}</td>
|
||||
@@ -976,7 +976,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
|
||||
<td className="whitespace-nowrap rounded-r-lg bg-background-section-burn py-1.5 pl-3">{t('table.header.time', { ns: 'appLog' })}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="system-sm-regular text-text-secondary">
|
||||
<tbody className="text-text-secondary system-sm-regular">
|
||||
{logs.data.map((log: any) => {
|
||||
const endUser = log.from_end_user_session_id || log.from_account_name
|
||||
const leftValue = get(log, isChatMode ? 'name' : 'message.inputs.query') || (!isChatMode ? (get(log, 'message.query') || get(log, 'message.inputs.default_input')) : '') || ''
|
||||
|
||||
@@ -27,7 +27,21 @@ describe('NumberInputField', () => {
|
||||
|
||||
it('should update value when users click increment', () => {
|
||||
render(<NumberInputField label="Count" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'increment' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.increment' }))
|
||||
expect(mockField.handleChange).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('should reset field value when users clear the input', () => {
|
||||
render(<NumberInputField label="Count" />)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } })
|
||||
expect(mockField.handleChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should clamp out-of-range edits before updating field state', () => {
|
||||
render(<NumberInputField label="Count" min={0} max={10} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '12' } })
|
||||
|
||||
expect(mockField.handleChange).toHaveBeenLastCalledWith(10)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,52 @@
|
||||
import type { InputNumberProps } from '../../../input-number'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { NumberFieldInputProps, NumberFieldRootProps, NumberFieldSize } from '../../../ui/number-field'
|
||||
import type { LabelProps } from '../label'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useFieldContext } from '../..'
|
||||
import { InputNumber } from '../../../input-number'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
NumberFieldUnit,
|
||||
} from '../../../ui/number-field'
|
||||
import Label from '../label'
|
||||
|
||||
type TextFieldProps = {
|
||||
type NumberInputFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
} & Omit<InputNumberProps, 'id' | 'value' | 'onChange' | 'onBlur'>
|
||||
inputClassName?: string
|
||||
unit?: ReactNode
|
||||
size?: NumberFieldSize
|
||||
} & Omit<NumberFieldRootProps, 'children' | 'className' | 'id' | 'value' | 'defaultValue' | 'onValueChange'> & Omit<NumberFieldInputProps, 'children' | 'size' | 'onBlur' | 'className' | 'onChange'>
|
||||
|
||||
const NumberInputField = ({
|
||||
label,
|
||||
labelOptions,
|
||||
className,
|
||||
...inputProps
|
||||
}: TextFieldProps) => {
|
||||
inputClassName,
|
||||
unit,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberInputFieldProps) => {
|
||||
const field = useFieldContext<number>()
|
||||
const {
|
||||
value: _value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
disabled,
|
||||
readOnly,
|
||||
required,
|
||||
name: _name,
|
||||
id: _id,
|
||||
...inputProps
|
||||
} = props
|
||||
const emptyValue = min ?? 0
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
@@ -27,13 +55,36 @@ const NumberInputField = ({
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<InputNumber
|
||||
<NumberField
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onChange={value => field.handleChange(value)}
|
||||
onBlur={field.handleBlur}
|
||||
{...inputProps}
|
||||
/>
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
onValueChange={value => field.handleChange(value ?? emptyValue)}
|
||||
>
|
||||
<NumberFieldGroup size={size}>
|
||||
<NumberFieldInput
|
||||
{...inputProps}
|
||||
size={size}
|
||||
className={inputClassName}
|
||||
onBlur={field.handleBlur}
|
||||
/>
|
||||
{Boolean(unit) && (
|
||||
<NumberFieldUnit size={size}>
|
||||
{unit}
|
||||
</NumberFieldUnit>
|
||||
)}
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size={size} />
|
||||
<NumberFieldDecrement size={size} />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 5C2 3.44487 2.58482 1.98537 3.54004 1.04932C2.17681 1.34034 1 2.90001 1 5C1 7.09996 2.17685 8.65912 3.54004 8.9502C2.58496 8.01413 2 6.55501 2 5ZM3 5C3 7.33338 4.4528 9 6 9C7.5472 9 9 7.33338 9 5C9 2.66664 7.5472 1 6 1C4.4528 1 3 2.66664 3 5ZM10 5C10 7.63722 8.3188 10 6 10H4C1.6812 10 0 7.63722 0 5C0 2.3628 1.6812 0 4 0H6C8.3188 0 10 2.3628 10 5Z" fill="#676F83"/>
|
||||
<path d="M6.71519 4.09259L6.45385 3.18667C6.42141 3.07421 6.34037 3 6.25 3C6.15963 3 6.07859 3.07421 6.04615 3.18667L5.78481 4.09259C5.74675 4.22464 5.66849 4.32899 5.56945 4.37978L4.88999 4.7282C4.80565 4.77146 4.75 4.87951 4.75 5C4.75 5.12049 4.80565 5.22854 4.88999 5.2718L5.56945 5.62022C5.66849 5.67101 5.74675 5.77536 5.78481 5.90741L6.04615 6.81333C6.07859 6.92579 6.15963 7 6.25 7C6.34037 7 6.42141 6.92579 6.45385 6.81333L6.71519 5.90741C6.75325 5.77536 6.83151 5.67101 6.93055 5.62022L7.61001 5.2718C7.69435 5.22854 7.75 5.12049 7.75 5C7.75 4.87951 7.69435 4.77146 7.61001 4.7282L6.93055 4.37978C6.83151 4.32899 6.75325 4.22464 6.71519 4.09259Z" fill="#676F83"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="arrow-down-round-fill">
|
||||
<path id="Vector" d="M6.02913 6.23572C5.08582 6.23572 4.56482 7.33027 5.15967 8.06239L7.13093 10.4885C7.57922 11.0403 8.42149 11.0403 8.86986 10.4885L10.8411 8.06239C11.4359 7.33027 10.9149 6.23572 9.97158 6.23572H6.02913Z" fill="#101828"/>
|
||||
<path id="Vector" d="M6.02913 6.23572C5.08582 6.23572 4.56482 7.33027 5.15967 8.06239L7.13093 10.4885C7.57922 11.0403 8.42149 11.0403 8.86986 10.4885L10.8411 8.06239C11.4359 7.33027 10.9149 6.23572 9.97158 6.23572H6.02913Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 380 B After Width: | Height: | Size: 385 B |
@@ -1,3 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Solid" fill-rule="evenodd" clip-rule="evenodd" d="M8.00008 0.666016C3.94999 0.666016 0.666748 3.94926 0.666748 7.99935C0.666748 12.0494 3.94999 15.3327 8.00008 15.3327C12.0502 15.3327 15.3334 12.0494 15.3334 7.99935C15.3334 3.94926 12.0502 0.666016 8.00008 0.666016ZM10.4715 5.52794C10.7318 5.78829 10.7318 6.2104 10.4715 6.47075L8.94289 7.99935L10.4715 9.52794C10.7318 9.78829 10.7318 10.2104 10.4715 10.4708C10.2111 10.7311 9.78903 10.7311 9.52868 10.4708L8.00008 8.94216L6.47149 10.4708C6.21114 10.7311 5.78903 10.7311 5.52868 10.4708C5.26833 10.2104 5.26833 9.78829 5.52868 9.52794L7.05727 7.99935L5.52868 6.47075C5.26833 6.2104 5.26833 5.78829 5.52868 5.52794C5.78903 5.26759 6.21114 5.26759 6.47149 5.52794L8.00008 7.05654L9.52868 5.52794C9.78903 5.26759 10.2111 5.26759 10.4715 5.52794Z" fill="#98A2B3"/>
|
||||
<path id="Solid" fill-rule="evenodd" clip-rule="evenodd" d="M8.00008 0.666016C3.94999 0.666016 0.666748 3.94926 0.666748 7.99935C0.666748 12.0494 3.94999 15.3327 8.00008 15.3327C12.0502 15.3327 15.3334 12.0494 15.3334 7.99935C15.3334 3.94926 12.0502 0.666016 8.00008 0.666016ZM10.4715 5.52794C10.7318 5.78829 10.7318 6.2104 10.4715 6.47075L8.94289 7.99935L10.4715 9.52794C10.7318 9.78829 10.7318 10.2104 10.4715 10.4708C10.2111 10.7311 9.78903 10.7311 9.52868 10.4708L8.00008 8.94216L6.47149 10.4708C6.21114 10.7311 5.78903 10.7311 5.52868 10.4708C5.26833 10.2104 5.26833 9.78829 5.52868 9.52794L7.05727 7.99935L5.52868 6.47075C5.26833 6.2104 5.26833 5.78829 5.52868 5.52794C5.78903 5.26759 6.21114 5.26759 6.47149 5.52794L8.00008 7.05654L9.52868 5.52794C9.78903 5.26759 10.2111 5.26759 10.4715 5.52794Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 925 B After Width: | Height: | Size: 930 B |
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "10",
|
||||
"height": "10",
|
||||
"viewBox": "0 0 10 10",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M2 5C2 3.44487 2.58482 1.98537 3.54004 1.04932C2.17681 1.34034 1 2.90001 1 5C1 7.09996 2.17685 8.65912 3.54004 8.9502C2.58496 8.01413 2 6.55501 2 5ZM3 5C3 7.33338 4.4528 9 6 9C7.5472 9 9 7.33338 9 5C9 2.66664 7.5472 1 6 1C4.4528 1 3 2.66664 3 5ZM10 5C10 7.63722 8.3188 10 6 10H4C1.6812 10 0 7.63722 0 5C0 2.3628 1.6812 0 4 0H6C8.3188 0 10 2.3628 10 5Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M6.71519 4.09259L6.45385 3.18667C6.42141 3.07421 6.34037 3 6.25 3C6.15963 3 6.07859 3.07421 6.04615 3.18667L5.78481 4.09259C5.74675 4.22464 5.66849 4.32899 5.56945 4.37978L4.88999 4.7282C4.80565 4.77146 4.75 4.87951 4.75 5C4.75 5.12049 4.80565 5.22854 4.88999 5.2718L5.56945 5.62022C5.66849 5.67101 5.74675 5.77536 5.78481 5.90741L6.04615 6.81333C6.07859 6.92579 6.15963 7 6.25 7C6.34037 7 6.42141 6.92579 6.45385 6.81333L6.71519 5.90741C6.75325 5.77536 6.83151 5.67101 6.93055 5.62022L7.61001 5.2718C7.69435 5.22854 7.75 5.12049 7.75 5C7.75 4.87951 7.69435 4.77146 7.61001 4.7282L6.93055 4.37978C6.83151 4.32899 6.75325 4.22464 6.71519 4.09259Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "CreditsCoin"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './CreditsCoin.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'CreditsCoin'
|
||||
|
||||
export default Icon
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as Balance } from './Balance'
|
||||
export { default as CoinsStacked01 } from './CoinsStacked01'
|
||||
export { default as CreditsCoin } from './CreditsCoin'
|
||||
export { default as GoldCoin } from './GoldCoin'
|
||||
export { default as ReceiptList } from './ReceiptList'
|
||||
export { default as Tag01 } from './Tag01'
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InputNumber } from '../index'
|
||||
|
||||
describe('InputNumber Component', () => {
|
||||
const defaultProps = {
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders input with default values', () => {
|
||||
render(<InputNumber {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles increment button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={5} />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
await user.click(incrementBtn)
|
||||
expect(onChange).toHaveBeenCalledWith(6)
|
||||
})
|
||||
|
||||
it('handles decrement button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={5} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
await user.click(decrementBtn)
|
||||
expect(onChange).toHaveBeenCalledWith(4)
|
||||
})
|
||||
|
||||
it('respects max value constraint', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={10} max={10} />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
await user.click(incrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('respects min value constraint', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={0} min={0} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
await user.click(decrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles direct input changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '42' } })
|
||||
expect(onChange).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('handles empty input', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={1} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('does not call onChange when input is not parseable', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call onChange when direct input exceeds range', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} max={10} min={0} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '11' } })
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses default value when increment and decrement are clicked without value prop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} defaultValue={7} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /increment/i }))
|
||||
await user.click(screen.getByRole('button', { name: /decrement/i }))
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, 7)
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, 7)
|
||||
})
|
||||
|
||||
it('falls back to zero when controls are used without value and defaultValue', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /increment/i }))
|
||||
await user.click(screen.getByRole('button', { name: /decrement/i }))
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, 0)
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, 0)
|
||||
})
|
||||
|
||||
it('displays unit when provided', () => {
|
||||
const onChange = vi.fn()
|
||||
const unit = 'px'
|
||||
render(<InputNumber onChange={onChange} unit={unit} />)
|
||||
expect(screen.getByText(unit)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables controls when disabled prop is true', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} disabled />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
expect(input).toBeDisabled()
|
||||
expect(incrementBtn).toBeDisabled()
|
||||
expect(decrementBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not change value when disabled controls are clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const { getByRole } = render(<InputNumber onChange={onChange} disabled value={5} />)
|
||||
|
||||
const incrementBtn = getByRole('button', { name: /increment/i })
|
||||
const decrementBtn = getByRole('button', { name: /decrement/i })
|
||||
|
||||
expect(incrementBtn).toBeDisabled()
|
||||
expect(decrementBtn).toBeDisabled()
|
||||
|
||||
await user.click(incrementBtn)
|
||||
await user.click(decrementBtn)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps increment guard when disabled even if button is force-clickable', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} disabled value={5} />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
// Remove native disabled to force event dispatch and hit component-level guard.
|
||||
incrementBtn.removeAttribute('disabled')
|
||||
fireEvent.click(incrementBtn)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps decrement guard when disabled even if button is force-clickable', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} disabled value={5} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
// Remove native disabled to force event dispatch and hit component-level guard.
|
||||
decrementBtn.removeAttribute('disabled')
|
||||
fireEvent.click(decrementBtn)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies large-size classes for control buttons', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} size="large" />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
expect(incrementBtn).toHaveClass('pt-1.5')
|
||||
expect(decrementBtn).toHaveClass('pb-1.5')
|
||||
})
|
||||
|
||||
it('prevents increment beyond max with custom amount', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={8} max={10} amount={5} />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
await user.click(incrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses fallback step guard when step is any', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={10} max={10} step="any" />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
await user.click(incrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents decrement below min with custom amount', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={2} min={0} amount={5} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
await user.click(decrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('increments when value with custom amount stays within bounds', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={5} max={10} amount={3} />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
await user.click(incrementBtn)
|
||||
expect(onChange).toHaveBeenCalledWith(8)
|
||||
})
|
||||
|
||||
it('decrements when value with custom amount stays within bounds', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={5} min={0} amount={3} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
await user.click(decrementBtn)
|
||||
expect(onChange).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('validates input against max constraint', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} max={10} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '15' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates input against min constraint', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={5} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '2' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts input within min and max constraints', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={0} max={100} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '50' } })
|
||||
expect(onChange).toHaveBeenCalledWith(50)
|
||||
})
|
||||
|
||||
it('handles negative min and max values', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={-10} max={10} value={0} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
await user.click(decrementBtn)
|
||||
expect(onChange).toHaveBeenCalledWith(-1)
|
||||
})
|
||||
|
||||
it('prevents decrement below negative min', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={-10} value={-10} />)
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
await user.click(decrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies wrapClassName to outer div', () => {
|
||||
const onChange = vi.fn()
|
||||
const wrapClassName = 'custom-wrap-class'
|
||||
render(<InputNumber onChange={onChange} wrapClassName={wrapClassName} />)
|
||||
const wrapper = screen.getByTestId('input-number-wrapper')
|
||||
expect(wrapper).toHaveClass(wrapClassName)
|
||||
})
|
||||
|
||||
it('applies wrapperClassName to outer div for Input compatibility', () => {
|
||||
const onChange = vi.fn()
|
||||
const wrapperClassName = 'custom-input-wrapper'
|
||||
render(<InputNumber onChange={onChange} wrapperClassName={wrapperClassName} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const wrapper = screen.getByTestId('input-number-wrapper')
|
||||
|
||||
expect(input).not.toHaveAttribute('wrapperClassName')
|
||||
expect(wrapper).toHaveClass(wrapperClassName)
|
||||
})
|
||||
|
||||
it('applies styleCss to the input element', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} styleCss={{ color: 'red' }} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveStyle({ color: 'rgb(255, 0, 0)' })
|
||||
})
|
||||
|
||||
it('applies controlWrapClassName to control buttons container', () => {
|
||||
const onChange = vi.fn()
|
||||
const controlWrapClassName = 'custom-control-wrap'
|
||||
render(<InputNumber onChange={onChange} controlWrapClassName={controlWrapClassName} />)
|
||||
const controlDiv = screen.getByTestId('input-number-controls')
|
||||
expect(controlDiv).toHaveClass(controlWrapClassName)
|
||||
})
|
||||
|
||||
it('applies controlClassName to individual control buttons', () => {
|
||||
const onChange = vi.fn()
|
||||
const controlClassName = 'custom-control'
|
||||
render(<InputNumber onChange={onChange} controlClassName={controlClassName} />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
expect(incrementBtn).toHaveClass(controlClassName)
|
||||
expect(decrementBtn).toHaveClass(controlClassName)
|
||||
})
|
||||
|
||||
it('applies regular-size classes for control buttons when size is regular', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} size="regular" />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
expect(incrementBtn).toHaveClass('pt-1')
|
||||
expect(decrementBtn).toHaveClass('pb-1')
|
||||
})
|
||||
|
||||
it('handles zero as a valid input', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={-5} max={5} value={1} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '0' } })
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('prevents exact max boundary increment', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={10} max={10} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /increment/i }))
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents exact min boundary decrement', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={0} min={0} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /decrement/i }))
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,479 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useState } from 'react'
|
||||
import { InputNumber } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/InputNumber',
|
||||
component: InputNumber,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Number input component with increment/decrement buttons. Supports min/max constraints, custom step amounts, and units display.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'number',
|
||||
description: 'Current value',
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['regular', 'large'],
|
||||
description: 'Input size',
|
||||
},
|
||||
min: {
|
||||
control: 'number',
|
||||
description: 'Minimum value',
|
||||
},
|
||||
max: {
|
||||
control: 'number',
|
||||
description: 'Maximum value',
|
||||
},
|
||||
amount: {
|
||||
control: 'number',
|
||||
description: 'Step amount for increment/decrement',
|
||||
},
|
||||
unit: {
|
||||
control: 'text',
|
||||
description: 'Unit text displayed (e.g., "px", "ms")',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
defaultValue: {
|
||||
control: 'number',
|
||||
description: 'Default value when undefined',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onChange: (value) => {
|
||||
console.log('Value changed:', value)
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof InputNumber>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const InputNumberDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value ?? 0)
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<InputNumber
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue)
|
||||
console.log('Value changed:', newValue)
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Current value:
|
||||
{' '}
|
||||
<span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 0,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Large size
|
||||
export const LargeSize: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 10,
|
||||
size: 'large',
|
||||
},
|
||||
}
|
||||
|
||||
// With min/max constraints
|
||||
export const WithMinMax: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 5,
|
||||
min: 0,
|
||||
max: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// With custom step amount
|
||||
export const CustomStepAmount: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
amount: 5,
|
||||
min: 0,
|
||||
max: 100,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// With unit
|
||||
export const WithUnit: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 100,
|
||||
unit: 'px',
|
||||
min: 0,
|
||||
max: 1000,
|
||||
amount: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 42,
|
||||
disabled: true,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Decimal values
|
||||
export const DecimalValues: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 2.5,
|
||||
amount: 0.5,
|
||||
min: 0,
|
||||
max: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Negative values allowed
|
||||
export const NegativeValues: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
amount: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Size comparison
|
||||
const SizeComparisonDemo = () => {
|
||||
const [regularValue, setRegularValue] = useState(10)
|
||||
const [largeValue, setLargeValue] = useState(20)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" style={{ width: '300px' }}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Regular Size</label>
|
||||
<InputNumber
|
||||
size="regular"
|
||||
value={regularValue}
|
||||
onChange={setRegularValue}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Large Size</label>
|
||||
<InputNumber
|
||||
size="large"
|
||||
value={largeValue}
|
||||
onChange={setLargeValue}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => <SizeComparisonDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Font size picker
|
||||
const FontSizePickerDemo = () => {
|
||||
const [fontSize, setFontSize] = useState(16)
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Font Size</label>
|
||||
<InputNumber
|
||||
value={fontSize}
|
||||
onChange={setFontSize}
|
||||
min={8}
|
||||
max={72}
|
||||
amount={2}
|
||||
unit="px"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<p style={{ fontSize: `${fontSize}px` }} className="text-gray-900">
|
||||
Preview Text
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FontSizePicker: Story = {
|
||||
render: () => <FontSizePickerDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Quantity selector
|
||||
const QuantitySelectorDemo = () => {
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const pricePerItem = 29.99
|
||||
const total = (quantity * pricePerItem).toFixed(2)
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Product Name</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
$
|
||||
{pricePerItem}
|
||||
{' '}
|
||||
each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Quantity</label>
|
||||
<InputNumber
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
min={1}
|
||||
max={99}
|
||||
amount={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Total</span>
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
$
|
||||
{total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const QuantitySelector: Story = {
|
||||
render: () => <QuantitySelectorDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Timer settings
|
||||
const TimerSettingsDemo = () => {
|
||||
const [hours, setHours] = useState(0)
|
||||
const [minutes, setMinutes] = useState(15)
|
||||
const [seconds, setSeconds] = useState(30)
|
||||
|
||||
const totalSeconds = hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Timer Configuration</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Hours</label>
|
||||
<InputNumber
|
||||
value={hours}
|
||||
onChange={setHours}
|
||||
min={0}
|
||||
max={23}
|
||||
unit="h"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Minutes</label>
|
||||
<InputNumber
|
||||
value={minutes}
|
||||
onChange={setMinutes}
|
||||
min={0}
|
||||
max={59}
|
||||
unit="m"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Seconds</label>
|
||||
<InputNumber
|
||||
value={seconds}
|
||||
onChange={setSeconds}
|
||||
min={0}
|
||||
max={59}
|
||||
unit="s"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-blue-50 p-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total duration:
|
||||
{' '}
|
||||
<span className="font-semibold">
|
||||
{totalSeconds}
|
||||
{' '}
|
||||
seconds
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TimerSettings: Story = {
|
||||
render: () => <TimerSettingsDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Animation settings
|
||||
const AnimationSettingsDemo = () => {
|
||||
const [duration, setDuration] = useState(300)
|
||||
const [delay, setDelay] = useState(0)
|
||||
const [iterations, setIterations] = useState(1)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Animation Properties</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Duration</label>
|
||||
<InputNumber
|
||||
value={duration}
|
||||
onChange={setDuration}
|
||||
min={0}
|
||||
max={5000}
|
||||
amount={50}
|
||||
unit="ms"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Delay</label>
|
||||
<InputNumber
|
||||
value={delay}
|
||||
onChange={setDelay}
|
||||
min={0}
|
||||
max={2000}
|
||||
amount={50}
|
||||
unit="ms"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Iterations</label>
|
||||
<InputNumber
|
||||
value={iterations}
|
||||
onChange={setIterations}
|
||||
min={1}
|
||||
max={10}
|
||||
amount={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-gray-50 p-4">
|
||||
<div className="font-mono text-xs text-gray-700">
|
||||
animation:
|
||||
{' '}
|
||||
{duration}
|
||||
ms
|
||||
{' '}
|
||||
{delay}
|
||||
ms
|
||||
{' '}
|
||||
{iterations}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AnimationSettings: Story = {
|
||||
render: () => <AnimationSettingsDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Temperature control
|
||||
const TemperatureControlDemo = () => {
|
||||
const [temperature, setTemperature] = useState(20)
|
||||
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Temperature Control</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Set Temperature</label>
|
||||
<InputNumber
|
||||
size="large"
|
||||
value={temperature}
|
||||
onChange={setTemperature}
|
||||
min={16}
|
||||
max={30}
|
||||
amount={0.5}
|
||||
unit="°C"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-lg bg-gray-50 p-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Celsius</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">
|
||||
{temperature}
|
||||
°C
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Fahrenheit</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">
|
||||
{fahrenheit}
|
||||
°F
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TemperatureControl: Story = {
|
||||
render: () => <TemperatureControlDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 10,
|
||||
size: 'regular',
|
||||
min: 0,
|
||||
max: 100,
|
||||
amount: 1,
|
||||
unit: '',
|
||||
disabled: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import type { NumberFieldRoot as BaseNumberFieldRoot } from '@base-ui/react/number-field'
|
||||
import type { CSSProperties, FC, InputHTMLAttributes } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
NumberFieldUnit,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type InputNumberInputProps = Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
'defaultValue' | 'max' | 'min' | 'onChange' | 'size' | 'type' | 'value'
|
||||
>
|
||||
|
||||
export type InputNumberProps = InputNumberInputProps & {
|
||||
unit?: string
|
||||
value?: number
|
||||
onChange: (value: number) => void
|
||||
amount?: number
|
||||
size?: 'regular' | 'large'
|
||||
max?: number
|
||||
min?: number
|
||||
step?: number | 'any'
|
||||
defaultValue?: number
|
||||
disabled?: boolean
|
||||
wrapClassName?: string
|
||||
wrapperClassName?: string
|
||||
styleCss?: CSSProperties
|
||||
controlWrapClassName?: string
|
||||
controlClassName?: string
|
||||
type?: 'number'
|
||||
}
|
||||
|
||||
const STEPPER_REASONS = new Set<BaseNumberFieldRoot.ChangeEventDetails['reason']>([
|
||||
'increment-press',
|
||||
'decrement-press',
|
||||
])
|
||||
|
||||
const isValueWithinBounds = (value: number, min?: number, max?: number) => {
|
||||
if (typeof min === 'number' && value < min)
|
||||
return false
|
||||
|
||||
if (typeof max === 'number' && value > max)
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const resolveStep = (amount?: number, step?: InputNumberProps['step']) => (
|
||||
amount ?? (step === 'any' || typeof step === 'number' ? step : undefined) ?? 1
|
||||
)
|
||||
|
||||
const exceedsStepBounds = ({
|
||||
value,
|
||||
reason,
|
||||
stepAmount,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
value?: number
|
||||
reason: BaseNumberFieldRoot.ChangeEventDetails['reason']
|
||||
stepAmount: number
|
||||
min?: number
|
||||
max?: number
|
||||
}) => {
|
||||
if (typeof value !== 'number')
|
||||
return false
|
||||
|
||||
if (reason === 'increment-press' && typeof max === 'number')
|
||||
return value + stepAmount > max
|
||||
|
||||
if (reason === 'decrement-press' && typeof min === 'number')
|
||||
return value - stepAmount < min
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const InputNumber: FC<InputNumberProps> = (props) => {
|
||||
const {
|
||||
unit,
|
||||
className,
|
||||
wrapperClassName,
|
||||
styleCss,
|
||||
onChange,
|
||||
amount,
|
||||
value,
|
||||
size = 'regular',
|
||||
max,
|
||||
min,
|
||||
defaultValue,
|
||||
wrapClassName,
|
||||
controlWrapClassName,
|
||||
controlClassName,
|
||||
disabled,
|
||||
step,
|
||||
id,
|
||||
name,
|
||||
readOnly,
|
||||
required,
|
||||
type: _type,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const resolvedStep = resolveStep(amount, step)
|
||||
const stepAmount = typeof resolvedStep === 'number' ? resolvedStep : 1
|
||||
|
||||
const handleValueChange = useCallback((
|
||||
nextValue: number | null,
|
||||
eventDetails: BaseNumberFieldRoot.ChangeEventDetails,
|
||||
) => {
|
||||
if (value === undefined && STEPPER_REASONS.has(eventDetails.reason)) {
|
||||
onChange(defaultValue ?? 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (nextValue === null) {
|
||||
onChange(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (exceedsStepBounds({
|
||||
value,
|
||||
reason: eventDetails.reason,
|
||||
stepAmount,
|
||||
min,
|
||||
max,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValueWithinBounds(nextValue, min, max))
|
||||
return
|
||||
|
||||
onChange(nextValue)
|
||||
}, [defaultValue, max, min, onChange, stepAmount, value])
|
||||
|
||||
return (
|
||||
<div data-testid="input-number-wrapper" className={cn('flex w-full min-w-0', wrapClassName, wrapperClassName)}>
|
||||
<NumberField
|
||||
className="min-w-0 grow"
|
||||
value={value ?? null}
|
||||
min={min}
|
||||
max={max}
|
||||
step={resolvedStep}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
id={id}
|
||||
name={name}
|
||||
allowOutOfRange
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<NumberFieldGroup size={size}>
|
||||
<NumberFieldInput
|
||||
{...rest}
|
||||
size={size}
|
||||
style={styleCss}
|
||||
className={className}
|
||||
/>
|
||||
{unit && (
|
||||
<NumberFieldUnit size={size}>
|
||||
{unit}
|
||||
</NumberFieldUnit>
|
||||
)}
|
||||
<NumberFieldControls
|
||||
data-testid="input-number-controls"
|
||||
className={controlWrapClassName}
|
||||
>
|
||||
<NumberFieldIncrement
|
||||
aria-label="increment"
|
||||
size={size}
|
||||
className={controlClassName}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-arrow-up-s-line size-3" />
|
||||
</NumberFieldIncrement>
|
||||
<NumberFieldDecrement
|
||||
aria-label="decrement"
|
||||
size={size}
|
||||
className={controlClassName}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-arrow-down-s-line size-3" />
|
||||
</NumberFieldDecrement>
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -112,6 +112,63 @@ describe('ParamItem', () => {
|
||||
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0.8)
|
||||
})
|
||||
|
||||
it('should reset the textbox and slider when users clear the input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const StatefulParamItem = () => {
|
||||
const [value, setValue] = useState(defaultProps.value)
|
||||
|
||||
return (
|
||||
<ParamItem
|
||||
{...defaultProps}
|
||||
value={value}
|
||||
onChange={(key: string, nextValue: number) => {
|
||||
defaultProps.onChange(key, nextValue)
|
||||
setValue(nextValue)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<StatefulParamItem />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0)
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0')
|
||||
|
||||
await user.tab()
|
||||
|
||||
expect(input).toHaveValue('0')
|
||||
})
|
||||
|
||||
it('should clamp out-of-range text edits before updating state', async () => {
|
||||
const user = userEvent.setup()
|
||||
const StatefulParamItem = () => {
|
||||
const [value, setValue] = useState(defaultProps.value)
|
||||
|
||||
return (
|
||||
<ParamItem
|
||||
{...defaultProps}
|
||||
value={value}
|
||||
onChange={(key: string, nextValue: number) => {
|
||||
defaultProps.onChange(key, nextValue)
|
||||
setValue(nextValue)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<StatefulParamItem />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
await user.type(input, '1.5')
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 1)
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '100')
|
||||
})
|
||||
|
||||
it('should pass scaled value to slider when max < 5', () => {
|
||||
render(<ParamItem {...defaultProps} value={0.5} />)
|
||||
const slider = screen.getByRole('slider')
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { FC } from 'react'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { InputNumber } from '../input-number'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '../ui/number-field'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
@@ -36,7 +43,7 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="system-sm-semibold mr-1 text-text-secondary">{name}</span>
|
||||
<span className="mr-1 text-text-secondary system-sm-semibold">{name}</span>
|
||||
{!noTooltip && (
|
||||
<Tooltip
|
||||
triggerClassName="w-4 h-4 shrink-0"
|
||||
@@ -47,20 +54,22 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
|
||||
</div>
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="mr-3 flex shrink-0 items-center">
|
||||
<InputNumber
|
||||
<NumberField
|
||||
disabled={!enable}
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
amount={step}
|
||||
size="regular"
|
||||
value={value}
|
||||
onChange={(value) => {
|
||||
onChange(id, value)
|
||||
}}
|
||||
className="w-[72px]"
|
||||
/>
|
||||
onValueChange={nextValue => onChange(id, nextValue ?? min)}
|
||||
>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput size="regular" className="w-[72px]" />
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size="regular" />
|
||||
<NumberFieldDecrement size="regular" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
<div className="flex grow items-center">
|
||||
<Slider
|
||||
|
||||
@@ -53,7 +53,7 @@ const createSelectorWithTransientPrefix = (prefix: string, suffix: string): stri
|
||||
}
|
||||
|
||||
const hasErrorIcon = (container: HTMLElement) => {
|
||||
return container.querySelector('svg.text-text-destructive') !== null
|
||||
return container.querySelector('svg.text-text-warning') !== null
|
||||
}
|
||||
|
||||
const renderVariableBlock = (props: {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useReactFlow, useStoreApi } from 'reactflow'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
|
||||
import {
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
UPDATE_WORKFLOW_NODES_MAP,
|
||||
} from './index'
|
||||
import { WorkflowVariableBlockNode } from './node'
|
||||
import { useLlmModelPluginInstalled } from './use-llm-model-plugin-installed'
|
||||
|
||||
type WorkflowVariableBlockComponentProps = {
|
||||
nodeKey: string
|
||||
@@ -68,6 +69,8 @@ const WorkflowVariableBlockComponent = ({
|
||||
const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]]
|
||||
|
||||
const isException = isExceptionVariable(varName, node?.type)
|
||||
const sourceNodeId = variables[isRagVar ? 1 : 0]
|
||||
const isLlmModelInstalled = useLlmModelPluginInstalled(sourceNodeId, localWorkflowNodesMap)
|
||||
const variableValid = useMemo(() => {
|
||||
let variableValid = true
|
||||
const isEnv = isENV(variables)
|
||||
@@ -144,7 +147,13 @@ const WorkflowVariableBlockComponent = ({
|
||||
handleVariableJump()
|
||||
}}
|
||||
isExceptionVariable={isException}
|
||||
errorMsg={!variableValid ? t('errorMsg.invalidVariable', { ns: 'workflow' }) : undefined}
|
||||
errorMsg={
|
||||
!variableValid
|
||||
? t('errorMsg.invalidVariable', { ns: 'workflow' })
|
||||
: !isLlmModelInstalled
|
||||
? t('errorMsg.modelPluginNotInstalled', { ns: 'workflow' })
|
||||
: undefined
|
||||
}
|
||||
isSelected={isSelected}
|
||||
ref={ref}
|
||||
notShowFullPath={isShowAPart}
|
||||
@@ -155,9 +164,9 @@ const WorkflowVariableBlockComponent = ({
|
||||
return Item
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
noDecoration
|
||||
popupContent={(
|
||||
<Tooltip>
|
||||
<TooltipTrigger disabled={!isShowAPart} render={<div>{Item}</div>} />
|
||||
<TooltipContent variant="plain">
|
||||
<VarFullPathPanel
|
||||
nodeName={node.title}
|
||||
path={variables.slice(1)}
|
||||
@@ -169,10 +178,7 @@ const WorkflowVariableBlockComponent = ({
|
||||
: Type.string}
|
||||
nodeType={node?.type}
|
||||
/>
|
||||
)}
|
||||
disabled={!isShowAPart}
|
||||
>
|
||||
<div>{Item}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useLlmModelPluginInstalled } from './use-llm-model-plugin-installed'
|
||||
|
||||
let mockModelProviders: Array<{ provider: string }> = []
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: <T>(selector: (state: { modelProviders: Array<{ provider: string }> }) => T): T =>
|
||||
selector({ modelProviders: mockModelProviders }),
|
||||
}))
|
||||
|
||||
const createWorkflowNodesMap = (node: Record<string, unknown>): WorkflowNodesMap =>
|
||||
({
|
||||
target: {
|
||||
title: 'Target',
|
||||
type: BlockEnum.Start,
|
||||
...node,
|
||||
},
|
||||
} as unknown as WorkflowNodesMap)
|
||||
|
||||
describe('useLlmModelPluginInstalled', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockModelProviders = []
|
||||
})
|
||||
|
||||
it('should return true when the node is missing', () => {
|
||||
const { result } = renderHook(() => useLlmModelPluginInstalled('target', undefined))
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when the node is not an LLM node', () => {
|
||||
const workflowNodesMap = createWorkflowNodesMap({
|
||||
id: 'target',
|
||||
type: BlockEnum.Start,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when the matching model plugin is installed', () => {
|
||||
mockModelProviders = [
|
||||
{ provider: 'langgenius/openai/openai' },
|
||||
{ provider: 'langgenius/anthropic/claude' },
|
||||
]
|
||||
const workflowNodesMap = createWorkflowNodesMap({
|
||||
id: 'target',
|
||||
type: BlockEnum.LLM,
|
||||
modelProvider: 'langgenius/openai/gpt-4.1',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when the matching model plugin is not installed', () => {
|
||||
mockModelProviders = [
|
||||
{ provider: 'langgenius/anthropic/claude' },
|
||||
]
|
||||
const workflowNodesMap = createWorkflowNodesMap({
|
||||
id: 'target',
|
||||
type: BlockEnum.LLM,
|
||||
modelProvider: 'langgenius/openai/gpt-4.1',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { extractPluginId } from '@/app/components/workflow/utils/plugin'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
|
||||
export function useLlmModelPluginInstalled(
|
||||
nodeId: string,
|
||||
workflowNodesMap: WorkflowNodesMap | undefined,
|
||||
): boolean {
|
||||
const node = workflowNodesMap?.[nodeId]
|
||||
const modelProvider = node?.type === BlockEnum.LLM
|
||||
? node.modelProvider
|
||||
: undefined
|
||||
const modelPluginId = modelProvider ? extractPluginId(modelProvider) : undefined
|
||||
|
||||
return useProviderContextSelector((state) => {
|
||||
if (!modelPluginId)
|
||||
return true
|
||||
return state.modelProviders.some(p =>
|
||||
extractPluginId(p.provider) === modelPluginId,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export type GetVarType = (payload: {
|
||||
export type WorkflowVariableBlockType = {
|
||||
show?: boolean
|
||||
variables?: NodeOutPutVar[]
|
||||
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'>>
|
||||
workflowNodesMap?: WorkflowNodesMap
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
getVarType?: GetVarType
|
||||
@@ -81,12 +81,14 @@ export type WorkflowVariableBlockType = {
|
||||
onManageInputField?: () => void
|
||||
}
|
||||
|
||||
export type WorkflowNodesMap = Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'> & { modelProvider?: string }>
|
||||
|
||||
export type HITLInputBlockType = {
|
||||
show?: boolean
|
||||
nodeId: string
|
||||
formInputs?: FormInputItem[]
|
||||
variables?: NodeOutPutVar[]
|
||||
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'>>
|
||||
workflowNodesMap?: WorkflowNodesMap
|
||||
getVarType?: GetVarType
|
||||
onFormInputsChange?: (inputs: FormInputItem[]) => void
|
||||
onFormInputItemRemove: (varName: string) => void
|
||||
|
||||
57
web/app/components/base/tag-input/__tests__/interop.spec.tsx
Normal file
57
web/app/components/base/tag-input/__tests__/interop.spec.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ComponentType, InputHTMLAttributes } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
type AutosizeInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
inputClassName?: string
|
||||
}
|
||||
|
||||
const MockAutosizeInput: ComponentType<AutosizeInputProps> = ({ inputClassName, ...props }) => (
|
||||
<input data-testid="autosize-input" className={inputClassName} {...props} />
|
||||
)
|
||||
|
||||
describe('TagInput autosize interop', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('should support a namespace-style default export from react-18-input-autosize', async () => {
|
||||
vi.doMock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
vi.doMock('react-18-input-autosize', () => ({
|
||||
default: {
|
||||
default: MockAutosizeInput,
|
||||
},
|
||||
}))
|
||||
|
||||
const { default: TagInput } = await import('../index')
|
||||
|
||||
render(<TagInput items={[]} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('autosize-input')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should support a direct default export from react-18-input-autosize', async () => {
|
||||
vi.doMock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
vi.doMock('react-18-input-autosize', () => ({
|
||||
default: MockAutosizeInput,
|
||||
}))
|
||||
|
||||
const { default: TagInput } = await import('../index')
|
||||
|
||||
render(<TagInput items={[]} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('autosize-input')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { ChangeEvent, FC, KeyboardEvent } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import AutosizeInput from 'react-18-input-autosize'
|
||||
import _AutosizeInput from 'react-18-input-autosize'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
// CJS/ESM interop: Turbopack may resolve the module namespace object instead of the default export
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const AutosizeInput = ('default' in (_AutosizeInput as any) ? (_AutosizeInput as any).default : _AutosizeInput) as typeof _AutosizeInput
|
||||
|
||||
type TagInputProps = {
|
||||
items: string[]
|
||||
onChange: (items: string[]) => void
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('Toast', () => {
|
||||
)
|
||||
|
||||
const successToast = getToastElementByMessage('Success message')
|
||||
expect(successToast).toHaveClass('z-[1101]')
|
||||
const successIcon = within(successToast).getByTestId('toast-icon-success')
|
||||
expect(successIcon).toHaveClass('text-text-success')
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ const Toast = ({
|
||||
return (
|
||||
<div className={cn(
|
||||
className,
|
||||
'fixed z-[9999] mx-8 my-4 w-[360px] grow overflow-hidden rounded-xl',
|
||||
// Keep legacy toast above highPriority modals until overlay migration completes.
|
||||
'fixed z-[1101] mx-8 my-4 w-[360px] grow overflow-hidden rounded-xl',
|
||||
'border border-components-panel-border-subtle bg-components-panel-bg-blur shadow-sm',
|
||||
'top-0',
|
||||
'right-0',
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// During migration, z-[1002] is chosen to sit above all legacy overlays
|
||||
// (Modal z-[60], PortalToFollowElem callers up to z-[1001]).
|
||||
// Once all legacy overlays are migrated, this can be reduced back to z-50.
|
||||
// Toast — z-[9999], always on top (defined in toast component)
|
||||
// Toast uses z-[1101] during migration so it stays above legacy highPriority modals.
|
||||
|
||||
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
|
||||
import * as React from 'react'
|
||||
@@ -46,20 +46,24 @@ type DialogContentProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
overlayClassName?: string
|
||||
backdropProps?: React.ComponentPropsWithoutRef<typeof BaseDialog.Backdrop>
|
||||
}
|
||||
|
||||
export function DialogContent({
|
||||
children,
|
||||
className,
|
||||
overlayClassName,
|
||||
backdropProps,
|
||||
}: DialogContentProps) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<BaseDialog.Backdrop
|
||||
{...backdropProps}
|
||||
className={cn(
|
||||
'fixed inset-0 z-[1002] bg-background-overlay',
|
||||
'transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
|
||||
overlayClassName,
|
||||
backdropProps?.className,
|
||||
)}
|
||||
/>
|
||||
<BaseDialog.Popup
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
NumberFieldButtonProps,
|
||||
NumberFieldControlsProps,
|
||||
NumberFieldGroupProps,
|
||||
NumberFieldInputProps,
|
||||
NumberFieldUnitProps,
|
||||
} from '../index'
|
||||
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
@@ -10,104 +18,258 @@ import {
|
||||
NumberFieldUnit,
|
||||
} from '../index'
|
||||
|
||||
type RenderNumberFieldOptions = {
|
||||
defaultValue?: number
|
||||
groupProps?: Partial<NumberFieldGroupProps>
|
||||
inputProps?: Partial<NumberFieldInputProps>
|
||||
unitProps?: Partial<NumberFieldUnitProps> & { children?: ReactNode }
|
||||
controlsProps?: Partial<NumberFieldControlsProps>
|
||||
incrementProps?: Partial<NumberFieldButtonProps>
|
||||
decrementProps?: Partial<NumberFieldButtonProps>
|
||||
}
|
||||
|
||||
const renderNumberField = ({
|
||||
defaultValue = 8,
|
||||
groupProps,
|
||||
inputProps,
|
||||
unitProps,
|
||||
controlsProps,
|
||||
incrementProps,
|
||||
decrementProps,
|
||||
}: RenderNumberFieldOptions = {}) => {
|
||||
const {
|
||||
children: unitChildren = 'ms',
|
||||
...restUnitProps
|
||||
} = unitProps ?? {}
|
||||
|
||||
return render(
|
||||
<NumberField defaultValue={defaultValue}>
|
||||
<NumberFieldGroup data-testid="group" {...groupProps}>
|
||||
<NumberFieldInput
|
||||
aria-label="Amount"
|
||||
data-testid="input"
|
||||
{...inputProps}
|
||||
/>
|
||||
{unitProps && (
|
||||
<NumberFieldUnit data-testid="unit" {...restUnitProps}>
|
||||
{unitChildren}
|
||||
</NumberFieldUnit>
|
||||
)}
|
||||
{(controlsProps || incrementProps || decrementProps) && (
|
||||
<NumberFieldControls data-testid="controls" {...controlsProps}>
|
||||
<NumberFieldIncrement data-testid="increment" {...incrementProps} />
|
||||
<NumberFieldDecrement data-testid="decrement" {...decrementProps} />
|
||||
</NumberFieldControls>
|
||||
)}
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('NumberField wrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Export mapping should stay aligned with the Base UI primitive.
|
||||
describe('Exports', () => {
|
||||
it('should map NumberField to the matching base primitive root', () => {
|
||||
expect(NumberField).toBe(BaseNumberField.Root)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variants', () => {
|
||||
it('should apply regular variant classes and forward className to group and input', () => {
|
||||
render(
|
||||
<NumberField defaultValue={12}>
|
||||
<NumberFieldGroup size="regular" className="custom-group" data-testid="group">
|
||||
<NumberFieldInput
|
||||
aria-label="Regular amount"
|
||||
placeholder="Regular placeholder"
|
||||
size="regular"
|
||||
className="custom-input"
|
||||
/>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
// Group and input wrappers should preserve the design-system variants and DOM defaults.
|
||||
describe('Group and input', () => {
|
||||
it('should apply regular group classes by default and merge custom className', () => {
|
||||
renderNumberField({
|
||||
groupProps: {
|
||||
className: 'custom-group',
|
||||
},
|
||||
})
|
||||
|
||||
const group = screen.getByTestId('group')
|
||||
const input = screen.getByRole('textbox', { name: 'Regular amount' })
|
||||
|
||||
expect(group).toHaveClass('radius-md')
|
||||
expect(group).toHaveClass('custom-group')
|
||||
expect(input).toHaveAttribute('placeholder', 'Regular placeholder')
|
||||
expect(input).toHaveClass('px-3')
|
||||
expect(input).toHaveClass('py-[7px]')
|
||||
expect(input).toHaveClass('custom-input')
|
||||
})
|
||||
|
||||
it('should apply large variant classes to grouped parts when large size is provided', () => {
|
||||
render(
|
||||
<NumberField defaultValue={24}>
|
||||
<NumberFieldGroup size="large" data-testid="group">
|
||||
<NumberFieldInput aria-label="Large amount" size="large" />
|
||||
<NumberFieldUnit size="large">ms</NumberFieldUnit>
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement aria-label="Increment amount" size="large" />
|
||||
<NumberFieldDecrement aria-label="Decrement amount" size="large" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
it('should apply large group and input classes when large size is provided', () => {
|
||||
renderNumberField({
|
||||
groupProps: {
|
||||
size: 'large',
|
||||
},
|
||||
inputProps: {
|
||||
size: 'large',
|
||||
},
|
||||
})
|
||||
|
||||
const group = screen.getByTestId('group')
|
||||
const input = screen.getByRole('textbox', { name: 'Large amount' })
|
||||
const unit = screen.getByText('ms')
|
||||
const increment = screen.getByRole('button', { name: 'Increment amount' })
|
||||
const decrement = screen.getByRole('button', { name: 'Decrement amount' })
|
||||
const input = screen.getByTestId('input')
|
||||
|
||||
expect(group).toHaveClass('radius-lg')
|
||||
expect(input).toHaveClass('px-4')
|
||||
expect(input).toHaveClass('py-2')
|
||||
expect(unit).toHaveClass('flex')
|
||||
expect(unit).toHaveClass('items-center')
|
||||
expect(unit).toHaveClass('pr-2.5')
|
||||
expect(increment).toHaveClass('pt-1.5')
|
||||
expect(decrement).toHaveClass('pb-1.5')
|
||||
})
|
||||
|
||||
it('should set input defaults and forward passthrough props', () => {
|
||||
renderNumberField({
|
||||
inputProps: {
|
||||
className: 'custom-input',
|
||||
placeholder: 'Regular placeholder',
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Amount' })
|
||||
|
||||
expect(input).toHaveAttribute('autoComplete', 'off')
|
||||
expect(input).toHaveAttribute('autoCorrect', 'off')
|
||||
expect(input).toHaveAttribute('placeholder', 'Regular placeholder')
|
||||
expect(input).toBeRequired()
|
||||
expect(input).toHaveClass('px-3')
|
||||
expect(input).toHaveClass('py-[7px]')
|
||||
expect(input).toHaveClass('system-sm-regular')
|
||||
expect(input).toHaveClass('custom-input')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Passthrough props', () => {
|
||||
it('should forward passthrough props and custom classes to controls and buttons', () => {
|
||||
render(
|
||||
<NumberField defaultValue={8}>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput aria-label="Amount" size="regular" />
|
||||
<NumberFieldControls className="custom-controls" data-testid="controls">
|
||||
<NumberFieldIncrement
|
||||
aria-label="Increment"
|
||||
size="regular"
|
||||
className="custom-increment"
|
||||
data-track-id="increment-track"
|
||||
/>
|
||||
<NumberFieldDecrement
|
||||
aria-label="Decrement"
|
||||
size="regular"
|
||||
className="custom-decrement"
|
||||
data-track-id="decrement-track"
|
||||
/>
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
// Unit and controls wrappers should preserve layout tokens and HTML passthrough props.
|
||||
describe('Unit and controls', () => {
|
||||
it.each([
|
||||
['regular', 'pr-2'],
|
||||
['large', 'pr-2.5'],
|
||||
] as const)('should apply the %s unit spacing variant', (size, spacingClass) => {
|
||||
renderNumberField({
|
||||
unitProps: {
|
||||
size,
|
||||
className: 'custom-unit',
|
||||
title: `unit-${size}`,
|
||||
},
|
||||
})
|
||||
|
||||
const unit = screen.getByTestId('unit')
|
||||
|
||||
expect(unit).toHaveTextContent('ms')
|
||||
expect(unit).toHaveAttribute('title', `unit-${size}`)
|
||||
expect(unit).toHaveClass('custom-unit')
|
||||
expect(unit).toHaveClass(spacingClass)
|
||||
})
|
||||
|
||||
it('should forward passthrough props to controls', () => {
|
||||
renderNumberField({
|
||||
controlsProps: {
|
||||
className: 'custom-controls',
|
||||
title: 'controls-title',
|
||||
},
|
||||
})
|
||||
|
||||
const controls = screen.getByTestId('controls')
|
||||
const increment = screen.getByRole('button', { name: 'Increment' })
|
||||
const decrement = screen.getByRole('button', { name: 'Decrement' })
|
||||
|
||||
expect(controls).toHaveClass('border-l')
|
||||
expect(controls).toHaveAttribute('title', 'controls-title')
|
||||
expect(controls).toHaveClass('custom-controls')
|
||||
})
|
||||
})
|
||||
|
||||
// Increment and decrement buttons should preserve accessible naming, icon fallbacks, and spacing variants.
|
||||
describe('Control buttons', () => {
|
||||
it('should provide localized aria labels and default icons when labels are not provided', () => {
|
||||
renderNumberField({
|
||||
controlsProps: {},
|
||||
})
|
||||
|
||||
const increment = screen.getByRole('button', { name: 'common.operation.increment' })
|
||||
const decrement = screen.getByRole('button', { name: 'common.operation.decrement' })
|
||||
|
||||
expect(increment.querySelector('.i-ri-arrow-up-s-line')).toBeInTheDocument()
|
||||
expect(decrement.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve explicit aria labels and custom children', () => {
|
||||
renderNumberField({
|
||||
controlsProps: {},
|
||||
incrementProps: {
|
||||
'aria-label': 'Increase amount',
|
||||
'children': <span data-testid="custom-increment-icon">+</span>,
|
||||
},
|
||||
decrementProps: {
|
||||
'aria-label': 'Decrease amount',
|
||||
'children': <span data-testid="custom-decrement-icon">-</span>,
|
||||
},
|
||||
})
|
||||
|
||||
const increment = screen.getByRole('button', { name: 'Increase amount' })
|
||||
const decrement = screen.getByRole('button', { name: 'Decrease amount' })
|
||||
|
||||
expect(increment).toContainElement(screen.getByTestId('custom-increment-icon'))
|
||||
expect(decrement).toContainElement(screen.getByTestId('custom-decrement-icon'))
|
||||
expect(increment.querySelector('.i-ri-arrow-up-s-line')).not.toBeInTheDocument()
|
||||
expect(decrement.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the fallback aria labels when aria-label is omitted in props', () => {
|
||||
renderNumberField({
|
||||
controlsProps: {},
|
||||
incrementProps: {
|
||||
'aria-label': undefined,
|
||||
},
|
||||
decrementProps: {
|
||||
'aria-label': undefined,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.increment' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.decrement' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should rely on aria-labelledby when provided instead of injecting a translated aria-label', () => {
|
||||
render(
|
||||
<>
|
||||
<span id="increment-label">Increment from label</span>
|
||||
<span id="decrement-label">Decrement from label</span>
|
||||
<NumberField defaultValue={8}>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput aria-label="Amount" size="regular" />
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement aria-labelledby="increment-label" size="regular" />
|
||||
<NumberFieldDecrement aria-labelledby="decrement-label" size="regular" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</>,
|
||||
)
|
||||
|
||||
const increment = screen.getByRole('button', { name: 'Increment from label' })
|
||||
const decrement = screen.getByRole('button', { name: 'Decrement from label' })
|
||||
|
||||
expect(increment).not.toHaveAttribute('aria-label')
|
||||
expect(decrement).not.toHaveAttribute('aria-label')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['regular', 'pt-1', 'pb-1'],
|
||||
['large', 'pt-1.5', 'pb-1.5'],
|
||||
] as const)('should apply the %s control button compound spacing classes', (size, incrementClass, decrementClass) => {
|
||||
renderNumberField({
|
||||
controlsProps: {},
|
||||
incrementProps: {
|
||||
size,
|
||||
className: 'custom-increment',
|
||||
},
|
||||
decrementProps: {
|
||||
size,
|
||||
className: 'custom-decrement',
|
||||
title: `decrement-${size}`,
|
||||
},
|
||||
})
|
||||
|
||||
const increment = screen.getByTestId('increment')
|
||||
const decrement = screen.getByTestId('decrement')
|
||||
|
||||
expect(increment).toHaveClass(incrementClass)
|
||||
expect(increment).toHaveClass('custom-increment')
|
||||
expect(increment).toHaveAttribute('data-track-id', 'increment-track')
|
||||
expect(decrement).toHaveClass(decrementClass)
|
||||
expect(decrement).toHaveClass('custom-decrement')
|
||||
expect(decrement).toHaveAttribute('data-track-id', 'decrement-track')
|
||||
expect(decrement).toHaveAttribute('title', `decrement-${size}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
285
web/app/components/base/ui/number-field/index.stories.tsx
Normal file
285
web/app/components/base/ui/number-field/index.stories.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useId, useState } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
NumberFieldUnit,
|
||||
} from '.'
|
||||
|
||||
type DemoFieldProps = {
|
||||
label: string
|
||||
helperText: string
|
||||
placeholder: string
|
||||
size: 'regular' | 'large'
|
||||
unit?: string
|
||||
defaultValue?: number | null
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
showCurrentValue?: boolean
|
||||
widthClassName?: string
|
||||
formatValue?: (value: number | null) => string
|
||||
}
|
||||
|
||||
const formatNumericValue = (value: number | null, unit?: string) => {
|
||||
if (value === null)
|
||||
return 'Empty'
|
||||
|
||||
if (!unit)
|
||||
return String(value)
|
||||
|
||||
return `${value} ${unit}`
|
||||
}
|
||||
|
||||
const FieldLabel = ({
|
||||
inputId,
|
||||
label,
|
||||
helperText,
|
||||
}: Pick<DemoFieldProps, 'label' | 'helperText'> & { inputId: string }) => (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={inputId} className="text-text-secondary system-sm-medium">
|
||||
{label}
|
||||
</label>
|
||||
<p className="text-text-tertiary system-xs-regular">{helperText}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DemoField = ({
|
||||
label,
|
||||
helperText,
|
||||
placeholder,
|
||||
size,
|
||||
unit,
|
||||
defaultValue,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
disabled,
|
||||
readOnly,
|
||||
showCurrentValue,
|
||||
widthClassName,
|
||||
formatValue,
|
||||
}: DemoFieldProps) => {
|
||||
const inputId = useId()
|
||||
const [value, setValue] = useState<number | null>(defaultValue ?? null)
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full max-w-80 flex-col gap-2', widthClassName)}>
|
||||
<FieldLabel inputId={inputId} label={label} helperText={helperText} />
|
||||
<NumberField
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
onValueChange={setValue}
|
||||
>
|
||||
<NumberFieldGroup size={size}>
|
||||
<NumberFieldInput
|
||||
id={inputId}
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
size={size}
|
||||
/>
|
||||
{unit && <NumberFieldUnit size={size}>{unit}</NumberFieldUnit>}
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size={size} />
|
||||
<NumberFieldDecrement size={size} />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
{showCurrentValue && (
|
||||
<p className="text-text-quaternary system-xs-regular">
|
||||
Current value:
|
||||
{' '}
|
||||
{formatValue ? formatValue(value) : formatNumericValue(value, unit)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/NumberField',
|
||||
component: NumberField,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound numeric input built on Base UI NumberField. Stories explicitly enumerate the shipped CVA variants, then cover realistic numeric-entry cases such as decimals, empty values, range limits, read-only, and disabled states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof NumberField>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const VariantMatrix: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-[720px] gap-6 md:grid-cols-2">
|
||||
<DemoField
|
||||
label="Top K"
|
||||
helperText="Regular size without suffix. Covers the regular group, input, and control button spacing."
|
||||
placeholder="Set top K"
|
||||
size="regular"
|
||||
defaultValue={3}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
/>
|
||||
<DemoField
|
||||
label="Score threshold"
|
||||
helperText="Regular size with a suffix so the regular unit variant is visible."
|
||||
placeholder="Set threshold"
|
||||
size="regular"
|
||||
unit="%"
|
||||
defaultValue={85}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
<DemoField
|
||||
label="Chunk overlap"
|
||||
helperText="Large size without suffix. Matches the larger dataset form treatment."
|
||||
placeholder="Set overlap"
|
||||
size="large"
|
||||
defaultValue={64}
|
||||
min={0}
|
||||
max={512}
|
||||
step={16}
|
||||
/>
|
||||
<DemoField
|
||||
label="Max segment length"
|
||||
helperText="Large size with suffix so the large unit variant is also enumerated."
|
||||
placeholder="Set length"
|
||||
size="large"
|
||||
unit="tokens"
|
||||
defaultValue={512}
|
||||
min={1}
|
||||
max={4000}
|
||||
step={32}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DecimalInputs: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-[720px] gap-6 md:grid-cols-2">
|
||||
<DemoField
|
||||
label="Score threshold"
|
||||
helperText="Two-decimal precision with a 0.01 step, like retrieval tuning fields."
|
||||
placeholder="0.00"
|
||||
size="regular"
|
||||
defaultValue={0.82}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
showCurrentValue
|
||||
formatValue={value => value === null ? 'Empty' : value.toFixed(2)}
|
||||
/>
|
||||
<DemoField
|
||||
label="Temperature"
|
||||
helperText="One-decimal stepping for generation parameters."
|
||||
placeholder="0.0"
|
||||
size="large"
|
||||
defaultValue={0.7}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
showCurrentValue
|
||||
formatValue={value => value === null ? 'Empty' : value.toFixed(1)}
|
||||
/>
|
||||
<DemoField
|
||||
label="Penalty"
|
||||
helperText="Starts empty so the placeholder and empty numeric state are both visible."
|
||||
placeholder="Optional"
|
||||
size="regular"
|
||||
defaultValue={null}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.05}
|
||||
showCurrentValue
|
||||
formatValue={value => value === null ? 'Empty' : value.toFixed(2)}
|
||||
/>
|
||||
<DemoField
|
||||
label="Latency budget"
|
||||
helperText="Decimal input with a unit suffix and larger spacing."
|
||||
placeholder="0.0"
|
||||
size="large"
|
||||
unit="s"
|
||||
defaultValue={1.5}
|
||||
min={0.5}
|
||||
max={10}
|
||||
step={0.5}
|
||||
showCurrentValue
|
||||
formatValue={value => value === null ? 'Empty' : `${value.toFixed(1)} s`}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const BoundariesAndStates: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-[720px] gap-6 md:grid-cols-2">
|
||||
<DemoField
|
||||
label="HTTP status code"
|
||||
helperText="Integer-only style usage with tighter bounds from 100 to 599."
|
||||
placeholder="200"
|
||||
size="regular"
|
||||
defaultValue={200}
|
||||
min={100}
|
||||
max={599}
|
||||
step={1}
|
||||
showCurrentValue
|
||||
/>
|
||||
<DemoField
|
||||
label="Request timeout"
|
||||
helperText="Bounded regular input with suffix, common in system settings."
|
||||
placeholder="Set timeout"
|
||||
size="regular"
|
||||
unit="ms"
|
||||
defaultValue={1200}
|
||||
min={100}
|
||||
max={10000}
|
||||
step={100}
|
||||
showCurrentValue
|
||||
/>
|
||||
<DemoField
|
||||
label="Retry count"
|
||||
helperText="Disabled state preserves the layout while switching to disabled tokens."
|
||||
placeholder="Retry count"
|
||||
size="large"
|
||||
defaultValue={5}
|
||||
min={0}
|
||||
max={10}
|
||||
step={1}
|
||||
disabled
|
||||
showCurrentValue
|
||||
/>
|
||||
<DemoField
|
||||
label="Archived score threshold"
|
||||
helperText="Read-only state keeps the same structure but removes interactive affordances."
|
||||
placeholder="0.00"
|
||||
size="large"
|
||||
unit="%"
|
||||
defaultValue={92}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
readOnly
|
||||
showCurrentValue
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import type { VariantProps } from 'class-variance-authority'
|
||||
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const NumberField = BaseNumberField.Root
|
||||
export type NumberFieldRootProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Root>
|
||||
|
||||
export const numberFieldGroupVariants = cva(
|
||||
[
|
||||
@@ -15,7 +17,7 @@ export const numberFieldGroupVariants = cva(
|
||||
'data-[focused]:border-components-input-border-active data-[focused]:bg-components-input-bg-active data-[focused]:shadow-xs',
|
||||
'data-[disabled]:cursor-not-allowed data-[disabled]:border-transparent data-[disabled]:bg-components-input-bg-disabled data-[disabled]:text-components-input-text-filled-disabled',
|
||||
'data-[disabled]:hover:border-transparent data-[disabled]:hover:bg-components-input-bg-disabled',
|
||||
'data-[readonly]:shadow-none motion-reduce:transition-none',
|
||||
'data-[readonly]:shadow-none data-[readonly]:hover:border-transparent data-[readonly]:hover:bg-components-input-bg-normal motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
@@ -29,8 +31,9 @@ export const numberFieldGroupVariants = cva(
|
||||
},
|
||||
},
|
||||
)
|
||||
export type NumberFieldSize = NonNullable<VariantProps<typeof numberFieldGroupVariants>['size']>
|
||||
|
||||
type NumberFieldGroupProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Group> & VariantProps<typeof numberFieldGroupVariants>
|
||||
export type NumberFieldGroupProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Group> & VariantProps<typeof numberFieldGroupVariants>
|
||||
|
||||
export function NumberFieldGroup({
|
||||
className,
|
||||
@@ -65,7 +68,7 @@ export const numberFieldInputVariants = cva(
|
||||
},
|
||||
)
|
||||
|
||||
type NumberFieldInputProps = Omit<React.ComponentPropsWithoutRef<typeof BaseNumberField.Input>, 'size'> & VariantProps<typeof numberFieldInputVariants>
|
||||
export type NumberFieldInputProps = Omit<React.ComponentPropsWithoutRef<typeof BaseNumberField.Input>, 'size'> & VariantProps<typeof numberFieldInputVariants>
|
||||
|
||||
export function NumberFieldInput({
|
||||
className,
|
||||
@@ -95,7 +98,7 @@ export const numberFieldUnitVariants = cva(
|
||||
},
|
||||
)
|
||||
|
||||
type NumberFieldUnitProps = React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof numberFieldUnitVariants>
|
||||
export type NumberFieldUnitProps = React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof numberFieldUnitVariants>
|
||||
|
||||
export function NumberFieldUnit({
|
||||
className,
|
||||
@@ -114,7 +117,7 @@ export const numberFieldControlsVariants = cva(
|
||||
'flex shrink-0 flex-col items-stretch border-l border-divider-subtle bg-transparent text-text-tertiary',
|
||||
)
|
||||
|
||||
type NumberFieldControlsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
export type NumberFieldControlsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function NumberFieldControls({
|
||||
className,
|
||||
@@ -130,11 +133,12 @@ export function NumberFieldControls({
|
||||
|
||||
export const numberFieldControlButtonVariants = cva(
|
||||
[
|
||||
'flex items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors',
|
||||
'flex touch-manipulation select-none items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors',
|
||||
'hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover',
|
||||
'disabled:cursor-not-allowed disabled:hover:bg-transparent',
|
||||
'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent',
|
||||
'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent',
|
||||
'focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
'disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:focus-visible:bg-transparent disabled:focus-visible:ring-0',
|
||||
'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent group-data-[disabled]/number-field:focus-visible:bg-transparent group-data-[disabled]/number-field:focus-visible:ring-0',
|
||||
'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent group-data-[readonly]/number-field:focus-visible:bg-transparent group-data-[readonly]/number-field:focus-visible:ring-0',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
@@ -182,30 +186,42 @@ type NumberFieldButtonVariantProps = Omit<
|
||||
'direction'
|
||||
>
|
||||
|
||||
type NumberFieldButtonProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Increment> & NumberFieldButtonVariantProps
|
||||
export type NumberFieldButtonProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Increment> & NumberFieldButtonVariantProps
|
||||
|
||||
export function NumberFieldIncrement({
|
||||
className,
|
||||
children,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<BaseNumberField.Increment
|
||||
className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : t('operation.increment', { ns: 'common' }))}
|
||||
className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)}
|
||||
>
|
||||
{children ?? <span aria-hidden="true" className="i-ri-arrow-up-s-line size-3" />}
|
||||
</BaseNumberField.Increment>
|
||||
)
|
||||
}
|
||||
|
||||
export function NumberFieldDecrement({
|
||||
className,
|
||||
children,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<BaseNumberField.Decrement
|
||||
className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : t('operation.decrement', { ns: 'common' }))}
|
||||
className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)}
|
||||
>
|
||||
{children ?? <span aria-hidden="true" className="i-ri-arrow-down-s-line size-3" />}
|
||||
</BaseNumberField.Decrement>
|
||||
)
|
||||
}
|
||||
|
||||
313
web/app/components/base/ui/toast/__tests__/index.spec.tsx
Normal file
313
web/app/components/base/ui/toast/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { toast, ToastHost } from '../index'
|
||||
|
||||
describe('base/ui/toast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
act(() => {
|
||||
toast.close()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
toast.close()
|
||||
vi.runOnlyPendingTimers()
|
||||
})
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Core host and manager integration.
|
||||
it('should render a toast when add is called', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Saved',
|
||||
description: 'Your changes are available now.',
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Saved')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your changes are available now.')).toBeInTheDocument()
|
||||
const viewport = screen.getByRole('region', { name: 'common.toast.notifications' })
|
||||
expect(viewport).toHaveAttribute('aria-live', 'polite')
|
||||
expect(viewport).toHaveClass('z-[1101]')
|
||||
expect(viewport.firstElementChild).toHaveClass('top-4')
|
||||
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
|
||||
expect(document.body.querySelector('button[aria-label="common.toast.close"][aria-hidden="true"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Collapsed stacks should keep multiple toast roots mounted for smooth stack animation.
|
||||
it('should keep multiple toast roots mounted in a collapsed stack', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'First toast',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('First toast')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Second toast',
|
||||
})
|
||||
toast.add({
|
||||
title: 'Third toast',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Third toast')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('dialog')).toHaveLength(3)
|
||||
expect(document.body.querySelectorAll('button[aria-label="common.toast.close"][aria-hidden="true"]')).toHaveLength(3)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByRole('region', { name: 'common.toast.notifications' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('button[aria-label="common.toast.close"][aria-hidden="true"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Base UI limit should cap the visible stack and mark overflow toasts as limited.
|
||||
it('should mark overflow toasts as limited when the stack exceeds the configured limit', async () => {
|
||||
render(<ToastHost limit={1} />)
|
||||
|
||||
act(() => {
|
||||
toast.add({ title: 'First toast' })
|
||||
toast.add({ title: 'Second toast' })
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Second toast')).toBeInTheDocument()
|
||||
expect(document.body.querySelector('[data-limited]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Closing should work through the public manager API.
|
||||
it('should close a toast when close(id) is called', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
let toastId = ''
|
||||
act(() => {
|
||||
toastId = toast.add({
|
||||
title: 'Closable',
|
||||
description: 'This toast can be removed.',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Closable')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
toast.close(toastId)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Closable')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User dismissal needs to remain accessible.
|
||||
it('should close a toast when the dismiss button is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<ToastHost />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Dismiss me',
|
||||
description: 'Manual dismissal path.',
|
||||
onClose,
|
||||
})
|
||||
})
|
||||
|
||||
fireEvent.mouseEnter(screen.getByRole('region', { name: 'common.toast.notifications' }))
|
||||
|
||||
const dismissButton = await screen.findByRole('button', { name: 'common.toast.close' })
|
||||
|
||||
act(() => {
|
||||
dismissButton.click()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Dismiss me')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Base UI default timeout should apply when no timeout is provided.
|
||||
it('should auto dismiss toasts with the Base UI default timeout', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Default timeout',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Default timeout')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4999)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Default timeout')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Default timeout')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Provider timeout should apply to all toasts when configured.
|
||||
it('should respect the host timeout configuration', async () => {
|
||||
render(<ToastHost timeout={3000} />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Configured timeout',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Configured timeout')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2999)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Configured timeout')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Configured timeout')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Callers must be able to override or disable timeout per toast.
|
||||
it('should respect custom timeout values including zero', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Custom timeout',
|
||||
timeout: 1000,
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Custom timeout')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Custom timeout')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Persistent',
|
||||
timeout: 0,
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Persistent')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10000)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Persistent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Updates should flow through the same manager state.
|
||||
it('should update an existing toast', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
let toastId = ''
|
||||
act(() => {
|
||||
toastId = toast.add({
|
||||
title: 'Loading',
|
||||
description: 'Preparing your data…',
|
||||
type: 'info',
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Loading')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
toast.update(toastId, {
|
||||
title: 'Done',
|
||||
description: 'Your data is ready.',
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByText('Done')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your data is ready.')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Loading')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Action props should pass through to the Base UI action button.
|
||||
it('should render and invoke toast action props', async () => {
|
||||
const onAction = vi.fn()
|
||||
|
||||
render(<ToastHost />)
|
||||
|
||||
act(() => {
|
||||
toast.add({
|
||||
title: 'Action toast',
|
||||
actionProps: {
|
||||
children: 'Undo',
|
||||
onClick: onAction,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const actionButton = await screen.findByRole('button', { name: 'Undo' })
|
||||
|
||||
act(() => {
|
||||
actionButton.click()
|
||||
})
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Promise helpers are part of the public API and need a regression test.
|
||||
it('should transition a promise toast from loading to success', async () => {
|
||||
render(<ToastHost />)
|
||||
|
||||
let resolvePromise: ((value: string) => void) | undefined
|
||||
const promise = new Promise<string>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
|
||||
void act(() => toast.promise(promise, {
|
||||
loading: 'Saving…',
|
||||
success: result => ({
|
||||
title: 'Saved',
|
||||
description: result,
|
||||
type: 'success',
|
||||
}),
|
||||
error: 'Failed',
|
||||
}))
|
||||
|
||||
expect(await screen.findByText('Saving…')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise?.('Your changes are available now.')
|
||||
await promise
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Saved')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your changes are available now.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
332
web/app/components/base/ui/toast/index.stories.tsx
Normal file
332
web/app/components/base/ui/toast/index.stories.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import type { ReactNode } from 'react'
|
||||
import { toast, ToastHost } from '.'
|
||||
|
||||
const buttonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-2 text-sm text-text-secondary shadow-xs transition-colors hover:bg-state-base-hover'
|
||||
const cardClassName = 'flex min-h-[220px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6 shadow-sm shadow-shadow-shadow-3'
|
||||
|
||||
const ExampleCard = ({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
}) => {
|
||||
return (
|
||||
<section className={cardClassName}>
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold leading-6 text-text-primary">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm leading-6 text-text-secondary">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-auto flex flex-wrap gap-3">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const VariantExamples = () => {
|
||||
const createVariantToast = (type: 'success' | 'error' | 'warning' | 'info') => {
|
||||
const copy = {
|
||||
success: {
|
||||
title: 'Changes saved',
|
||||
description: 'Your draft is available to collaborators.',
|
||||
},
|
||||
error: {
|
||||
title: 'Sync failed',
|
||||
description: 'Check your network connection and try again.',
|
||||
},
|
||||
warning: {
|
||||
title: 'Storage almost full',
|
||||
description: 'You have less than 10% of workspace quota remaining.',
|
||||
},
|
||||
info: {
|
||||
title: 'Invitation sent',
|
||||
description: 'An email has been sent to the new teammate.',
|
||||
},
|
||||
} as const
|
||||
|
||||
toast.add({
|
||||
type,
|
||||
...copy[type],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ExampleCard
|
||||
eyebrow="Variants"
|
||||
title="Tone-specific notifications"
|
||||
description="Trigger the four supported tones from the shared viewport to validate iconography, gradient treatment, and copy density."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={() => createVariantToast('success')}>
|
||||
Success
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={() => createVariantToast('info')}>
|
||||
Info
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={() => createVariantToast('warning')}>
|
||||
Warning
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={() => createVariantToast('error')}>
|
||||
Error
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const StackExamples = () => {
|
||||
const createStack = () => {
|
||||
;[
|
||||
{
|
||||
type: 'info' as const,
|
||||
title: 'Generating preview',
|
||||
description: 'The first toast compresses behind the newest notification.',
|
||||
},
|
||||
{
|
||||
type: 'warning' as const,
|
||||
title: 'Review required',
|
||||
description: 'A second toast should deepen the stack without breaking spacing.',
|
||||
},
|
||||
{
|
||||
type: 'success' as const,
|
||||
title: 'Ready to publish',
|
||||
description: 'The newest toast stays frontmost while older items tuck behind it.',
|
||||
},
|
||||
].forEach(item => toast.add(item))
|
||||
}
|
||||
|
||||
const createBurst = () => {
|
||||
Array.from({ length: 5 }).forEach((_, index) => {
|
||||
toast.add({
|
||||
type: index % 2 === 0 ? 'info' : 'success',
|
||||
title: `Background task ${index + 1}`,
|
||||
description: 'Use this to inspect how the stack behaves near the host limit.',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ExampleCard
|
||||
eyebrow="Stack"
|
||||
title="Stacked viewport behavior"
|
||||
description="These examples mirror the Base UI docs pattern: repeated triggers should compress into a single shared stack at the top-right corner."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={createStack}>
|
||||
Create 3 stacked toasts
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={createBurst}>
|
||||
Stress the stack
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const PromiseExamples = () => {
|
||||
const createPromiseToast = () => {
|
||||
const request = new Promise<string>((resolve) => {
|
||||
window.setTimeout(() => resolve('The deployment is now available in production.'), 1400)
|
||||
})
|
||||
|
||||
void toast.promise(request, {
|
||||
loading: {
|
||||
type: 'info',
|
||||
title: 'Deploying workflow',
|
||||
description: 'Provisioning runtime and publishing the latest version.',
|
||||
},
|
||||
success: result => ({
|
||||
type: 'success',
|
||||
title: 'Deployment complete',
|
||||
description: result,
|
||||
}),
|
||||
error: () => ({
|
||||
type: 'error',
|
||||
title: 'Deployment failed',
|
||||
description: 'The release could not be completed.',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const createRejectingPromiseToast = () => {
|
||||
const request = new Promise<string>((_, reject) => {
|
||||
window.setTimeout(() => reject(new Error('intentional story failure')), 1200)
|
||||
})
|
||||
|
||||
void toast.promise(request, {
|
||||
loading: 'Validating model credentials…',
|
||||
success: 'Credentials verified',
|
||||
error: () => ({
|
||||
type: 'error',
|
||||
title: 'Credentials rejected',
|
||||
description: 'The model provider returned an authentication error.',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ExampleCard
|
||||
eyebrow="Promise"
|
||||
title="Async lifecycle"
|
||||
description="The promise helper should swap the same toast through loading, success, and error states instead of growing the stack unnecessarily."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={createPromiseToast}>
|
||||
Promise success
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={createRejectingPromiseToast}>
|
||||
Promise error
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const ActionExamples = () => {
|
||||
const createActionToast = () => {
|
||||
toast.add({
|
||||
type: 'warning',
|
||||
title: 'Project archived',
|
||||
description: 'You can restore it from workspace settings for the next 30 days.',
|
||||
actionProps: {
|
||||
children: 'Undo',
|
||||
onClick: () => {
|
||||
toast.add({
|
||||
type: 'success',
|
||||
title: 'Project restored',
|
||||
description: 'The workspace is active again.',
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const createLongCopyToast = () => {
|
||||
toast.add({
|
||||
type: 'info',
|
||||
title: 'Knowledge ingestion in progress',
|
||||
description: 'This longer example helps validate line wrapping, close button alignment, and action button placement when the content spans multiple rows.',
|
||||
actionProps: {
|
||||
children: 'View details',
|
||||
onClick: () => {
|
||||
toast.add({
|
||||
type: 'info',
|
||||
title: 'Job details opened',
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ExampleCard
|
||||
eyebrow="Action"
|
||||
title="Actionable toasts"
|
||||
description="Use these to verify the secondary action button, multi-line content, and the close affordance under tighter layouts."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={createActionToast}>
|
||||
Undo action
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={createLongCopyToast}>
|
||||
Long content
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const UpdateExamples = () => {
|
||||
const createUpdatableToast = () => {
|
||||
const toastId = toast.add({
|
||||
type: 'info',
|
||||
title: 'Import started',
|
||||
description: 'Preparing assets and metadata for processing.',
|
||||
timeout: 0,
|
||||
})
|
||||
|
||||
window.setTimeout(() => {
|
||||
toast.update(toastId, {
|
||||
type: 'success',
|
||||
title: 'Import finished',
|
||||
description: '128 records were imported successfully.',
|
||||
timeout: 5000,
|
||||
})
|
||||
}, 1400)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
toast.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<ExampleCard
|
||||
eyebrow="Update"
|
||||
title="Programmatic lifecycle"
|
||||
description="This example exercises manual updates on an existing toast and keeps a clear-all control nearby for repeated interaction during review."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={createUpdatableToast}>
|
||||
Add then update
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={clearAll}>
|
||||
Clear all
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const ToastDocsDemo = () => {
|
||||
return (
|
||||
<>
|
||||
<ToastHost timeout={5000} limit={5} />
|
||||
<div className="min-h-screen bg-background-default-subtle px-6 py-12">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
Base UI toast docs
|
||||
</div>
|
||||
<h2 className="text-[24px] font-semibold leading-8 text-text-primary">
|
||||
Shared stacked toast examples
|
||||
</h2>
|
||||
<p className="max-w-3xl text-sm leading-6 text-text-secondary">
|
||||
Each example card below triggers the same shared toast viewport in the top-right corner, so you can review stacking, state transitions, actions, and tone variants the same way the official Base UI documentation demonstrates toast behavior.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<VariantExamples />
|
||||
<StackExamples />
|
||||
<PromiseExamples />
|
||||
<ActionExamples />
|
||||
<UpdateExamples />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/UI Toast',
|
||||
component: ToastHost,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Dify toast host built on Base UI Toast. The story is organized as multiple example panels that all feed the same shared toast viewport, matching the way the Base UI documentation showcases toast behavior.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ToastHost>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const DocsPattern: Story = {
|
||||
render: () => <ToastDocsDemo />,
|
||||
}
|
||||
202
web/app/components/base/ui/toast/index.tsx
Normal file
202
web/app/components/base/ui/toast/index.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
ToastManagerAddOptions,
|
||||
ToastManagerPromiseOptions,
|
||||
ToastManagerUpdateOptions,
|
||||
ToastObject,
|
||||
} from '@base-ui/react/toast'
|
||||
import { Toast as BaseToast } from '@base-ui/react/toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type ToastData = Record<string, never>
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
type ToastAddOptions = Omit<ToastManagerAddOptions<ToastData>, 'data' | 'positionerProps' | 'type'> & {
|
||||
type?: ToastType
|
||||
}
|
||||
|
||||
type ToastUpdateOptions = Omit<ToastManagerUpdateOptions<ToastData>, 'data' | 'positionerProps' | 'type'> & {
|
||||
type?: ToastType
|
||||
}
|
||||
|
||||
type ToastPromiseOptions<Value> = {
|
||||
loading: string | ToastUpdateOptions
|
||||
success: string | ToastUpdateOptions | ((result: Value) => string | ToastUpdateOptions)
|
||||
error: string | ToastUpdateOptions | ((error: unknown) => string | ToastUpdateOptions)
|
||||
}
|
||||
|
||||
export type ToastHostProps = {
|
||||
timeout?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
const toastManager = BaseToast.createToastManager<ToastData>()
|
||||
|
||||
export const toast = {
|
||||
add(options: ToastAddOptions) {
|
||||
return toastManager.add(options)
|
||||
},
|
||||
close(toastId?: string) {
|
||||
toastManager.close(toastId)
|
||||
},
|
||||
update(toastId: string, options: ToastUpdateOptions) {
|
||||
toastManager.update(toastId, options)
|
||||
},
|
||||
promise<Value>(promiseValue: Promise<Value>, options: ToastPromiseOptions<Value>) {
|
||||
return toastManager.promise(promiseValue, options as ToastManagerPromiseOptions<Value, ToastData>)
|
||||
},
|
||||
}
|
||||
|
||||
function ToastIcon({ type }: { type?: string }) {
|
||||
if (type === 'success') {
|
||||
return <span aria-hidden="true" className="i-ri-checkbox-circle-fill h-5 w-5 text-text-success" />
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
return <span aria-hidden="true" className="i-ri-error-warning-fill h-5 w-5 text-text-destructive" />
|
||||
}
|
||||
|
||||
if (type === 'warning') {
|
||||
return <span aria-hidden="true" className="i-ri-alert-fill h-5 w-5 text-text-warning-secondary" />
|
||||
}
|
||||
|
||||
if (type === 'info') {
|
||||
return <span aria-hidden="true" className="i-ri-information-2-fill h-5 w-5 text-text-accent" />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getToneGradientClasses(type?: string) {
|
||||
if (type === 'success')
|
||||
return 'from-components-badge-status-light-success-halo to-background-gradient-mask-transparent'
|
||||
|
||||
if (type === 'error')
|
||||
return 'from-components-badge-status-light-error-halo to-background-gradient-mask-transparent'
|
||||
|
||||
if (type === 'warning')
|
||||
return 'from-components-badge-status-light-warning-halo to-background-gradient-mask-transparent'
|
||||
|
||||
if (type === 'info')
|
||||
return 'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent'
|
||||
|
||||
return 'from-background-default-subtle to-background-gradient-mask-transparent'
|
||||
}
|
||||
|
||||
function ToastCard({
|
||||
toast: toastItem,
|
||||
showHoverBridge = false,
|
||||
}: {
|
||||
toast: ToastObject<ToastData>
|
||||
showHoverBridge?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
return (
|
||||
<BaseToast.Root
|
||||
toast={toastItem}
|
||||
className={cn(
|
||||
'pointer-events-auto absolute right-0 top-0 w-[360px] max-w-[calc(100vw-2rem)] origin-top-right cursor-default select-none outline-none',
|
||||
'[--toast-current-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:8px] [--toast-peek:5px] [--toast-scale:calc(1-(var(--toast-index)*0.0225))] [--toast-shrink:calc(1-var(--toast-scale))]',
|
||||
'[height:var(--toast-current-height)] [z-index:calc(100-var(--toast-index))]',
|
||||
'[transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms] motion-reduce:transition-none',
|
||||
'translate-x-[var(--toast-swipe-movement-x)] translate-y-[calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-current-height)))] scale-[var(--toast-scale)]',
|
||||
'data-[expanded]:translate-x-[var(--toast-swipe-movement-x)] data-[expanded]:translate-y-[calc(var(--toast-offset-y)+var(--toast-swipe-movement-y)+(var(--toast-index)*8px))] data-[expanded]:scale-100 data-[expanded]:[height:var(--toast-height)]',
|
||||
'data-[limited]:pointer-events-none data-[ending-style]:translate-y-[calc(var(--toast-swipe-movement-y)-150%)] data-[starting-style]:-translate-y-[150%] data-[ending-style]:opacity-0 data-[limited]:opacity-0 data-[starting-style]:opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn('absolute inset-[-1px] bg-gradient-to-r opacity-40', getToneGradientClasses(toastItem.type))}
|
||||
/>
|
||||
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-[behind]:opacity-0 data-[expanded]:opacity-100">
|
||||
<div className="flex shrink-0 items-center justify-center p-0.5">
|
||||
<ToastIcon type={toastItem.type} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 p-1">
|
||||
<div className="flex w-full items-center gap-1">
|
||||
{toastItem.title != null && (
|
||||
<BaseToast.Title className="break-words text-text-primary system-sm-semibold">
|
||||
{toastItem.title}
|
||||
</BaseToast.Title>
|
||||
)}
|
||||
</div>
|
||||
{toastItem.description != null && (
|
||||
<BaseToast.Description className="mt-1 break-words text-text-secondary system-xs-regular">
|
||||
{toastItem.description}
|
||||
</BaseToast.Description>
|
||||
)}
|
||||
{toastItem.actionProps && (
|
||||
<div className="flex w-full items-start gap-1 pb-1 pt-2">
|
||||
<BaseToast.Action
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center overflow-hidden rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] system-sm-medium',
|
||||
'hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-center rounded-md p-0.5">
|
||||
<BaseToast.Close
|
||||
aria-label={t('toast.close')}
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-md hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-components-input-border-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</BaseToast.Close>
|
||||
</div>
|
||||
</BaseToast.Content>
|
||||
</div>
|
||||
{showHoverBridge && (
|
||||
<div aria-hidden="true" className="absolute inset-x-0 -bottom-2 h-2" />
|
||||
)}
|
||||
</BaseToast.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastViewport() {
|
||||
const { t } = useTranslation('common')
|
||||
const { toasts } = BaseToast.useToastManager<ToastData>()
|
||||
|
||||
return (
|
||||
<BaseToast.Viewport
|
||||
aria-label={t('toast.notifications')}
|
||||
className={cn(
|
||||
// During overlay migration, toast must stay above legacy highPriority modals (z-[1100]).
|
||||
'group/toast-viewport pointer-events-none fixed inset-0 z-[1101] overflow-visible',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-4 top-4 w-[360px] max-w-[calc(100vw-2rem)] sm:right-8',
|
||||
)}
|
||||
>
|
||||
{toasts.map((toastItem, index) => (
|
||||
<ToastCard
|
||||
key={toastItem.id}
|
||||
toast={toastItem}
|
||||
showHoverBridge={index < toasts.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</BaseToast.Viewport>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToastHost({
|
||||
timeout,
|
||||
limit,
|
||||
}: ToastHostProps) {
|
||||
return (
|
||||
<BaseToast.Provider toastManager={toastManager} timeout={timeout} limit={limit}>
|
||||
<BaseToast.Portal>
|
||||
<ToastViewport />
|
||||
</BaseToast.Portal>
|
||||
</BaseToast.Provider>
|
||||
)
|
||||
}
|
||||
93
web/app/components/billing/pricing/__tests__/dialog.spec.tsx
Normal file
93
web/app/components/billing/pricing/__tests__/dialog.spec.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Mock } from 'vitest'
|
||||
import type { UsagePlanInfo } from '../../type'
|
||||
import { render } from '@testing-library/react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGetPricingPageLanguage } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '../../type'
|
||||
import Pricing from '../index'
|
||||
|
||||
type DialogProps = {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
let latestOnOpenChange: DialogProps['onOpenChange']
|
||||
|
||||
vi.mock('@/app/components/base/ui/dialog', () => ({
|
||||
Dialog: ({ children, onOpenChange }: DialogProps) => {
|
||||
latestOnOpenChange = onOpenChange
|
||||
return <div data-testid="dialog">{children}</div>
|
||||
},
|
||||
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../header', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<button data-testid="pricing-header-close" onClick={onClose}>close</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../plan-switcher', () => ({
|
||||
default: () => <div>plan-switcher</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../plans', () => ({
|
||||
default: () => <div>plans</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../footer', () => ({
|
||||
default: () => <div>footer</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetPricingPageLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
const buildUsage = (): UsagePlanInfo => ({
|
||||
buildApps: 0,
|
||||
teamMembers: 0,
|
||||
annotatedResponse: 0,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: 0,
|
||||
triggerEvents: 0,
|
||||
vectorSpace: 0,
|
||||
})
|
||||
|
||||
describe('Pricing dialog lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestOnOpenChange = undefined
|
||||
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
plan: {
|
||||
type: Plan.sandbox,
|
||||
usage: buildUsage(),
|
||||
total: buildUsage(),
|
||||
},
|
||||
})
|
||||
;(useGetPricingPageLanguage as Mock).mockReturnValue('en')
|
||||
})
|
||||
|
||||
it('should only call onCancel when the dialog requests closing', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
latestOnOpenChange?.(true)
|
||||
latestOnOpenChange?.(false)
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { CategoryEnum } from '..'
|
||||
import Footer from '../footer'
|
||||
import { CategoryEnum } from '../types'
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { Dialog } from '@/app/components/base/ui/dialog'
|
||||
import Header from '../header'
|
||||
|
||||
function renderHeader(onClose: () => void) {
|
||||
return render(
|
||||
<Dialog open>
|
||||
<Header onClose={onClose} />
|
||||
</Dialog>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -11,7 +20,7 @@ describe('Header', () => {
|
||||
it('should render title and description translations', () => {
|
||||
const handleClose = vi.fn()
|
||||
|
||||
render(<Header onClose={handleClose} />)
|
||||
renderHeader(handleClose)
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
|
||||
@@ -22,7 +31,7 @@ describe('Header', () => {
|
||||
describe('Props', () => {
|
||||
it('should invoke onClose when close button is clicked', () => {
|
||||
const handleClose = vi.fn()
|
||||
render(<Header onClose={handleClose} />)
|
||||
renderHeader(handleClose)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
@@ -32,7 +41,7 @@ describe('Header', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render structural elements with translation keys', () => {
|
||||
const { container } = render(<Header onClose={vi.fn()} />)
|
||||
const { container } = renderHeader(vi.fn())
|
||||
|
||||
expect(container.querySelector('span')).toBeInTheDocument()
|
||||
expect(container.querySelector('p')).toBeInTheDocument()
|
||||
|
||||
@@ -74,15 +74,11 @@ describe('Pricing', () => {
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should allow switching categories and handle esc key', () => {
|
||||
const handleCancel = vi.fn()
|
||||
render(<Pricing onCancel={handleCancel} />)
|
||||
it('should allow switching categories', () => {
|
||||
render(<Pricing onCancel={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByText('billing.plansCommon.self'))
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
expect(handleCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Category } from '.'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
import type { Category } from './types'
|
||||
import Link from 'next/link'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { CategoryEnum } from '.'
|
||||
import { CategoryEnum } from './types'
|
||||
|
||||
type FooterProps = {
|
||||
pricingPageURL: string
|
||||
@@ -34,7 +33,7 @@ const Footer = ({
|
||||
>
|
||||
{t('plansCommon.comparePlanAndFeatures', { ns: 'billing' })}
|
||||
</Link>
|
||||
<RiArrowRightUpLine className="size-4" />
|
||||
<span aria-hidden="true" className="i-ri-arrow-right-up-line size-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -39,7 +38,7 @@ const Header = ({
|
||||
className="absolute bottom-[40.5px] right-[-18px] z-10 size-9 rounded-full p-2"
|
||||
onClick={onClose}
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
<span aria-hidden="true" className="i-ri-close-line size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import type { Category } from './types'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGetPricingPageLanguage } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@@ -13,13 +13,7 @@ import Header from './header'
|
||||
import PlanSwitcher from './plan-switcher'
|
||||
import { PlanRange } from './plan-switcher/plan-range-switcher'
|
||||
import Plans from './plans'
|
||||
|
||||
export enum CategoryEnum {
|
||||
CLOUD = 'cloud',
|
||||
SELF = 'self',
|
||||
}
|
||||
|
||||
export type Category = CategoryEnum.CLOUD | CategoryEnum.SELF
|
||||
import { CategoryEnum } from './types'
|
||||
|
||||
type PricingProps = {
|
||||
onCancel: () => void
|
||||
@@ -33,42 +27,47 @@ const Pricing: FC<PricingProps> = ({
|
||||
const [planRange, setPlanRange] = React.useState<PlanRange>(PlanRange.monthly)
|
||||
const [currentCategory, setCurrentCategory] = useState<Category>(CategoryEnum.CLOUD)
|
||||
const canPay = isCurrentWorkspaceManager
|
||||
useKeyPress(['esc'], onCancel)
|
||||
|
||||
const pricingPageLanguage = useGetPricingPageLanguage()
|
||||
const pricingPageURL = pricingPageLanguage
|
||||
? `https://dify.ai/${pricingPageLanguage}/pricing#plans-and-features`
|
||||
: 'https://dify.ai/pricing#plans-and-features'
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 bottom-0 left-0 right-0 top-0 z-[1000] overflow-auto bg-saas-background"
|
||||
onClick={e => e.stopPropagation()}
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<div className="relative grid min-h-full min-w-[1200px] grid-rows-[1fr_auto_auto_1fr] overflow-hidden">
|
||||
<div className="absolute -top-12 left-0 right-0 -z-10">
|
||||
<NoiseTop />
|
||||
<DialogContent
|
||||
className="inset-0 h-full max-h-none w-full max-w-none translate-x-0 translate-y-0 overflow-auto rounded-none border-none bg-saas-background p-0 shadow-none"
|
||||
>
|
||||
<div className="relative grid min-h-full min-w-[1200px] grid-rows-[1fr_auto_auto_1fr] overflow-hidden">
|
||||
<div className="absolute -top-12 left-0 right-0 -z-10">
|
||||
<NoiseTop />
|
||||
</div>
|
||||
<Header onClose={onCancel} />
|
||||
<PlanSwitcher
|
||||
currentCategory={currentCategory}
|
||||
onChangeCategory={setCurrentCategory}
|
||||
currentPlanRange={planRange}
|
||||
onChangePlanRange={setPlanRange}
|
||||
/>
|
||||
<Plans
|
||||
plan={plan}
|
||||
currentPlan={currentCategory}
|
||||
planRange={planRange}
|
||||
canPay={canPay}
|
||||
/>
|
||||
<Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} />
|
||||
<div className="absolute -bottom-12 left-0 right-0 -z-10">
|
||||
<NoiseBottom />
|
||||
</div>
|
||||
</div>
|
||||
<Header onClose={onCancel} />
|
||||
<PlanSwitcher
|
||||
currentCategory={currentCategory}
|
||||
onChangeCategory={setCurrentCategory}
|
||||
currentPlanRange={planRange}
|
||||
onChangePlanRange={setPlanRange}
|
||||
/>
|
||||
<Plans
|
||||
plan={plan}
|
||||
currentPlan={currentCategory}
|
||||
planRange={planRange}
|
||||
canPay={canPay}
|
||||
/>
|
||||
<Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} />
|
||||
<div className="absolute -bottom-12 left-0 right-0 -z-10">
|
||||
<NoiseBottom />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
export default React.memo(Pricing)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { CategoryEnum } from '../../index'
|
||||
import { CategoryEnum } from '../../types'
|
||||
import PlanSwitcher from '../index'
|
||||
import { PlanRange } from '../plan-range-switcher'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Category } from '../index'
|
||||
import type { Category } from '../types'
|
||||
import type { PlanRange } from './plan-range-switcher'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
6
web/app/components/billing/pricing/types.ts
Normal file
6
web/app/components/billing/pricing/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum CategoryEnum {
|
||||
CLOUD = 'cloud',
|
||||
SELF = 'self',
|
||||
}
|
||||
|
||||
export type Category = CategoryEnum.CLOUD | CategoryEnum.SELF
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DelimiterInput, MaxLengthInput, OverlapInput } from '../inputs'
|
||||
|
||||
@@ -61,6 +61,21 @@ describe('MaxLengthInput', () => {
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset to the minimum when users clear the value', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<MaxLengthInput value={500} onChange={onChange} />)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } })
|
||||
expect(onChange).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should clamp out-of-range text edits before updating state', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<MaxLengthInput value={500} max={1000} onChange={onChange} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '1200' } })
|
||||
expect(onChange).toHaveBeenLastCalledWith(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OverlapInput', () => {
|
||||
@@ -89,4 +104,19 @@ describe('OverlapInput', () => {
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset to the minimum when users clear the value', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<OverlapInput value={50} onChange={onChange} />)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } })
|
||||
expect(onChange).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should clamp out-of-range text edits before updating state', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<OverlapInput value={50} max={100} onChange={onChange} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '150' } })
|
||||
expect(onChange).toHaveBeenLastCalledWith(100)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react'
|
||||
import type { InputProps } from '@/app/components/base/input'
|
||||
import type { InputNumberProps } from '@/app/components/base/input-number'
|
||||
import type { NumberFieldInputProps, NumberFieldRootProps, NumberFieldSize } from '@/app/components/base/ui/number-field'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
NumberFieldUnit,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import { env } from '@/env'
|
||||
|
||||
const TextLabel: FC<PropsWithChildren> = (props) => {
|
||||
@@ -25,7 +33,7 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = (props) =>
|
||||
return (
|
||||
<FormField label={(
|
||||
<div className="mb-1 flex items-center">
|
||||
<span className="system-sm-semibold mr-0.5">{t('stepTwo.separator', { ns: 'datasetCreation' })}</span>
|
||||
<span className="mr-0.5 system-sm-semibold">{t('stepTwo.separator', { ns: 'datasetCreation' })}</span>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="max-w-[200px]">
|
||||
@@ -46,19 +54,69 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = (props) =>
|
||||
)
|
||||
}
|
||||
|
||||
export const MaxLengthInput: FC<InputNumberProps> = (props) => {
|
||||
type CompoundNumberInputProps = Omit<NumberFieldRootProps, 'children' | 'className' | 'onValueChange'> & Omit<NumberFieldInputProps, 'children' | 'size' | 'onChange'> & {
|
||||
unit?: ReactNode
|
||||
size?: NumberFieldSize
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
function CompoundNumberInput({
|
||||
onChange,
|
||||
unit,
|
||||
size = 'large',
|
||||
className,
|
||||
...props
|
||||
}: CompoundNumberInputProps) {
|
||||
const { value, defaultValue, min, max, step, disabled, readOnly, required, id, name, onBlur, ...inputProps } = props
|
||||
const emptyValue = defaultValue ?? min ?? 0
|
||||
|
||||
return (
|
||||
<NumberField
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
id={id}
|
||||
name={name}
|
||||
onValueChange={value => onChange(value ?? emptyValue)}
|
||||
>
|
||||
<NumberFieldGroup size={size}>
|
||||
<NumberFieldInput
|
||||
{...inputProps}
|
||||
size={size}
|
||||
className={className}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{Boolean(unit) && (
|
||||
<NumberFieldUnit size={size}>
|
||||
{unit}
|
||||
</NumberFieldUnit>
|
||||
)}
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size={size} />
|
||||
<NumberFieldDecrement size={size} />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
)
|
||||
}
|
||||
|
||||
export const MaxLengthInput: FC<CompoundNumberInputProps> = (props) => {
|
||||
const maxValue = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH
|
||||
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<FormField label={(
|
||||
<div className="system-sm-semibold mb-1">
|
||||
<div className="mb-1 system-sm-semibold">
|
||||
{t('stepTwo.maxLength', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<InputNumber
|
||||
type="number"
|
||||
<CompoundNumberInput
|
||||
size="large"
|
||||
placeholder={`≤ ${maxValue}`}
|
||||
max={maxValue}
|
||||
@@ -69,7 +127,7 @@ export const MaxLengthInput: FC<InputNumberProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const OverlapInput: FC<InputNumberProps> = (props) => {
|
||||
export const OverlapInput: FC<CompoundNumberInputProps> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<FormField label={(
|
||||
@@ -85,8 +143,7 @@ export const OverlapInput: FC<InputNumberProps> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<InputNumber
|
||||
type="number"
|
||||
<CompoundNumberInput
|
||||
size="large"
|
||||
placeholder={t('stepTwo.overlap', { ns: 'datasetCreation' }) || ''}
|
||||
min={1}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -55,6 +55,21 @@ const createMockCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): Cr
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createDeferred = <T,>() => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
}
|
||||
}
|
||||
|
||||
// FireCrawl Component Tests
|
||||
|
||||
describe('FireCrawl', () => {
|
||||
@@ -217,7 +232,7 @@ describe('FireCrawl', () => {
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -241,7 +256,7 @@ describe('FireCrawl', () => {
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -277,6 +292,10 @@ describe('FireCrawl', () => {
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onJobIdChange with job_id from API response', async () => {
|
||||
@@ -301,6 +320,10 @@ describe('FireCrawl', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockOnJobIdChange).toHaveBeenCalledWith('my-job-123')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove empty max_depth from crawlOptions before sending to API', async () => {
|
||||
@@ -334,11 +357,23 @@ describe('FireCrawl', () => {
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
it('should show loading state while running', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockImplementation(() => new Promise(() => {})) // Never resolves
|
||||
const createTaskDeferred = createDeferred<{ job_id: string }>()
|
||||
mockCreateFirecrawlTask.mockImplementation(() => createTaskDeferred.promise)
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
data: [],
|
||||
total: 0,
|
||||
current: 0,
|
||||
time_consuming: 1,
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
@@ -352,6 +387,14 @@ describe('FireCrawl', () => {
|
||||
await waitFor(() => {
|
||||
expect(runButton).not.toHaveTextContent(/run/i)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
createTaskDeferred.resolve({ job_id: 'test-job-id' })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -656,7 +699,7 @@ describe('FireCrawl', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
// Total should be capped to limit (5)
|
||||
expect(mockCheckFirecrawlTaskStatus).toHaveBeenCalled()
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
@@ -35,6 +35,22 @@ enum Step {
|
||||
finished = 'finished',
|
||||
}
|
||||
|
||||
type CrawlState = {
|
||||
current: number
|
||||
total: number
|
||||
data: CrawlResultItem[]
|
||||
time_consuming: number | string
|
||||
}
|
||||
|
||||
type CrawlFinishedResult = {
|
||||
isCancelled?: boolean
|
||||
isError: boolean
|
||||
errorMessage?: string
|
||||
data: Partial<CrawlState> & {
|
||||
data: CrawlResultItem[]
|
||||
}
|
||||
}
|
||||
|
||||
const FireCrawl: FC<Props> = ({
|
||||
onPreview,
|
||||
checkedCrawlResult,
|
||||
@@ -46,10 +62,16 @@ const FireCrawl: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState<Step>(Step.init)
|
||||
const [controlFoldOptions, setControlFoldOptions] = useState<number>(0)
|
||||
const isMountedRef = useRef(true)
|
||||
useEffect(() => {
|
||||
if (step !== Step.init)
|
||||
setControlFoldOptions(Date.now())
|
||||
}, [step])
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
||||
const handleSetting = useCallback(() => {
|
||||
setShowAccountSettingModal({
|
||||
@@ -85,16 +107,19 @@ const FireCrawl: FC<Props> = ({
|
||||
const isInit = step === Step.init
|
||||
const isCrawlFinished = step === Step.finished
|
||||
const isRunning = step === Step.running
|
||||
const [crawlResult, setCrawlResult] = useState<{
|
||||
current: number
|
||||
total: number
|
||||
data: CrawlResultItem[]
|
||||
time_consuming: number | string
|
||||
} | undefined>(undefined)
|
||||
const [crawlResult, setCrawlResult] = useState<CrawlState | undefined>(undefined)
|
||||
const [crawlErrorMessage, setCrawlErrorMessage] = useState('')
|
||||
const showError = isCrawlFinished && crawlErrorMessage
|
||||
|
||||
const waitForCrawlFinished = useCallback(async (jobId: string) => {
|
||||
const waitForCrawlFinished = useCallback(async (jobId: string): Promise<CrawlFinishedResult> => {
|
||||
const cancelledResult: CrawlFinishedResult = {
|
||||
isCancelled: true,
|
||||
isError: false,
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await checkFirecrawlTaskStatus(jobId) as any
|
||||
if (res.status === 'completed') {
|
||||
@@ -104,7 +129,7 @@ const FireCrawl: FC<Props> = ({
|
||||
...res,
|
||||
total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)),
|
||||
},
|
||||
}
|
||||
} satisfies CrawlFinishedResult
|
||||
}
|
||||
if (res.status === 'error' || !res.status) {
|
||||
// can't get the error message from the firecrawl api
|
||||
@@ -114,12 +139,14 @@ const FireCrawl: FC<Props> = ({
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
} satisfies CrawlFinishedResult
|
||||
}
|
||||
res.data = res.data.map((item: any) => ({
|
||||
...item,
|
||||
content: item.markdown,
|
||||
}))
|
||||
if (!isMountedRef.current)
|
||||
return cancelledResult
|
||||
// update the progress
|
||||
setCrawlResult({
|
||||
...res,
|
||||
@@ -127,17 +154,21 @@ const FireCrawl: FC<Props> = ({
|
||||
})
|
||||
onCheckedCrawlResultChange(res.data || []) // default select the crawl result
|
||||
await sleep(2500)
|
||||
if (!isMountedRef.current)
|
||||
return cancelledResult
|
||||
return await waitForCrawlFinished(jobId)
|
||||
}
|
||||
catch (e: any) {
|
||||
const errorBody = await e.json()
|
||||
if (!isMountedRef.current)
|
||||
return cancelledResult
|
||||
const errorBody = typeof e?.json === 'function' ? await e.json() : undefined
|
||||
return {
|
||||
isError: true,
|
||||
errorMessage: errorBody.message,
|
||||
errorMessage: errorBody?.message,
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
} satisfies CrawlFinishedResult
|
||||
}
|
||||
}, [crawlOptions.limit, onCheckedCrawlResultChange])
|
||||
|
||||
@@ -162,24 +193,31 @@ const FireCrawl: FC<Props> = ({
|
||||
url,
|
||||
options: passToServerCrawlOptions,
|
||||
}) as any
|
||||
if (!isMountedRef.current)
|
||||
return
|
||||
const jobId = res.job_id
|
||||
onJobIdChange(jobId)
|
||||
const { isError, data, errorMessage } = await waitForCrawlFinished(jobId)
|
||||
const { isCancelled, isError, data, errorMessage } = await waitForCrawlFinished(jobId)
|
||||
if (isCancelled || !isMountedRef.current)
|
||||
return
|
||||
if (isError) {
|
||||
setCrawlErrorMessage(errorMessage || t(`${I18N_PREFIX}.unknownError`, { ns: 'datasetCreation' }))
|
||||
}
|
||||
else {
|
||||
setCrawlResult(data)
|
||||
setCrawlResult(data as CrawlState)
|
||||
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
|
||||
setCrawlErrorMessage('')
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if (!isMountedRef.current)
|
||||
return
|
||||
setCrawlErrorMessage(t(`${I18N_PREFIX}.unknownError`, { ns: 'datasetCreation' })!)
|
||||
console.log(e)
|
||||
}
|
||||
finally {
|
||||
setStep(Step.finished)
|
||||
if (isMountedRef.current)
|
||||
setStep(Step.finished)
|
||||
}
|
||||
}, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished, onCheckedCrawlResultChange])
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ const CSVUploader: FC<Props> = ({
|
||||
/>
|
||||
<div ref={dropRef}>
|
||||
{!file && (
|
||||
<div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur text-sm font-normal', dragging && 'border border-divider-subtle bg-components-panel-on-panel-item-bg-hover')}>
|
||||
<div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur text-sm font-normal', dragging && 'border border-divider-subtle bg-components-panel-on-panel-item-bg-hover')}>
|
||||
<div className="flex w-full items-center justify-center space-x-2">
|
||||
<CSVIcon className="shrink-0" />
|
||||
<div className="text-text-secondary">
|
||||
|
||||
@@ -58,7 +58,7 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({
|
||||
<Divider type="vertical" className="mx-1 h-3 bg-divider-regular" />
|
||||
<button
|
||||
type="button"
|
||||
className="system-xs-semibold text-text-accent"
|
||||
className="text-text-accent system-xs-semibold"
|
||||
onClick={() => {
|
||||
clearTimeout(refreshTimer.current)
|
||||
viewNewlyAddedChildChunk?.()
|
||||
@@ -120,11 +120,11 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({
|
||||
<div className="flex h-full flex-col">
|
||||
<div className={cn('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3')}>
|
||||
<div className="flex flex-col">
|
||||
<div className="system-xl-semibold text-text-primary">{t('segment.addChildChunk', { ns: 'datasetDocuments' })}</div>
|
||||
<div className="text-text-primary system-xl-semibold">{t('segment.addChildChunk', { ns: 'datasetDocuments' })}</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<SegmentIndexTag label={t('segment.newChildChunk', { ns: 'datasetDocuments' }) as string} />
|
||||
<Dot />
|
||||
<span className="system-xs-medium text-text-tertiary">{wordCountText}</span>
|
||||
<span className="text-text-tertiary system-xs-medium">{wordCountText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -61,7 +61,7 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
|
||||
<Divider type="vertical" className="mx-1 h-3 bg-divider-regular" />
|
||||
<button
|
||||
type="button"
|
||||
className="system-xs-semibold text-text-accent"
|
||||
className="text-text-accent system-xs-semibold"
|
||||
onClick={() => {
|
||||
clearTimeout(refreshTimer.current)
|
||||
viewNewlyAddedChunk()
|
||||
@@ -158,13 +158,13 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
|
||||
className={cn('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3')}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="system-xl-semibold text-text-primary">
|
||||
<div className="text-text-primary system-xl-semibold">
|
||||
{t('segment.addChunk', { ns: 'datasetDocuments' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<SegmentIndexTag label={t('segment.newChunk', { ns: 'datasetDocuments' })!} />
|
||||
<Dot />
|
||||
<span className="system-xs-medium text-text-tertiary">{wordCountText}</span>
|
||||
<span className="text-text-tertiary system-xs-medium">{wordCountText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -100,10 +100,10 @@ vi.mock('@/app/components/datasets/create/step-two', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting', () => ({
|
||||
default: ({ activeTab, onCancel }: { activeTab?: string, onCancel?: () => void }) => (
|
||||
default: ({ activeTab, onCancelAction }: { activeTab?: string, onCancelAction?: () => void }) => (
|
||||
<div data-testid="account-setting">
|
||||
<span data-testid="active-tab">{activeTab}</span>
|
||||
<button onClick={onCancel} data-testid="close-setting">Close</button>
|
||||
<button onClick={onCancelAction} data-testid="close-setting">Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
|
||||
import type { DataSourceProvider, NotionPage } from '@/models/common'
|
||||
import type {
|
||||
CrawlOptions,
|
||||
@@ -19,6 +20,7 @@ import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import StepTwo from '@/app/components/datasets/create/step-two'
|
||||
import AccountSetting from '@/app/components/header/account-setting'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import DatasetDetailContext from '@/context/dataset-detail'
|
||||
@@ -33,8 +35,13 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const [isShowSetAPIKey, { setTrue: showSetAPIKey, setFalse: hideSetAPIkey }] = useBoolean()
|
||||
const [accountSettingTab, setAccountSettingTab] = React.useState<AccountSettingTab>(ACCOUNT_SETTING_TAB.PROVIDER)
|
||||
const { indexingTechnique, dataset } = useContext(DatasetDetailContext)
|
||||
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
|
||||
const handleOpenAccountSetting = React.useCallback(() => {
|
||||
setAccountSettingTab(ACCOUNT_SETTING_TAB.PROVIDER)
|
||||
showSetAPIKey()
|
||||
}, [showSetAPIKey])
|
||||
|
||||
const invalidDocumentList = useInvalidDocumentList(datasetId)
|
||||
const invalidDocumentDetail = useInvalidDocumentDetail()
|
||||
@@ -135,7 +142,7 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
|
||||
{dataset && documentDetail && (
|
||||
<StepTwo
|
||||
isAPIKeySet={!!embeddingsDefaultModel}
|
||||
onSetting={showSetAPIKey}
|
||||
onSetting={handleOpenAccountSetting}
|
||||
datasetId={datasetId}
|
||||
dataSourceType={documentDetail.data_source_type as DataSourceType}
|
||||
notionPages={currentPage ? [currentPage as unknown as NotionPage] : []}
|
||||
@@ -155,8 +162,9 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
|
||||
</div>
|
||||
{isShowSetAPIKey && (
|
||||
<AccountSetting
|
||||
activeTab="provider"
|
||||
onCancel={async () => {
|
||||
activeTab={accountSettingTab}
|
||||
onTabChangeAction={setAccountSettingTab}
|
||||
onCancelAction={async () => {
|
||||
hideSetAPIkey()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -125,13 +125,13 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black/[.25]">
|
||||
<div className="shadows-shadow-xl relative flex w-[480px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg">
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6">
|
||||
<div className="title-2xl-semi-bold grow self-stretch text-text-primary">
|
||||
<div className="grow self-stretch text-text-primary title-2xl-semi-bold">
|
||||
{
|
||||
isEditMode ? t('editExternalAPIFormTitle', { ns: 'dataset' }) : t('createExternalAPI', { ns: 'dataset' })
|
||||
}
|
||||
</div>
|
||||
{isEditMode && (datasetBindings?.length ?? 0) > 0 && (
|
||||
<div className="system-xs-regular flex items-center text-text-tertiary">
|
||||
<div className="flex items-center text-text-tertiary system-xs-regular">
|
||||
{t('editExternalAPIFormWarning.front', { ns: 'dataset' })}
|
||||
<span className="flex cursor-pointer items-center text-text-accent">
|
||||
|
||||
@@ -144,12 +144,12 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
|
||||
popupContent={(
|
||||
<div className="p-1">
|
||||
<div className="flex items-start self-stretch pb-0.5 pl-2 pr-3 pt-1">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{`${datasetBindings?.length} ${t('editExternalAPITooltipTitle', { ns: 'dataset' })}`}</div>
|
||||
<div className="text-text-tertiary system-xs-medium-uppercase">{`${datasetBindings?.length} ${t('editExternalAPITooltipTitle', { ns: 'dataset' })}`}</div>
|
||||
</div>
|
||||
{datasetBindings?.map(binding => (
|
||||
<div key={binding.id} className="flex items-center gap-1 self-stretch px-2 py-1">
|
||||
<RiBook2Line className="h-4 w-4 text-text-secondary" />
|
||||
<div className="system-sm-medium text-text-secondary">{binding.name}</div>
|
||||
<div className="text-text-secondary system-sm-medium">{binding.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -193,8 +193,8 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
|
||||
{t('externalAPIForm.save', { ns: 'dataset' })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="system-xs-regular flex items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px]
|
||||
border-divider-subtle bg-background-soft px-2 py-3 text-text-tertiary"
|
||||
<div className="flex items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px] border-divider-subtle
|
||||
bg-background-soft px-2 py-3 text-text-tertiary system-xs-regular"
|
||||
>
|
||||
<RiLock2Fill className="h-3 w-3 text-text-quaternary" />
|
||||
{t('externalAPIForm.encrypted.front', { ns: 'dataset' })}
|
||||
|
||||
@@ -103,6 +103,18 @@ describe('InputCombined', () => {
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset cleared number input to 0', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should display current value for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Datepicker from '../base/date-picker'
|
||||
import { DataType } from '../types'
|
||||
@@ -36,15 +43,23 @@ const InputCombined: FC<Props> = ({
|
||||
if (type === DataType.number) {
|
||||
return (
|
||||
<div className="grow text-[0]">
|
||||
<InputNumber
|
||||
className={cn(className, 'rounded-l-md')}
|
||||
<NumberField
|
||||
className="min-w-0"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
size="regular"
|
||||
controlWrapClassName="overflow-hidden"
|
||||
controlClassName="pt-0 pb-0"
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
onValueChange={value => onChange(value ?? 0)}
|
||||
>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput
|
||||
size="regular"
|
||||
className={cn(className, 'rounded-l-md')}
|
||||
/>
|
||||
<NumberFieldControls className="overflow-hidden">
|
||||
<NumberFieldIncrement size="regular" className="pb-0 pt-0" />
|
||||
<NumberFieldDecrement size="regular" className="pb-0 pt-0" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -127,6 +127,14 @@ vi.mock('@/service/use-common', () => ({
|
||||
],
|
||||
},
|
||||
}),
|
||||
useCurrentWorkspace: () => ({
|
||||
data: {
|
||||
trial_credits: 1000,
|
||||
trial_credits_used: 100,
|
||||
next_credit_reset_date: undefined,
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
@@ -191,6 +199,42 @@ vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('../components/indexing-section', () => ({
|
||||
default: ({
|
||||
currentDataset,
|
||||
indexMethod,
|
||||
}: {
|
||||
currentDataset?: DataSet
|
||||
indexMethod?: IndexingType
|
||||
}) => (
|
||||
<div data-testid="indexing-section">
|
||||
{!!currentDataset?.doc_form && (
|
||||
<>
|
||||
<div>form.chunkStructure.title</div>
|
||||
<a href="https://docs.dify.ai/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text">
|
||||
form.chunkStructure.learnMore
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{!!(currentDataset
|
||||
&& currentDataset.doc_form !== ChunkingMode.parentChild
|
||||
&& currentDataset.indexing_technique
|
||||
&& indexMethod) && (
|
||||
<div>form.indexMethod</div>
|
||||
)}
|
||||
{indexMethod === IndexingType.QUALIFIED && <div>form.embeddingModel</div>}
|
||||
{currentDataset?.provider !== 'external' && indexMethod && (
|
||||
<>
|
||||
<div>form.retrievalSetting.title</div>
|
||||
<a href="https://docs.dify.ai/use-dify/knowledge/create-knowledge/setting-indexing-methods">
|
||||
form.retrievalSetting.learnMore
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Form', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -8,75 +8,149 @@ import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { IndexingType } from '../../../../create/step-two'
|
||||
import IndexingSection from '../indexing-section'
|
||||
|
||||
// Mock i18n doc link
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
// Mock app-context for child components
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown) => {
|
||||
const state = {
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
userProfile: {
|
||||
id: 'user-1',
|
||||
name: 'Current User',
|
||||
email: 'current@example.com',
|
||||
avatar_url: '',
|
||||
role: 'owner',
|
||||
},
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
vi.mock('@/app/components/base/divider', () => ({
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<div data-testid="divider" className={className} />
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock model-provider-page hooks
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: () => ({ data: [], mutate: vi.fn(), isLoading: false }),
|
||||
useCurrentProviderAndModel: () => ({ currentProvider: undefined, currentModel: undefined }),
|
||||
useDefaultModel: () => ({ data: undefined, mutate: vi.fn(), isLoading: false }),
|
||||
useModelListAndDefaultModel: () => ({ modelList: [], defaultModel: undefined }),
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
|
||||
modelList: [],
|
||||
defaultModel: undefined,
|
||||
currentProvider: undefined,
|
||||
currentModel: undefined,
|
||||
}),
|
||||
useUpdateModelList: () => vi.fn(),
|
||||
useUpdateModelProviders: () => vi.fn(),
|
||||
useLanguage: () => 'en_US',
|
||||
useSystemDefaultModelAndModelList: () => [undefined, vi.fn()],
|
||||
useProviderCredentialsAndLoadBalancing: () => ({
|
||||
credentials: undefined,
|
||||
loadBalancing: undefined,
|
||||
mutate: vi.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
useAnthropicBuyQuota: () => vi.fn(),
|
||||
useMarketplaceAllPlugins: () => ({ plugins: [], isLoading: false }),
|
||||
useRefreshModel: () => ({ handleRefreshModel: vi.fn() }),
|
||||
useModelModalHandler: () => vi.fn(),
|
||||
vi.mock('@/app/components/datasets/settings/chunk-structure', () => ({
|
||||
default: ({ chunkStructure }: { chunkStructure: string }) => (
|
||||
<div data-testid="chunk-structure" data-mode={chunkStructure}>
|
||||
{chunkStructure}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock provider-context
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
textGenerationModelList: [],
|
||||
embeddingsModelList: [],
|
||||
rerankModelList: [],
|
||||
agentThoughtModelList: [],
|
||||
modelProviders: [],
|
||||
textEmbeddingModelList: [],
|
||||
speech2textModelList: [],
|
||||
ttsModelList: [],
|
||||
moderationModelList: [],
|
||||
hasSettedApiKey: true,
|
||||
plan: { type: 'free' },
|
||||
enableBilling: false,
|
||||
onPlanInfoChanged: vi.fn(),
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
supportRetrievalMethods: ['semantic_search', 'full_text_search', 'hybrid_search'],
|
||||
}),
|
||||
vi.mock('@/app/components/datasets/settings/index-method', () => ({
|
||||
default: ({
|
||||
value,
|
||||
disabled,
|
||||
keywordNumber,
|
||||
onChange,
|
||||
onKeywordNumberChange,
|
||||
}: {
|
||||
value: string
|
||||
disabled?: boolean
|
||||
keywordNumber: number
|
||||
onChange: (value: IndexingType) => void
|
||||
onKeywordNumberChange: (value: number) => void
|
||||
}) => (
|
||||
<div
|
||||
data-testid="index-method"
|
||||
data-disabled={disabled ? 'true' : 'false'}
|
||||
data-keyword-number={String(keywordNumber)}
|
||||
data-value={value}
|
||||
>
|
||||
<button type="button" onClick={() => onChange(IndexingType.QUALIFIED)}>
|
||||
stepTwo.qualified
|
||||
</button>
|
||||
<button type="button" onClick={() => onChange(IndexingType.ECONOMICAL)}>
|
||||
form.indexMethodEconomy
|
||||
</button>
|
||||
<button type="button" onClick={() => onKeywordNumberChange(keywordNumber + 1)}>
|
||||
keyword-number-increment
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({
|
||||
defaultModel,
|
||||
onSelect,
|
||||
}: {
|
||||
defaultModel?: DefaultModel
|
||||
onSelect?: (value: DefaultModel) => void
|
||||
}) => (
|
||||
<div
|
||||
data-testid="model-selector"
|
||||
data-model={defaultModel?.model ?? ''}
|
||||
data-provider={defaultModel?.provider ?? ''}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect?.({ provider: 'cohere', model: 'embed-english-v3.0' })}
|
||||
>
|
||||
select-model
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
|
||||
default: ({
|
||||
summaryIndexSetting,
|
||||
onSummaryIndexSettingChange,
|
||||
}: {
|
||||
summaryIndexSetting?: SummaryIndexSetting
|
||||
onSummaryIndexSettingChange?: (payload: SummaryIndexSetting) => void
|
||||
}) => (
|
||||
<div data-testid="summary-index-setting" data-enabled={summaryIndexSetting?.enable ? 'true' : 'false'}>
|
||||
<button type="button" onClick={() => onSummaryIndexSettingChange?.({ enable: true })}>
|
||||
summary-enable
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({
|
||||
default: ({
|
||||
showMultiModalTip,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
showMultiModalTip?: boolean
|
||||
onChange: (value: RetrievalConfig) => void
|
||||
value: RetrievalConfig
|
||||
}) => (
|
||||
<div data-testid="retrieval-method-config">
|
||||
{showMultiModalTip && <span>show-multimodal-tip</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
top_k: 6,
|
||||
})}
|
||||
>
|
||||
update-retrieval
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({
|
||||
default: ({
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
onChange: (value: RetrievalConfig) => void
|
||||
value: RetrievalConfig
|
||||
}) => (
|
||||
<div data-testid="economical-retrieval-method-config">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
search_method: RETRIEVE_METHOD.keywordSearch,
|
||||
})}
|
||||
>
|
||||
update-economy-retrieval
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('IndexingSection', () => {
|
||||
@@ -187,315 +261,281 @@ describe('IndexingSection', () => {
|
||||
showMultiModalTip: false,
|
||||
}
|
||||
|
||||
const renderComponent = (props: Partial<typeof defaultProps> = {}) => {
|
||||
return render(<IndexingSection {...defaultProps} {...props} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<IndexingSection {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
|
||||
it('should render the chunk structure, index method, and retrieval sections for a standard dataset', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('form.chunkStructure.title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('chunk-structure')).toHaveAttribute('data-mode', ChunkingMode.text)
|
||||
expect(screen.getByText('form.indexMethod')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('index-method')).toBeInTheDocument()
|
||||
expect(screen.getByText('form.retrievalSetting.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chunk structure section when doc_form is set', () => {
|
||||
render(<IndexingSection {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
it('should render the embedding model selector when the index method is high quality', () => {
|
||||
renderComponent()
|
||||
|
||||
it('should render index method section when conditions are met', () => {
|
||||
render(<IndexingSection {...defaultProps} />)
|
||||
// May match multiple elements (label and descriptions)
|
||||
expect(screen.getAllByText(/form\.indexMethod/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render embedding model section when indexMethod is high_quality', () => {
|
||||
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.QUALIFIED} />)
|
||||
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render retrieval settings section', () => {
|
||||
render(<IndexingSection {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('form.embeddingModel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'text-embedding-ada-002')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chunk Structure Section', () => {
|
||||
it('should not render chunk structure when doc_form is not set', () => {
|
||||
const datasetWithoutDocForm = { ...mockDataset, doc_form: undefined as unknown as ChunkingMode }
|
||||
render(<IndexingSection {...defaultProps} currentDataset={datasetWithoutDocForm} />)
|
||||
it('should hide the chunk structure section when the dataset has no doc form', () => {
|
||||
renderComponent({
|
||||
currentDataset: {
|
||||
...mockDataset,
|
||||
doc_form: undefined as unknown as ChunkingMode,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.queryByText(/form\.chunkStructure\.title/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('form.chunkStructure.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('chunk-structure')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render learn more link for chunk structure', () => {
|
||||
render(<IndexingSection {...defaultProps} />)
|
||||
it('should render the chunk structure learn more link and description', () => {
|
||||
renderComponent()
|
||||
|
||||
const learnMoreLink = screen.getByText(/form\.chunkStructure\.learnMore/i)
|
||||
expect(learnMoreLink).toBeInTheDocument()
|
||||
const learnMoreLink = screen.getByRole('link', { name: 'form.chunkStructure.learnMore' })
|
||||
expect(learnMoreLink).toHaveAttribute('href', expect.stringContaining('chunking-and-cleaning-text'))
|
||||
})
|
||||
|
||||
it('should render chunk structure description', () => {
|
||||
render(<IndexingSection {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/form\.chunkStructure\.description/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('form.chunkStructure.description')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Index Method Section', () => {
|
||||
it('should not render index method for parentChild chunking mode', () => {
|
||||
const parentChildDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild }
|
||||
render(<IndexingSection {...defaultProps} currentDataset={parentChildDataset} />)
|
||||
it('should hide the index method section for parent-child chunking', () => {
|
||||
renderComponent({
|
||||
currentDataset: {
|
||||
...mockDataset,
|
||||
doc_form: ChunkingMode.parentChild,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.queryByText(/form\.indexMethod/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('form.indexMethod')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('index-method')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render high quality option', () => {
|
||||
render(<IndexingSection {...defaultProps} />)
|
||||
it('should render both index method options', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'stepTwo.qualified' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'form.indexMethodEconomy' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render economy option', () => {
|
||||
render(<IndexingSection {...defaultProps} />)
|
||||
|
||||
// May match multiple elements (title and tip)
|
||||
expect(screen.getAllByText(/form\.indexMethodEconomy/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should call setIndexMethod when index method changes', () => {
|
||||
it('should call setIndexMethod when the user selects a new index method', () => {
|
||||
const setIndexMethod = vi.fn()
|
||||
const { container } = render(<IndexingSection {...defaultProps} setIndexMethod={setIndexMethod} />)
|
||||
renderComponent({ setIndexMethod })
|
||||
|
||||
// Find the economy option card by looking for clickable elements containing the economy text
|
||||
const economyOptions = screen.getAllByText(/form\.indexMethodEconomy/i)
|
||||
if (economyOptions.length > 0) {
|
||||
const economyCard = economyOptions[0].closest('[class*="cursor-pointer"]')
|
||||
if (economyCard) {
|
||||
fireEvent.click(economyCard)
|
||||
}
|
||||
}
|
||||
fireEvent.click(screen.getByRole('button', { name: 'form.indexMethodEconomy' }))
|
||||
|
||||
// The handler should be properly passed - verify component renders without crashing
|
||||
expect(container).toBeInTheDocument()
|
||||
expect(setIndexMethod).toHaveBeenCalledWith(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
it('should show upgrade warning when switching from economy to high quality', () => {
|
||||
const economyDataset = { ...mockDataset, indexing_technique: IndexingType.ECONOMICAL }
|
||||
render(
|
||||
<IndexingSection
|
||||
{...defaultProps}
|
||||
currentDataset={economyDataset}
|
||||
indexMethod={IndexingType.QUALIFIED}
|
||||
/>,
|
||||
)
|
||||
it('should show an upgrade warning when moving from economy to high quality', () => {
|
||||
renderComponent({
|
||||
currentDataset: {
|
||||
...mockDataset,
|
||||
indexing_technique: IndexingType.ECONOMICAL,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/form\.upgradeHighQualityTip/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('form.upgradeHighQualityTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show upgrade warning when already on high quality', () => {
|
||||
render(
|
||||
<IndexingSection
|
||||
{...defaultProps}
|
||||
indexMethod={IndexingType.QUALIFIED}
|
||||
/>,
|
||||
)
|
||||
it('should pass disabled state to the index method when embeddings are unavailable', () => {
|
||||
renderComponent({
|
||||
currentDataset: {
|
||||
...mockDataset,
|
||||
embedding_available: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.queryByText(/form\.upgradeHighQualityTip/i)).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('index-method')).toHaveAttribute('data-disabled', 'true')
|
||||
})
|
||||
|
||||
it('should disable index method when embedding is not available', () => {
|
||||
const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false }
|
||||
render(<IndexingSection {...defaultProps} currentDataset={datasetWithoutEmbedding} />)
|
||||
it('should pass the keyword number and update handler through the index method mock', () => {
|
||||
const setKeywordNumber = vi.fn()
|
||||
renderComponent({
|
||||
keywordNumber: 15,
|
||||
setKeywordNumber,
|
||||
})
|
||||
|
||||
// Index method options should be disabled
|
||||
// The exact implementation depends on the IndexMethod component
|
||||
expect(screen.getByTestId('index-method')).toHaveAttribute('data-keyword-number', '15')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'keyword-number-increment' }))
|
||||
|
||||
expect(setKeywordNumber).toHaveBeenCalledWith(16)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Embedding Model Section', () => {
|
||||
it('should render embedding model when indexMethod is high_quality', () => {
|
||||
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.QUALIFIED} />)
|
||||
it('should hide the embedding model selector for economy indexing', () => {
|
||||
renderComponent({ indexMethod: IndexingType.ECONOMICAL })
|
||||
|
||||
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('form.embeddingModel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render embedding model when indexMethod is economy', () => {
|
||||
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.ECONOMICAL} />)
|
||||
|
||||
expect(screen.queryByText(/form\.embeddingModel/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setEmbeddingModel when model changes', () => {
|
||||
it('should call setEmbeddingModel when the user selects a model', () => {
|
||||
const setEmbeddingModel = vi.fn()
|
||||
render(
|
||||
<IndexingSection
|
||||
{...defaultProps}
|
||||
setEmbeddingModel={setEmbeddingModel}
|
||||
indexMethod={IndexingType.QUALIFIED}
|
||||
/>,
|
||||
)
|
||||
renderComponent({ setEmbeddingModel })
|
||||
|
||||
// The embedding model selector should be rendered
|
||||
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'select-model' }))
|
||||
|
||||
expect(setEmbeddingModel).toHaveBeenCalledWith({
|
||||
provider: 'cohere',
|
||||
model: 'embed-english-v3.0',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Summary Index Setting Section', () => {
|
||||
it('should render summary index setting for high quality with text chunking', () => {
|
||||
render(
|
||||
<IndexingSection
|
||||
{...defaultProps}
|
||||
indexMethod={IndexingType.QUALIFIED}
|
||||
/>,
|
||||
)
|
||||
it('should render the summary index setting only for high quality text chunking', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument()
|
||||
|
||||
// Summary index setting should be rendered based on conditions
|
||||
// The exact rendering depends on the SummaryIndexSetting component
|
||||
renderComponent({
|
||||
indexMethod: IndexingType.ECONOMICAL,
|
||||
})
|
||||
expect(screen.getAllByTestId('summary-index-setting')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should not render summary index setting for economy indexing', () => {
|
||||
render(
|
||||
<IndexingSection
|
||||
{...defaultProps}
|
||||
indexMethod={IndexingType.ECONOMICAL}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Summary index setting should not be rendered for economy
|
||||
})
|
||||
|
||||
it('should call handleSummaryIndexSettingChange when setting changes', () => {
|
||||
it('should call handleSummaryIndexSettingChange when the summary setting changes', () => {
|
||||
const handleSummaryIndexSettingChange = vi.fn()
|
||||
render(
|
||||
<IndexingSection
|
||||
{...defaultProps}
|
||||
handleSummaryIndexSettingChange={handleSummaryIndexSettingChange}
|
||||
indexMethod={IndexingType.QUALIFIED}
|
||||
/>,
|
||||
)
|
||||
renderComponent({ handleSummaryIndexSettingChange })
|
||||
|
||||
// The handler should be properly passed
|
||||
fireEvent.click(screen.getByRole('button', { name: 'summary-enable' }))
|
||||
|
||||
expect(handleSummaryIndexSettingChange).toHaveBeenCalledWith({ enable: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Retrieval Settings Section', () => {
|
||||
it('should render retrieval settings', () => {
|
||||
render(<IndexingSection {...defaultProps} />)
|
||||
it('should render the retrieval learn more link', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
|
||||
const learnMoreLink = screen.getByRole('link', { name: 'form.retrievalSetting.learnMore' })
|
||||
expect(learnMoreLink).toHaveAttribute('href', expect.stringContaining('setting-indexing-methods'))
|
||||
expect(screen.getByText('form.retrievalSetting.description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render learn more link for retrieval settings', () => {
|
||||
render(<IndexingSection {...defaultProps} />)
|
||||
|
||||
const learnMoreLinks = screen.getAllByText(/learnMore/i)
|
||||
const retrievalLearnMore = learnMoreLinks.find(link =>
|
||||
link.closest('a')?.href?.includes('setting-indexing-methods'),
|
||||
)
|
||||
expect(retrievalLearnMore).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render RetrievalMethodConfig for high quality indexing', () => {
|
||||
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.QUALIFIED} />)
|
||||
|
||||
// RetrievalMethodConfig should be rendered
|
||||
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render EconomicalRetrievalMethodConfig for economy indexing', () => {
|
||||
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.ECONOMICAL} />)
|
||||
|
||||
// EconomicalRetrievalMethodConfig should be rendered
|
||||
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setRetrievalConfig when config changes', () => {
|
||||
it('should render the high-quality retrieval config and propagate changes', () => {
|
||||
const setRetrievalConfig = vi.fn()
|
||||
render(<IndexingSection {...defaultProps} setRetrievalConfig={setRetrievalConfig} />)
|
||||
renderComponent({ setRetrievalConfig })
|
||||
|
||||
// The handler should be properly passed
|
||||
expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'update-retrieval' }))
|
||||
|
||||
expect(setRetrievalConfig).toHaveBeenCalledWith({
|
||||
...mockRetrievalConfig,
|
||||
top_k: 6,
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass showMultiModalTip to RetrievalMethodConfig', () => {
|
||||
render(<IndexingSection {...defaultProps} showMultiModalTip={true} />)
|
||||
it('should render the economical retrieval config for economy indexing', () => {
|
||||
const setRetrievalConfig = vi.fn()
|
||||
renderComponent({
|
||||
indexMethod: IndexingType.ECONOMICAL,
|
||||
setRetrievalConfig,
|
||||
})
|
||||
|
||||
// The tip should be passed to the config component
|
||||
expect(screen.getByTestId('economical-retrieval-method-config')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'update-economy-retrieval' }))
|
||||
|
||||
expect(setRetrievalConfig).toHaveBeenCalledWith({
|
||||
...mockRetrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.keywordSearch,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('External Provider', () => {
|
||||
it('should not render retrieval config for external provider', () => {
|
||||
const externalDataset = { ...mockDataset, provider: 'external' }
|
||||
render(<IndexingSection {...defaultProps} currentDataset={externalDataset} />)
|
||||
it('should pass the multimodal tip flag to the retrieval config', () => {
|
||||
renderComponent({ showMultiModalTip: true })
|
||||
|
||||
// Retrieval config should not be rendered for external provider
|
||||
// This is handled by the parent component, but we verify the condition
|
||||
expect(screen.getByText('show-multimodal-tip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide retrieval configuration for external datasets', () => {
|
||||
renderComponent({
|
||||
currentDataset: {
|
||||
...mockDataset,
|
||||
provider: 'external',
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.queryByText('form.retrievalSetting.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('retrieval-method-config')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('economical-retrieval-method-config')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Conditional Rendering', () => {
|
||||
it('should show divider between sections', () => {
|
||||
const { container } = render(<IndexingSection {...defaultProps} />)
|
||||
it('should render dividers between visible sections', () => {
|
||||
renderComponent()
|
||||
|
||||
// Dividers should be present
|
||||
const dividers = container.querySelectorAll('.bg-divider-subtle')
|
||||
expect(dividers.length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByTestId('divider').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not render index method when indexing_technique is not set', () => {
|
||||
const datasetWithoutTechnique = { ...mockDataset, indexing_technique: undefined as unknown as IndexingType }
|
||||
render(<IndexingSection {...defaultProps} currentDataset={datasetWithoutTechnique} indexMethod={undefined} />)
|
||||
it('should hide the index method section when the dataset lacks an indexing technique', () => {
|
||||
renderComponent({
|
||||
currentDataset: {
|
||||
...mockDataset,
|
||||
indexing_technique: undefined as unknown as IndexingType,
|
||||
},
|
||||
indexMethod: undefined,
|
||||
})
|
||||
|
||||
expect(screen.queryByText(/form\.indexMethod/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyword Number', () => {
|
||||
it('should pass keywordNumber to IndexMethod', () => {
|
||||
render(<IndexingSection {...defaultProps} keywordNumber={15} />)
|
||||
|
||||
// The keyword number should be displayed in the economy option description
|
||||
// The exact rendering depends on the IndexMethod component
|
||||
})
|
||||
|
||||
it('should call setKeywordNumber when keyword number changes', () => {
|
||||
const setKeywordNumber = vi.fn()
|
||||
render(<IndexingSection {...defaultProps} setKeywordNumber={setKeywordNumber} />)
|
||||
|
||||
// The handler should be properly passed
|
||||
expect(screen.queryByText('form.indexMethod')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('index-method')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Updates', () => {
|
||||
it('should update when indexMethod changes', () => {
|
||||
const { rerender } = render(<IndexingSection {...defaultProps} indexMethod={IndexingType.QUALIFIED} />)
|
||||
it('should update the embedding model section when indexMethod changes', () => {
|
||||
const { rerender } = renderComponent()
|
||||
|
||||
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
|
||||
rerender(<IndexingSection {...defaultProps} indexMethod={IndexingType.ECONOMICAL} />)
|
||||
|
||||
expect(screen.queryByText(/form\.embeddingModel/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update when currentDataset changes', () => {
|
||||
const { rerender } = render(<IndexingSection {...defaultProps} />)
|
||||
it('should update the chunk structure section when currentDataset changes', () => {
|
||||
const { rerender } = renderComponent()
|
||||
|
||||
expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('chunk-structure')).toBeInTheDocument()
|
||||
|
||||
const datasetWithoutDocForm = { ...mockDataset, doc_form: undefined as unknown as ChunkingMode }
|
||||
rerender(<IndexingSection {...defaultProps} currentDataset={datasetWithoutDocForm} />)
|
||||
rerender(
|
||||
<IndexingSection
|
||||
{...defaultProps}
|
||||
currentDataset={{
|
||||
...mockDataset,
|
||||
doc_form: undefined as unknown as ChunkingMode,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/form\.chunkStructure\.title/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('chunk-structure')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Undefined Dataset', () => {
|
||||
it('should handle undefined currentDataset gracefully', () => {
|
||||
render(<IndexingSection {...defaultProps} currentDataset={undefined} />)
|
||||
it('should render safely when currentDataset is undefined', () => {
|
||||
renderComponent({ currentDataset: undefined })
|
||||
|
||||
// Should not crash and should handle undefined gracefully
|
||||
// Most sections should not render without a dataset
|
||||
expect(screen.queryByTestId('chunk-structure')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -190,7 +190,7 @@ describe('IndexMethod', () => {
|
||||
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle keywordNumber of 0', () => {
|
||||
it('should handle minimum keywordNumber', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('0')
|
||||
|
||||
@@ -24,9 +24,8 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
it('should render tooltip with question icon', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
// RiQuestionLine renders as an svg
|
||||
const container = screen.getByText(/form\.numberOfKeywords/).closest('div')?.parentElement
|
||||
const questionIcon = container?.querySelector('svg')
|
||||
const questionIcon = container?.querySelector('.i-ri-question-line')
|
||||
expect(questionIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -88,15 +87,22 @@ describe('KeyWordNumber', () => {
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onKeywordNumberChange with undefined value', () => {
|
||||
it('should reset to 0 when users clear the input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
// When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
expect(handleChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should clamp out-of-range edits before updating state', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '60' } })
|
||||
expect(handleChange).toHaveBeenLastCalledWith(50)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
|
||||
const MIN_KEYWORD_NUMBER = 0
|
||||
const MAX_KEYWORD_NUMBER = 50
|
||||
|
||||
type KeyWordNumberProps = {
|
||||
keywordNumber: number
|
||||
@@ -17,35 +26,44 @@ const KeyWordNumber = ({
|
||||
}: KeyWordNumberProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleInputChange = useCallback((value: number | undefined) => {
|
||||
if (value)
|
||||
onKeywordNumberChange(value)
|
||||
const handleInputChange = useCallback((value: number | null) => {
|
||||
onKeywordNumberChange(value ?? MIN_KEYWORD_NUMBER)
|
||||
}, [onKeywordNumberChange])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="flex grow items-center gap-x-0.5">
|
||||
<div className="system-xs-medium truncate text-text-secondary">
|
||||
<div className="truncate text-text-secondary system-xs-medium">
|
||||
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Tooltip
|
||||
popupContent="number of keywords"
|
||||
popupContent={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
|
||||
>
|
||||
<RiQuestionLine className="h-3.5 w-3.5 text-text-quaternary" />
|
||||
<span className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Slider
|
||||
className="mr-3 w-[206px] shrink-0"
|
||||
value={keywordNumber}
|
||||
max={50}
|
||||
min={MIN_KEYWORD_NUMBER}
|
||||
max={MAX_KEYWORD_NUMBER}
|
||||
onChange={onKeywordNumberChange}
|
||||
/>
|
||||
<InputNumber
|
||||
wrapperClassName="shrink-0 w-12"
|
||||
type="number"
|
||||
<NumberField
|
||||
className="w-12 shrink-0"
|
||||
min={MIN_KEYWORD_NUMBER}
|
||||
max={MAX_KEYWORD_NUMBER}
|
||||
value={keywordNumber}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
onValueChange={handleInputChange}
|
||||
>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput size="regular" />
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement size="regular" />
|
||||
<NumberFieldDecrement size="regular" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ const SummaryIndexSetting = ({
|
||||
return (
|
||||
<div>
|
||||
<div className="flex h-6 items-center justify-between">
|
||||
<div className="system-sm-semibold-uppercase flex items-center text-text-secondary">
|
||||
<div className="flex items-center text-text-secondary system-sm-semibold-uppercase">
|
||||
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
|
||||
<Tooltip
|
||||
triggerClassName="ml-1 h-4 w-4 shrink-0"
|
||||
@@ -80,7 +80,7 @@ const SummaryIndexSetting = ({
|
||||
{
|
||||
summaryIndexSetting?.enable && (
|
||||
<div>
|
||||
<div className="system-xs-medium-uppercase mb-1.5 mt-2 flex h-6 items-center text-text-tertiary">
|
||||
<div className="mb-1.5 mt-2 flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
|
||||
{t('form.summaryModel', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<ModelSelector
|
||||
@@ -90,7 +90,7 @@ const SummaryIndexSetting = ({
|
||||
readonly={readonly}
|
||||
showDeprecatedWarnIcon
|
||||
/>
|
||||
<div className="system-xs-medium-uppercase mt-3 flex h-6 items-center text-text-tertiary">
|
||||
<div className="mt-3 flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
|
||||
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Textarea
|
||||
@@ -111,12 +111,12 @@ const SummaryIndexSetting = ({
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-x-1">
|
||||
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
<div className="text-text-secondary system-sm-semibold">
|
||||
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1.5">
|
||||
<div className="system-sm-semibold flex items-center text-text-secondary">
|
||||
<div className="flex items-center text-text-secondary system-sm-semibold">
|
||||
<Switch
|
||||
className="mr-2"
|
||||
value={summaryIndexSetting?.enable ?? false}
|
||||
@@ -127,7 +127,7 @@ const SummaryIndexSetting = ({
|
||||
summaryIndexSetting?.enable ? t('list.status.enabled', { ns: 'datasetDocuments' }) : t('list.status.disabled', { ns: 'datasetDocuments' })
|
||||
}
|
||||
</div>
|
||||
<div className="system-sm-regular mt-2 text-text-tertiary">
|
||||
<div className="mt-2 text-text-tertiary system-sm-regular">
|
||||
{
|
||||
summaryIndexSetting?.enable && t('form.summaryAutoGenTip', { ns: 'datasetSettings' })
|
||||
}
|
||||
@@ -142,7 +142,7 @@ const SummaryIndexSetting = ({
|
||||
<>
|
||||
<div className="flex gap-x-1">
|
||||
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
|
||||
<div className="system-sm-medium text-text-tertiary">
|
||||
<div className="text-text-tertiary system-sm-medium">
|
||||
{t('form.summaryModel', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +159,7 @@ const SummaryIndexSetting = ({
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
|
||||
<div className="system-sm-medium text-text-tertiary">
|
||||
<div className="text-text-tertiary system-sm-medium">
|
||||
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +188,7 @@ const SummaryIndexSetting = ({
|
||||
onChange={handleSummaryIndexEnableChange}
|
||||
size="md"
|
||||
/>
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
<div className="text-text-secondary system-sm-semibold">
|
||||
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,7 +196,7 @@ const SummaryIndexSetting = ({
|
||||
summaryIndexSetting?.enable && (
|
||||
<>
|
||||
<div>
|
||||
<div className="system-sm-medium mb-1.5 flex h-6 items-center text-text-secondary">
|
||||
<div className="mb-1.5 flex h-6 items-center text-text-secondary system-sm-medium">
|
||||
{t('form.summaryModel', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<ModelSelector
|
||||
@@ -209,7 +209,7 @@ const SummaryIndexSetting = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="system-sm-medium mb-1.5 flex h-6 items-center text-text-secondary">
|
||||
<div className="mb-1.5 flex h-6 items-center text-text-secondary system-sm-medium">
|
||||
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Textarea
|
||||
|
||||
@@ -46,7 +46,7 @@ const WorkplaceSelector = () => {
|
||||
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle font-semibold uppercase leading-6 text-shadow-shadow-1 opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center">
|
||||
<div className="system-sm-medium min-w-0 max-w-[149px] truncate text-text-secondary max-[800px]:hidden">{currentWorkspace?.name}</div>
|
||||
<div className="min-w-0 max-w-[149px] truncate text-text-secondary system-sm-medium max-[800px]:hidden">{currentWorkspace?.name}</div>
|
||||
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-text-secondary" />
|
||||
</div>
|
||||
</MenuButton>
|
||||
@@ -68,9 +68,9 @@ const WorkplaceSelector = () => {
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full flex-col items-start self-stretch rounded-xl border-[0.5px] border-components-panel-border p-1 pb-2 shadow-lg ">
|
||||
<div className="flex w-full flex-col items-start self-stretch rounded-xl border-[0.5px] border-components-panel-border p-1 pb-2 shadow-lg">
|
||||
<div className="flex items-start self-stretch px-3 pb-0.5 pt-1">
|
||||
<span className="system-xs-medium-uppercase flex-1 text-text-tertiary">{t('userProfile.workspace', { ns: 'common' })}</span>
|
||||
<span className="flex-1 text-text-tertiary system-xs-medium-uppercase">{t('userProfile.workspace', { ns: 'common' })}</span>
|
||||
</div>
|
||||
{
|
||||
workspaces.map(workspace => (
|
||||
@@ -78,7 +78,7 @@ const WorkplaceSelector = () => {
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
|
||||
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle font-semibold uppercase leading-6 text-shadow-shadow-1 opacity-90">{workspace?.name[0]?.toLocaleUpperCase()}</span>
|
||||
</div>
|
||||
<div className="system-md-regular line-clamp-1 grow cursor-pointer overflow-hidden text-ellipsis text-text-secondary">{workspace.name}</div>
|
||||
<div className="line-clamp-1 grow cursor-pointer overflow-hidden text-ellipsis text-text-secondary system-md-regular">{workspace.name}</div>
|
||||
<PlanBadge plan={workspace.plan as Plan} />
|
||||
</div>
|
||||
))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user