mirror of
https://github.com/langgenius/dify.git
synced 2026-05-21 09:17:27 +08:00
Compare commits
1 Commits
codex/inpu
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
| 48437f9aa0 |
2
.github/workflows/web-tests.yml
vendored
2
.github/workflows/web-tests.yml
vendored
@ -39,7 +39,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Run tests
|
||||
run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
||||
run: vp test run --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
||||
|
||||
- name: Upload blob report
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
@ -767,7 +767,6 @@ EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
|
||||
# Whether to use Redis cluster mode while use redis as event bus.
|
||||
# It's highly recommended to enable this for large deployments.
|
||||
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
||||
EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
|
||||
|
||||
# Whether to Enable human input timeout check task
|
||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||
|
||||
@ -2,7 +2,6 @@ from typing import Literal, Protocol, cast
|
||||
from urllib.parse import quote_plus, urlunparse
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic.types import NonNegativeInt
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
@ -71,24 +70,6 @@ class RedisPubSubConfig(BaseSettings):
|
||||
default=600,
|
||||
)
|
||||
|
||||
PUBSUB_LISTENER_JOIN_TIMEOUT_MS: NonNegativeInt = Field(
|
||||
validation_alias=AliasChoices("EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS", "PUBSUB_LISTENER_JOIN_TIMEOUT_MS"),
|
||||
description=(
|
||||
"Maximum time (milliseconds) that ``Subscription.close()`` waits for its listener thread to "
|
||||
"finish before returning. Bounds the tail latency between a terminal event being delivered to "
|
||||
"an SSE client and the response stream actually closing.\n\n"
|
||||
"The listener thread blocks on a polling read (XREAD BLOCK for streams, get_message timeout "
|
||||
"for pubsub/sharded) with a fixed 1s window, so close() naturally has to wait up to ~1s for "
|
||||
"the thread to notice the subscription was closed. Setting this lower (e.g. 100) lets close() "
|
||||
"return promptly while the daemon listener thread cleans itself up on the next poll "
|
||||
"boundary - safe because the listener holds no critical state and exits within one poll "
|
||||
"window. Setting it higher (e.g. 5000) gives the listener more grace before close() gives up "
|
||||
"and logs a warning. Default 2000ms preserves the pre-change behaviour.\n\n"
|
||||
"Also accepts ENV: EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS."
|
||||
),
|
||||
default=2000,
|
||||
)
|
||||
|
||||
def _build_default_pubsub_url(self) -> str:
|
||||
defaults = _redis_defaults(self)
|
||||
if not defaults.REDIS_HOST or not defaults.REDIS_PORT:
|
||||
|
||||
@ -177,9 +177,14 @@ class DatasetListApi(DatasetApiResource):
|
||||
|
||||
data = marshal(datasets, dataset_detail_fields)
|
||||
for item in data:
|
||||
if item["indexing_technique"] == IndexTechniqueType.HIGH_QUALITY and item["embedding_model_provider"]:
|
||||
item["embedding_model_provider"] = str(ModelProviderID(item["embedding_model_provider"]))
|
||||
item_model = f"{item['embedding_model']}:{item['embedding_model_provider']}"
|
||||
if (
|
||||
item["indexing_technique"] == IndexTechniqueType.HIGH_QUALITY # pyrefly: ignore[bad-index]
|
||||
and item["embedding_model_provider"] # pyrefly: ignore[bad-index]
|
||||
):
|
||||
item["embedding_model_provider"] = str( # pyrefly: ignore[unsupported-operation]
|
||||
ModelProviderID(item["embedding_model_provider"]) # pyrefly: ignore[bad-index]
|
||||
)
|
||||
item_model = f"{item['embedding_model']}:{item['embedding_model_provider']}" # pyrefly: ignore[bad-index]
|
||||
if item_model in model_names:
|
||||
item["embedding_available"] = True # type: ignore
|
||||
else:
|
||||
|
||||
@ -457,16 +457,14 @@ def init_app(app: DifyApp):
|
||||
|
||||
def get_pubsub_broadcast_channel() -> BroadcastChannelProtocol:
|
||||
assert _pubsub_redis_client is not None, "PubSub redis Client should be initialized here."
|
||||
join_timeout_ms = dify_config.PUBSUB_LISTENER_JOIN_TIMEOUT_MS
|
||||
if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "sharded":
|
||||
return ShardedRedisBroadcastChannel(_pubsub_redis_client, join_timeout_ms=join_timeout_ms)
|
||||
return ShardedRedisBroadcastChannel(_pubsub_redis_client)
|
||||
if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "streams":
|
||||
return StreamsBroadcastChannel(
|
||||
_pubsub_redis_client,
|
||||
retention_seconds=dify_config.PUBSUB_STREAMS_RETENTION_SECONDS,
|
||||
join_timeout_ms=join_timeout_ms,
|
||||
)
|
||||
return RedisBroadcastChannel(_pubsub_redis_client, join_timeout_ms=join_timeout_ms)
|
||||
return RedisBroadcastChannel(_pubsub_redis_client)
|
||||
|
||||
|
||||
def redis_fallback[T](default_return: T | None = None): # type: ignore
|
||||
|
||||
@ -26,8 +26,6 @@ class RedisSubscriptionBase(Subscription):
|
||||
client: Redis | RedisCluster,
|
||||
pubsub: PubSub,
|
||||
topic: str,
|
||||
*,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
# The _pubsub is None only if the subscription is closed.
|
||||
self._client = client
|
||||
@ -39,11 +37,6 @@ class RedisSubscriptionBase(Subscription):
|
||||
self._listener_thread: threading.Thread | None = None
|
||||
self._start_lock = threading.Lock()
|
||||
self._started = False
|
||||
# Max time close() will wait for the listener thread to finish before
|
||||
# returning. Bounds SSE close tail latency. The listener is a daemon
|
||||
# and exits on its own within one poll window (~1s), so a low value
|
||||
# here just means close() returns sooner without breaking anything.
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
def _start_if_needed(self) -> None:
|
||||
"""Start the subscription if not already started."""
|
||||
@ -212,7 +205,7 @@ class RedisSubscriptionBase(Subscription):
|
||||
# Due to the restriction above, the PubSub cleanup logic happens inside the consumer thread.
|
||||
listener = self._listener_thread
|
||||
if listener is not None:
|
||||
listener.join(timeout=self._join_timeout_ms / 1000.0)
|
||||
listener.join(timeout=1.0)
|
||||
self._listener_thread = None
|
||||
|
||||
# Abstract methods to be implemented by subclasses
|
||||
|
||||
@ -22,30 +22,18 @@ class BroadcastChannel:
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: Redis | RedisCluster,
|
||||
*,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
self._client = redis_client
|
||||
# See `RedisSubscriptionBase._join_timeout_ms`: how long close()
|
||||
# waits for the listener thread before returning.
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
def topic(self, topic: str) -> Topic:
|
||||
return Topic(self._client, topic, join_timeout_ms=self._join_timeout_ms)
|
||||
return Topic(self._client, topic)
|
||||
|
||||
|
||||
class Topic:
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: Redis | RedisCluster,
|
||||
topic: str,
|
||||
*,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
def __init__(self, redis_client: Redis | RedisCluster, topic: str):
|
||||
self._client = redis_client
|
||||
self._topic = topic
|
||||
self._redis_topic = serialize_redis_name(topic)
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
def as_producer(self) -> Producer:
|
||||
return self
|
||||
@ -61,7 +49,6 @@ class Topic:
|
||||
client=self._client,
|
||||
pubsub=self._client.pubsub(),
|
||||
topic=self._redis_topic,
|
||||
join_timeout_ms=self._join_timeout_ms,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -20,28 +20,18 @@ class ShardedRedisBroadcastChannel:
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: Redis | RedisCluster,
|
||||
*,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
self._client = redis_client
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
def topic(self, topic: str) -> ShardedTopic:
|
||||
return ShardedTopic(self._client, topic, join_timeout_ms=self._join_timeout_ms)
|
||||
return ShardedTopic(self._client, topic)
|
||||
|
||||
|
||||
class ShardedTopic:
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: Redis | RedisCluster,
|
||||
topic: str,
|
||||
*,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
def __init__(self, redis_client: Redis | RedisCluster, topic: str):
|
||||
self._client = redis_client
|
||||
self._topic = topic
|
||||
self._redis_topic = serialize_redis_name(topic)
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
def as_producer(self) -> Producer:
|
||||
return self
|
||||
@ -57,7 +47,6 @@ class ShardedTopic:
|
||||
client=self._client,
|
||||
pubsub=self._client.pubsub(),
|
||||
topic=self._redis_topic,
|
||||
join_timeout_ms=self._join_timeout_ms,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -24,42 +24,20 @@ class StreamsBroadcastChannel:
|
||||
- The stream key expires `retention_seconds` after the last event is published (to bound storage).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: Redis | RedisCluster,
|
||||
*,
|
||||
retention_seconds: int = 600,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
def __init__(self, redis_client: Redis | RedisCluster, *, retention_seconds: int = 600):
|
||||
self._client = redis_client
|
||||
self._retention_seconds = max(int(retention_seconds or 0), 0)
|
||||
# Max time close() will wait for the listener thread to finish.
|
||||
# See `_StreamsSubscription._join_timeout_ms` for the rationale.
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
def topic(self, topic: str) -> StreamsTopic:
|
||||
return StreamsTopic(
|
||||
self._client,
|
||||
topic,
|
||||
retention_seconds=self._retention_seconds,
|
||||
join_timeout_ms=self._join_timeout_ms,
|
||||
)
|
||||
return StreamsTopic(self._client, topic, retention_seconds=self._retention_seconds)
|
||||
|
||||
|
||||
class StreamsTopic:
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: Redis | RedisCluster,
|
||||
topic: str,
|
||||
*,
|
||||
retention_seconds: int = 600,
|
||||
join_timeout_ms: int = 2000,
|
||||
):
|
||||
def __init__(self, redis_client: Redis | RedisCluster, topic: str, *, retention_seconds: int = 600):
|
||||
self._client = redis_client
|
||||
self._topic = topic
|
||||
self._key = serialize_redis_name(f"stream:{topic}")
|
||||
self._retention_seconds = retention_seconds
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
self.max_length = 5000
|
||||
|
||||
def as_producer(self) -> Producer:
|
||||
@ -77,23 +55,15 @@ class StreamsTopic:
|
||||
return self
|
||||
|
||||
def subscribe(self) -> Subscription:
|
||||
return _StreamsSubscription(self._client, self._key, join_timeout_ms=self._join_timeout_ms)
|
||||
return _StreamsSubscription(self._client, self._key)
|
||||
|
||||
|
||||
class _StreamsSubscription(Subscription):
|
||||
_SENTINEL = object()
|
||||
|
||||
def __init__(self, client: Redis | RedisCluster, key: str, *, join_timeout_ms: int = 2000):
|
||||
def __init__(self, client: Redis | RedisCluster, key: str):
|
||||
self._client = client
|
||||
self._key = key
|
||||
# Max time close() will wait for the listener thread to finish before
|
||||
# returning. Bounds SSE close tail latency: the listener blocks on
|
||||
# XREAD with BLOCK=1000ms, so close() naturally waits up to ~1s for
|
||||
# the thread to notice _closed. Setting this lower lets close()
|
||||
# return promptly while the daemon listener exits on its own within
|
||||
# one BLOCK window - safe because the listener holds no critical
|
||||
# state. ``0`` means close() does not wait at all.
|
||||
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
|
||||
|
||||
self._queue: queue.Queue[object] = queue.Queue()
|
||||
|
||||
@ -211,13 +181,11 @@ class _StreamsSubscription(Subscription):
|
||||
# We close the listener outside of the with block to avoid holding the
|
||||
# lock for a long time.
|
||||
if listener is not None and listener.is_alive():
|
||||
listener.join(timeout=self._join_timeout_ms / 1000.0)
|
||||
listener.join(timeout=2.0)
|
||||
if listener.is_alive():
|
||||
logger.debug(
|
||||
"Streams subscription listener for key %s did not stop within %dms; "
|
||||
"daemon thread will exit on its own within one poll window.",
|
||||
logger.warning(
|
||||
"Streams subscription listener for key %s did not stop within timeout; keeping reference.",
|
||||
self._key,
|
||||
self._join_timeout_ms,
|
||||
)
|
||||
|
||||
# Context manager helpers
|
||||
|
||||
@ -31,7 +31,7 @@ dependencies = [
|
||||
"flask-migrate>=4.1.0,<5.0.0",
|
||||
"flask-orjson>=2.0.0,<3.0.0",
|
||||
"flask-restx>=1.3.2,<2.0.0",
|
||||
"google-cloud-aiplatform>=1.151.0,<2.0.0",
|
||||
"google-cloud-aiplatform>=1.153.1,<2.0.0",
|
||||
"httpx[socks]==0.28.1",
|
||||
"opentelemetry-distro==0.62b1",
|
||||
"opentelemetry-instrumentation-celery==0.62b1",
|
||||
|
||||
@ -176,48 +176,6 @@ class TestStreamsBroadcastChannel:
|
||||
assert topic.as_producer() is topic
|
||||
assert topic.as_subscriber() is topic
|
||||
|
||||
def test_join_timeout_ms_propagates_from_channel_to_subscription(self, fake_redis: FakeStreamsRedis):
|
||||
channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60, join_timeout_ms=150)
|
||||
topic = channel.topic("join-timeout-prop")
|
||||
|
||||
assert topic._join_timeout_ms == 150
|
||||
|
||||
sub = topic.subscribe()
|
||||
try:
|
||||
assert sub._join_timeout_ms == 150
|
||||
finally:
|
||||
sub.close()
|
||||
|
||||
def test_join_timeout_ms_defaults_to_2000(self, fake_redis: FakeStreamsRedis):
|
||||
channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60)
|
||||
topic = channel.topic("join-timeout-default")
|
||||
|
||||
assert topic._join_timeout_ms == 2000
|
||||
|
||||
def test_small_join_timeout_makes_close_return_promptly(self, fake_redis: FakeStreamsRedis):
|
||||
"""close() should respect the configured join timeout.
|
||||
|
||||
Regression test for SSE close tail latency: when an idle listener is
|
||||
blocked on its poll cycle, close() with a small join_timeout_ms must
|
||||
not wait for the full poll window. The orphaned daemon listener
|
||||
cleans itself up later.
|
||||
"""
|
||||
channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60, join_timeout_ms=50)
|
||||
topic = channel.topic("join-timeout-prompt-close")
|
||||
sub = topic.subscribe()
|
||||
|
||||
# Drive listener startup so the thread is actually blocked in xread.
|
||||
assert sub.receive(timeout=0.05) is None
|
||||
time.sleep(0.05)
|
||||
|
||||
started = time.monotonic()
|
||||
sub.close()
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
# 50ms timeout + scheduling slack; pick a ceiling well under the
|
||||
# default poll window (1000ms) to make the regression meaningful.
|
||||
assert elapsed < 0.5, f"close() took {elapsed:.3f}s; expected prompt return"
|
||||
|
||||
def test_publish_logs_warning_when_expire_fails(self, caplog: pytest.LogCaptureFixture):
|
||||
channel = StreamsBroadcastChannel(FailExpireRedis(), retention_seconds=60)
|
||||
topic = channel.topic("expire-warning")
|
||||
@ -384,17 +342,10 @@ class TestStreamsSubscription:
|
||||
|
||||
assert next(iter(subscription)) == b"event"
|
||||
|
||||
def test_close_logs_debug_when_listener_does_not_stop_in_time(
|
||||
def test_close_logs_warning_when_listener_does_not_stop_in_time(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
"""When a low join_timeout elapses with the listener still alive,
|
||||
close() should log at DEBUG (not WARNING) - with a deliberately small
|
||||
timeout this is expected, not anomalous; the orphaned daemon thread
|
||||
cleans itself up on the next poll boundary.
|
||||
"""
|
||||
import logging
|
||||
|
||||
blocking_redis = BlockingRedis()
|
||||
subscription = _StreamsSubscription(blocking_redis, "stream:slow-close")
|
||||
|
||||
@ -412,10 +363,8 @@ class TestStreamsSubscription:
|
||||
listener.is_alive = lambda: True # type: ignore[method-assign]
|
||||
|
||||
try:
|
||||
with caplog.at_level(logging.DEBUG, logger="libs.broadcast_channel.redis.streams_channel"):
|
||||
subscription.close()
|
||||
assert "did not stop within" in caplog.text
|
||||
assert "daemon thread will exit on its own" in caplog.text
|
||||
subscription.close()
|
||||
assert "did not stop within timeout" in caplog.text
|
||||
finally:
|
||||
listener.join = original_join # type: ignore[method-assign]
|
||||
listener.is_alive = original_is_alive # type: ignore[method-assign]
|
||||
|
||||
8
api/uv.lock
generated
8
api/uv.lock
generated
@ -1628,7 +1628,7 @@ requires-dist = [
|
||||
{ name = "gevent-websocket", specifier = "==0.10.1" },
|
||||
{ name = "gmpy2", specifier = ">=2.3.0,<3.0.0" },
|
||||
{ name = "google-api-python-client", specifier = ">=2.196.0,<3.0.0" },
|
||||
{ name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" },
|
||||
{ name = "google-cloud-aiplatform", specifier = ">=1.153.1,<2.0.0" },
|
||||
{ name = "graphon", specifier = "==0.4.0" },
|
||||
{ name = "gunicorn", specifier = ">=26.0.0,<27.0.0" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
|
||||
@ -2813,7 +2813,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-aiplatform"
|
||||
version = "1.151.0"
|
||||
version = "1.153.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docstring-parser" },
|
||||
@ -2829,9 +2829,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/f6/e2fbe175a011f5080da8c1f7d9169a6875a00ea2c7bee4193d952b097400/google_cloud_aiplatform-1.151.0.tar.gz", hash = "sha256:2f29b1853f790a7371a746c747bf1f664380b534254682441acd4b5ee26fafd2", size = 10617421, upload-time = "2026-05-07T21:56:52.91Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/97/1779e66ab845550bc602364311ea093ba156cb805a1c31b7c4d6f25b5863/google_cloud_aiplatform-1.153.1.tar.gz", hash = "sha256:445b6c683d5c630f174d81ae1f69f7da9e27e4d4ec5b70c5fe96de5c1247cfbc", size = 11011349, upload-time = "2026-05-15T06:34:14.851Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/4a/cd35f8ba622d563b1335222284d2838aa789b953b40516b1b997e50fe5b6/google_cloud_aiplatform-1.151.0-py2.py3-none-any.whl", hash = "sha256:61372bb0923b14b8027f45b83393452df3a85bf4ea86fa48e08844fb5ec50049", size = 8732627, upload-time = "2026-05-07T21:56:49.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/8a1900e7a742ed480e6037ac4f6541466cb981d81bd4cbd34a9d46204ea1/google_cloud_aiplatform-1.153.1-py2.py3-none-any.whl", hash = "sha256:033fa1595a7e8ed1d97066e261e630f38fbc60e10c98c6487cf228fe9c7ec151", size = 9170782, upload-time = "2026-05-15T06:34:10.887Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -118,7 +118,6 @@ CELERY_TASK_ANNOTATIONS=null
|
||||
EVENT_BUS_REDIS_URL=
|
||||
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
|
||||
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
||||
EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
|
||||
|
||||
# Web and app limits
|
||||
WEB_API_CORS_ALLOW_ORIGINS=*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -65,10 +65,6 @@
|
||||
"types": "./src/form/index.tsx",
|
||||
"import": "./src/form/index.tsx"
|
||||
},
|
||||
"./input": {
|
||||
"types": "./src/input/index.tsx",
|
||||
"import": "./src/input/index.tsx"
|
||||
},
|
||||
"./meter": {
|
||||
"types": "./src/meter/index.tsx",
|
||||
"import": "./src/meter/index.tsx"
|
||||
@ -77,14 +73,6 @@
|
||||
"types": "./src/number-field/index.tsx",
|
||||
"import": "./src/number-field/index.tsx"
|
||||
},
|
||||
"./radio": {
|
||||
"types": "./src/radio/index.tsx",
|
||||
"import": "./src/radio/index.tsx"
|
||||
},
|
||||
"./radio-group": {
|
||||
"types": "./src/radio-group/index.tsx",
|
||||
"import": "./src/radio-group/index.tsx"
|
||||
},
|
||||
"./popover": {
|
||||
"types": "./src/popover/index.tsx",
|
||||
"import": "./src/popover/index.tsx"
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
import type { Field as BaseFieldNS } from '@base-ui/react/field'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { Field as BaseField } from '@base-ui/react/field'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { cn } from '../cn'
|
||||
import { inputVariants } from '../form-control-shared'
|
||||
|
||||
export type FieldRootProps
|
||||
= Omit<BaseFieldNS.Root.Props, 'className'>
|
||||
@ -62,11 +62,37 @@ export function FieldLabel({
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldControlSize = NonNullable<VariantProps<typeof inputVariants>['size']>
|
||||
const fieldControlVariants = cva(
|
||||
[
|
||||
'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
|
||||
'placeholder:text-components-input-text-placeholder',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
|
||||
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
|
||||
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
|
||||
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
|
||||
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'rounded-md px-2 py-[3px] system-xs-regular',
|
||||
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
|
||||
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type FieldControlSize = NonNullable<VariantProps<typeof fieldControlVariants>['size']>
|
||||
|
||||
export type FieldControlProps
|
||||
= Omit<BaseFieldNS.Control.Props, 'className' | 'size'>
|
||||
& VariantProps<typeof inputVariants>
|
||||
& VariantProps<typeof fieldControlVariants>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
@ -80,7 +106,7 @@ export function FieldControl({
|
||||
}: FieldControlProps) {
|
||||
return (
|
||||
<BaseField.Control
|
||||
className={cn(inputVariants({ size }), className)}
|
||||
className={cn(fieldControlVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const inputVariants = cva(
|
||||
[
|
||||
'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
|
||||
'placeholder:text-components-input-text-placeholder',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
|
||||
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
|
||||
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
|
||||
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
|
||||
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'rounded-md px-2 py-[3px] system-xs-regular',
|
||||
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
|
||||
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type InputSize = NonNullable<VariantProps<typeof inputVariants>['size']>
|
||||
@ -1,83 +0,0 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '../../field'
|
||||
import { Form } from '../../form'
|
||||
import { Input } from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
describe('Input', () => {
|
||||
it('should render a labelled Base UI input with design-system classes', async () => {
|
||||
const screen = await render(
|
||||
<label>
|
||||
Workspace name
|
||||
<Input name="workspaceName" defaultValue="Dify" />
|
||||
</label>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Workspace name' })
|
||||
|
||||
await expect.element(input).toHaveValue('Dify')
|
||||
await expect.element(input).toHaveClass('rounded-lg', 'py-[7px]', 'system-sm-regular')
|
||||
})
|
||||
|
||||
it('should apply size variants shared with FieldControl', async () => {
|
||||
const screen = await render(
|
||||
<>
|
||||
<label>
|
||||
Small input
|
||||
<Input size="small" />
|
||||
</label>
|
||||
<div>
|
||||
Large field
|
||||
<FieldRoot name="largeField">
|
||||
<FieldLabel>Large field</FieldLabel>
|
||||
<FieldControl size="large" />
|
||||
</FieldRoot>
|
||||
</div>
|
||||
</>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Small input' })).toHaveClass('rounded-md', 'py-[3px]', 'system-xs-regular')
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Large field' })).toHaveClass('rounded-[10px]', 'py-[7px]', 'system-md-regular')
|
||||
})
|
||||
|
||||
it('should use FieldRoot invalid state', async () => {
|
||||
const screen = await render(
|
||||
<FieldRoot name="repositoryUrl" invalid>
|
||||
<FieldLabel>Repository URL</FieldLabel>
|
||||
<Input defaultValue="github.com/langgenius" />
|
||||
</FieldRoot>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Repository URL' })
|
||||
|
||||
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
|
||||
await expect.element(input).toHaveAttribute('data-invalid')
|
||||
await expect.element(input).toHaveClass('data-invalid:border-components-input-border-destructive')
|
||||
})
|
||||
|
||||
it('should integrate with FieldRoot and Base UI Form validation', async () => {
|
||||
const onFormSubmit = vi.fn()
|
||||
const screen = await render(
|
||||
<Form aria-label="account form" onFormSubmit={onFormSubmit}>
|
||||
<FieldRoot name="email">
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input type="email" required />
|
||||
<FieldError match="valueMissing">Email is required.</FieldError>
|
||||
</FieldRoot>
|
||||
<button type="submit">Save</button>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Email' })
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(screen.getByText('Email is required.')).toBeInTheDocument()
|
||||
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
|
||||
await expect.element(input).toHaveAttribute('data-invalid')
|
||||
})
|
||||
expect(onFormSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,124 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Button } from '../button'
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../field'
|
||||
import { Form } from '../form'
|
||||
import { Input } from './index'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Input',
|
||||
component: Input,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'A standalone text input primitive built on Base UI Input. Use it for labelled text boxes outside FieldControl, and keep FieldControl for full FieldRoot form composition.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Input>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<div className="w-80">
|
||||
<label htmlFor="workspace-name" className="mb-1 block w-fit py-1 text-text-secondary system-sm-medium">
|
||||
Workspace name
|
||||
</label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
name="workspaceName"
|
||||
autoComplete="organization"
|
||||
placeholder="e.g. Acme workspace…"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-80 gap-3">
|
||||
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="small-input">
|
||||
Small
|
||||
<Input id="small-input" size="small" name="smallInput" placeholder="e.g. tag…" autoComplete="off" />
|
||||
</label>
|
||||
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="medium-input">
|
||||
Medium
|
||||
<Input id="medium-input" name="mediumInput" placeholder="e.g. Production API…" autoComplete="off" />
|
||||
</label>
|
||||
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="large-input">
|
||||
Large
|
||||
<Input id="large-input" size="large" name="largeInput" placeholder="e.g. Customer portal…" autoComplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const States: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-80 gap-3">
|
||||
<div className="grid gap-1">
|
||||
<label className="text-text-secondary system-sm-medium" htmlFor="placeholder-state">Placeholder</label>
|
||||
<Input id="placeholder-state" name="placeholderState" placeholder="e.g. Search datasets…" autoComplete="off" />
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<label className="text-text-secondary system-sm-medium" htmlFor="filled-state">Filled</label>
|
||||
<Input id="filled-state" name="filledState" defaultValue="Customer knowledge base" autoComplete="off" />
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<FieldRoot name="repositoryUrl" invalid>
|
||||
<FieldLabel>Invalid</FieldLabel>
|
||||
<Input
|
||||
id="invalid-state"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
defaultValue="github.com/langgenius"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<FieldError match>Enter a full URL including https://.</FieldError>
|
||||
</FieldRoot>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<label className="text-text-secondary system-sm-medium" htmlFor="disabled-state">Disabled</label>
|
||||
<Input id="disabled-state" disabled name="disabledEmail" type="email" inputMode="email" placeholder="name@example.com…" autoComplete="email" spellCheck={false} />
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<label className="text-text-secondary system-sm-medium" htmlFor="readonly-state">Read-only</label>
|
||||
<Input id="readonly-state" readOnly name="endpoint" type="url" inputMode="url" defaultValue="https://api.example.com" autoComplete="url" spellCheck={false} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithField: Story = {
|
||||
render: () => (
|
||||
<Form aria-label="Account form" className="grid w-80 gap-4" onFormSubmit={() => undefined}>
|
||||
<FieldRoot name="email">
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input type="email" inputMode="email" required autoComplete="email" placeholder="name@example.com…" spellCheck={false} />
|
||||
<FieldDescription>Used for account notifications.</FieldDescription>
|
||||
<FieldError match="valueMissing">Email is required.</FieldError>
|
||||
<FieldError match="typeMismatch">Enter a valid email address.</FieldError>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="repositoryUrl">
|
||||
<FieldLabel>Repository URL</FieldLabel>
|
||||
<Input type="url" inputMode="url" required autoComplete="off" placeholder="https://github.com/langgenius/dify…" spellCheck={false} />
|
||||
<FieldDescription>Use the full GitHub repository URL.</FieldDescription>
|
||||
<FieldError match="valueMissing">Repository URL is required.</FieldError>
|
||||
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
|
||||
</FieldRoot>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="primary">Save Settings</Button>
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Input as BaseInputNS } from '@base-ui/react/input'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { Input as BaseInput } from '@base-ui/react/input'
|
||||
import { cn } from '../cn'
|
||||
import { inputVariants } from '../form-control-shared'
|
||||
|
||||
export type InputSize = NonNullable<VariantProps<typeof inputVariants>['size']>
|
||||
|
||||
export type InputProps
|
||||
= Omit<BaseInputNS.Props, 'className' | 'size'>
|
||||
& VariantProps<typeof inputVariants>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export type InputChangeEventDetails = BaseInputNS.ChangeEventDetails
|
||||
|
||||
export function Input({
|
||||
className,
|
||||
size = 'medium',
|
||||
...props
|
||||
}: InputProps) {
|
||||
return (
|
||||
<BaseInput
|
||||
className={cn(inputVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '../../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
|
||||
import { Radio } from '../../radio'
|
||||
import { RadioGroup } from '../index'
|
||||
|
||||
const clickElement = (element: HTMLElement | SVGElement) => {
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
}
|
||||
|
||||
describe('RadioGroup', () => {
|
||||
it('should manage a controlled single selection', async () => {
|
||||
function StorageDemo() {
|
||||
const [value, setValue] = useState('ssd')
|
||||
|
||||
return (
|
||||
<FieldRoot name="storageType">
|
||||
<FieldsetRoot render={<RadioGroup value={value} onValueChange={setValue} />}>
|
||||
<FieldsetLegend>Storage type</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Radio value="ssd" />
|
||||
SSD
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Radio value="hdd" />
|
||||
HDD
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
const screen = await render(<StorageDemo />)
|
||||
|
||||
await expect.element(screen.getByRole('radio', { name: 'SSD' })).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
clickElement(screen.getByRole('radio', { name: 'HDD' }).element())
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(screen.getByRole('radio', { name: 'SSD' })).toHaveAttribute('aria-checked', 'false')
|
||||
await expect.element(screen.getByRole('radio', { name: 'HDD' })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should compose with Dify UI Field and Fieldset without losing labels', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const screen = await render(
|
||||
<FieldRoot name="storageType">
|
||||
<FieldsetRoot render={<RadioGroup value="ssd" onValueChange={onValueChange} />}>
|
||||
<FieldsetLegend>Storage type</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Radio value="ssd" />
|
||||
SSD
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Radio value="hdd" />
|
||||
HDD
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('radiogroup', { name: 'Storage type' })).toBeInTheDocument()
|
||||
|
||||
const hdd = screen.getByRole('radio', { name: 'HDD' })
|
||||
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
clickElement(hdd.element())
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||
expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd')
|
||||
})
|
||||
})
|
||||
@ -1,217 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { useState } from 'react'
|
||||
import { RadioGroup } from '.'
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldItem,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
|
||||
import { Radio, RadioControl, RadioRoot } from '../radio'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/RadioGroup',
|
||||
component: RadioGroup,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'RadioGroup primitive built on Base UI. For normal form rows, compose FieldRoot, FieldsetRoot, FieldLabel, RadioGroup, and Radio. For option cards, make the card itself a RadioRoot with variant="unstyled" and render RadioControl inside it.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof RadioGroup>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function StandardFormRowsDemo() {
|
||||
const [value, setValue] = useState('vector')
|
||||
|
||||
return (
|
||||
<FieldRoot name="retrievalIndex" className="w-80">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-start gap-3" />
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend>Retrieval index</FieldsetLegend>
|
||||
{[
|
||||
{ value: 'vector', label: 'Vector storage' },
|
||||
{ value: 'keyword', label: 'Keyword index' },
|
||||
{ value: 'hybrid', label: 'Hybrid retrieval' },
|
||||
].map(option => (
|
||||
<FieldItem key={option.value}>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value={option.value} />
|
||||
{option.label}
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const StandardFormRows: Story = {
|
||||
render: () => <StandardFormRowsDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Default form composition. Most product code should use this shape: RadioGroup owns value, FieldsetLegend names the group, and FieldLabel makes each row clickable.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function BooleanInlineDemo() {
|
||||
const [value, setValue] = useState(true)
|
||||
|
||||
return (
|
||||
<FieldRoot name="streaming" className="w-80">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<boolean> value={value} onValueChange={setValue} className="gap-3" />
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend>Streaming output</FieldsetLegend>
|
||||
<div className="flex items-center gap-3">
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={true} />
|
||||
True
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={false} />
|
||||
False
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const BooleanInline: Story = {
|
||||
render: () => <BooleanInlineDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Compact boolean radio fields. This is the pattern used by model parameters and dynamic boolean schema fields.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function OptionCardsDemo() {
|
||||
const [value, setValue] = useState('default')
|
||||
|
||||
return (
|
||||
<FieldRoot name="promptMode" className="w-100">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-stretch gap-3" />
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend>Prompt mode</FieldsetLegend>
|
||||
{[
|
||||
{
|
||||
value: 'default',
|
||||
title: 'Default prompt',
|
||||
description: 'Use the built-in prompt for consistent output.',
|
||||
},
|
||||
{
|
||||
value: 'custom',
|
||||
title: 'Custom prompt',
|
||||
description: 'Write a prompt for this app and keep full control.',
|
||||
},
|
||||
].map(option => (
|
||||
<RadioRoot
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
className="w-full rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-4 text-left transition-colors hover:bg-state-base-hover data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="system-sm-semibold text-text-primary">
|
||||
{option.title}
|
||||
</div>
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
<RadioControl aria-hidden="true" />
|
||||
</div>
|
||||
</RadioRoot>
|
||||
))}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const OptionCards: Story = {
|
||||
render: () => <OptionCardsDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Use RadioRoot with variant="unstyled" when the entire option card is the radio. RadioControl renders the visual dot inside the card.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function DynamicFormFieldDemo() {
|
||||
const options = [
|
||||
{ value: 'automatic', label: 'Automatic' },
|
||||
{ value: 'high_quality', label: 'High quality' },
|
||||
{ value: 'economy', label: 'Economy' },
|
||||
]
|
||||
const [selected, setSelected] = useState('automatic')
|
||||
|
||||
return (
|
||||
<FieldRoot name="generation_mode" className="flex w-80 flex-col gap-2">
|
||||
<FieldDescription className="body-xs-regular text-text-tertiary">
|
||||
This mirrors Dify dynamic form fields where radio options are controlled by schema and persisted as a single value.
|
||||
</FieldDescription>
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup
|
||||
value={selected}
|
||||
onValueChange={setSelected}
|
||||
className="flex-col items-start gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg p-3"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="system-sm-medium text-text-secondary">
|
||||
Generation mode
|
||||
</FieldsetLegend>
|
||||
{options.map(option => (
|
||||
<FieldItem key={option.value}>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value={option.value} />
|
||||
{option.label}
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const DynamicFormField: Story = {
|
||||
render: () => <DynamicFormFieldDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Matches Dify form composition: Field and Fieldset provide group labeling while RadioGroup owns controlled single-selection state.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { RadioGroup as BaseRadioGroupNS } from '@base-ui/react/radio-group'
|
||||
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export type RadioGroupProps<Value = string>
|
||||
= Omit<BaseRadioGroupNS.Props<Value>, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function RadioGroup<Value = string>({
|
||||
className,
|
||||
...props
|
||||
}: RadioGroupProps<Value>) {
|
||||
return (
|
||||
<BaseRadioGroup
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,178 +0,0 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '../../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
|
||||
import { RadioGroup } from '../../radio-group'
|
||||
import {
|
||||
Radio,
|
||||
RadioControl,
|
||||
RadioIndicator,
|
||||
RadioRoot,
|
||||
RadioSkeleton,
|
||||
} from '../index'
|
||||
|
||||
const clickElement = (element: HTMLElement | SVGElement) => {
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
}
|
||||
|
||||
type TestRadioGroupProps = ComponentProps<typeof RadioGroup> & {
|
||||
children: ReactNode
|
||||
label: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
function TestRadioGroup({
|
||||
children,
|
||||
label,
|
||||
name = 'radioField',
|
||||
...props
|
||||
}: TestRadioGroupProps) {
|
||||
return (
|
||||
<FieldRoot name={name}>
|
||||
<FieldsetRoot render={<RadioGroup {...props} />}>
|
||||
<FieldsetLegend>{label}</FieldsetLegend>
|
||||
{children}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
type TestRadioOptionProps = ComponentProps<typeof Radio> & {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function TestRadioOption({
|
||||
children,
|
||||
...props
|
||||
}: TestRadioOptionProps) {
|
||||
return (
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Radio {...props} />
|
||||
{children}
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Radio', () => {
|
||||
it('should render unchecked and checked radios with Base UI semantics', async () => {
|
||||
const screen = await render(
|
||||
<TestRadioGroup defaultValue="ssd" label="Storage type">
|
||||
<TestRadioOption value="ssd">SSD</TestRadioOption>
|
||||
<TestRadioOption value="hdd">HDD</TestRadioOption>
|
||||
</TestRadioGroup>,
|
||||
)
|
||||
|
||||
const ssd = screen.getByRole('radio', { name: 'SSD' })
|
||||
const hdd = screen.getByRole('radio', { name: 'HDD' })
|
||||
|
||||
await expect.element(ssd).toHaveAttribute('aria-checked', 'true')
|
||||
await expect.element(ssd).toHaveAttribute('data-checked', '')
|
||||
await expect.element(ssd).toHaveClass('data-checked:border-components-radio-border-checked')
|
||||
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
|
||||
await expect.element(hdd).toHaveAttribute('data-unchecked', '')
|
||||
})
|
||||
|
||||
it('should call onValueChange and update uncontrolled state when selected', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const screen = await render(
|
||||
<TestRadioGroup defaultValue="ssd" label="Storage type" onValueChange={onValueChange}>
|
||||
<TestRadioOption value="ssd">SSD</TestRadioOption>
|
||||
<TestRadioOption value="hdd">HDD</TestRadioOption>
|
||||
</TestRadioGroup>,
|
||||
)
|
||||
|
||||
clickElement(screen.getByRole('radio', { name: 'HDD' }).element())
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||
expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd')
|
||||
await expect.element(screen.getByRole('radio', { name: 'HDD' })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should ignore interaction when disabled', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const screen = await render(
|
||||
<TestRadioGroup defaultValue="ssd" label="Storage type" onValueChange={onValueChange}>
|
||||
<TestRadioOption value="ssd">SSD</TestRadioOption>
|
||||
<TestRadioOption value="hdd" disabled>HDD</TestRadioOption>
|
||||
</TestRadioGroup>,
|
||||
)
|
||||
|
||||
const hdd = screen.getByRole('radio', { name: 'HDD' })
|
||||
await expect.element(hdd).toHaveAttribute('data-disabled', '')
|
||||
await expect.element(hdd).toHaveClass('data-disabled:cursor-not-allowed')
|
||||
|
||||
clickElement(hdd.element())
|
||||
|
||||
expect(onValueChange).not.toHaveBeenCalled()
|
||||
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('should submit the selected group value through the hidden input', async () => {
|
||||
const screen = await render(
|
||||
<form>
|
||||
<TestRadioGroup defaultValue="ssd" label="Storage type" name="storageType">
|
||||
<TestRadioOption value="ssd">SSD</TestRadioOption>
|
||||
<TestRadioOption value="hdd">HDD</TestRadioOption>
|
||||
</TestRadioGroup>
|
||||
</form>,
|
||||
)
|
||||
const form = screen.container.querySelector<HTMLFormElement>('form')
|
||||
expect(form).not.toBeNull()
|
||||
if (!form)
|
||||
return
|
||||
|
||||
const data = new FormData(form)
|
||||
|
||||
expect(data.get('storageType')).toBe('ssd')
|
||||
})
|
||||
|
||||
it('should support custom compound composition with RadioRoot and RadioIndicator', async () => {
|
||||
const screen = await render(
|
||||
<TestRadioGroup defaultValue="custom" label="Custom">
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<RadioRoot value="custom" className="custom-root">
|
||||
<RadioIndicator className="custom-indicator" keepMounted />
|
||||
</RadioRoot>
|
||||
Custom
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</TestRadioGroup>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('radio', { name: 'Custom' })).toHaveClass('custom-root')
|
||||
expect(screen.container.querySelector('.custom-indicator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should support unstyled roots with a visual RadioControl for option cards', async () => {
|
||||
const screen = await render(
|
||||
<RadioGroup defaultValue="card" aria-label="Card choice">
|
||||
<RadioRoot
|
||||
value="card"
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
render={<button type="button" className="custom-card" />}
|
||||
>
|
||||
<span>Card option</span>
|
||||
<RadioControl className="custom-control" />
|
||||
</RadioRoot>
|
||||
</RadioGroup>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('radio', { name: 'Card option' })).toHaveClass('custom-card')
|
||||
expect(screen.container.querySelector('.custom-control')).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('radio', { name: 'Card option' })).toHaveAttribute('data-checked', '')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RadioSkeleton', () => {
|
||||
it('should render a visual placeholder without radio semantics', async () => {
|
||||
const screen = await render(<RadioSkeleton />)
|
||||
const skeleton = screen.container.querySelector<HTMLElement>('.rounded-full')
|
||||
|
||||
expect(screen.container.querySelector('[role="radio"]')).not.toBeInTheDocument()
|
||||
await expect.element(skeleton).toHaveClass('rounded-full', 'opacity-20')
|
||||
})
|
||||
})
|
||||
@ -1,147 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Radio,
|
||||
RadioSkeleton,
|
||||
} from '.'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
|
||||
import { RadioGroup } from '../radio-group'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Radio',
|
||||
component: Radio,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Radio primitive built on Base UI. It preserves RadioGroup selection, hidden input, disabled, and form semantics while applying the Dify 16px radio design from Figma. Import from `@langgenius/dify-ui/radio` and place radios inside `RadioGroup` from `@langgenius/dify-ui/radio-group`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
disabled: false,
|
||||
value: 'ssd',
|
||||
},
|
||||
argTypes: {
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disables user interaction and exposes Base UI disabled state attributes.',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Radio>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function RadioDemo(args: Partial<ComponentProps<typeof Radio>>) {
|
||||
const [value, setValue] = useState('ssd')
|
||||
|
||||
return (
|
||||
<FieldRoot name="storageType">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-start gap-3" />
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend>Storage type</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio {...args} value="ssd" />
|
||||
SSD
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio {...args} value="hdd" />
|
||||
HDD
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: args => <RadioDemo {...args} />,
|
||||
args: {
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
value: 'checked',
|
||||
},
|
||||
render: () => (
|
||||
<FieldRoot name="disabledStates">
|
||||
<FieldsetRoot render={<RadioGroup value="checked" className="flex-col items-start gap-3" />}>
|
||||
<FieldsetLegend>Disabled states</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value="unchecked" disabled />
|
||||
Disabled unchecked
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value="checked" disabled />
|
||||
Disabled checked
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
),
|
||||
}
|
||||
|
||||
export const StateMatrix: Story = {
|
||||
args: {
|
||||
value: 'checked',
|
||||
},
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-3">
|
||||
<FieldRoot name="radioStates">
|
||||
<FieldsetRoot render={<RadioGroup value="checked" className="flex-col items-start gap-3" />}>
|
||||
<FieldsetLegend>Radio states</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value="unchecked" />
|
||||
Unchecked
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value="checked" />
|
||||
Checked
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value="disabled-unchecked" disabled />
|
||||
Disabled unchecked
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value="checked" disabled />
|
||||
Disabled checked
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
<div className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<RadioSkeleton aria-hidden="true" />
|
||||
Skeleton
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'The full visual matrix for Dify radio states. State styling comes from Base UI data attributes such as data-checked and data-disabled.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -1,105 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Radio as BaseRadioNS } from '@base-ui/react/radio'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { Radio as BaseRadio } from '@base-ui/react/radio'
|
||||
import { cn } from '../cn'
|
||||
|
||||
const radioRootClassName = cn(
|
||||
'inline-flex size-4 shrink-0 touch-manipulation items-center justify-center rounded-full p-0 transition-colors motion-reduce:transition-none',
|
||||
'border border-components-radio-border bg-components-radio-bg shadow-xs shadow-shadow-shadow-3',
|
||||
'hover:border-components-radio-border-hover hover:bg-components-radio-bg-hover',
|
||||
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover focus-visible:ring-offset-0',
|
||||
'data-checked:border-[5px] data-checked:border-components-radio-border-checked data-checked:hover:border-components-radio-border-checked-hover',
|
||||
'data-disabled:cursor-not-allowed data-disabled:border-components-radio-border-disabled data-disabled:bg-components-radio-bg-disabled',
|
||||
'data-disabled:hover:border-components-radio-border-disabled data-disabled:hover:bg-components-radio-bg-disabled',
|
||||
'data-disabled:data-checked:border-[5px] data-disabled:data-checked:border-components-radio-border-checked-disabled',
|
||||
'data-disabled:data-checked:hover:border-components-radio-border-checked-disabled',
|
||||
)
|
||||
|
||||
const radioIndicatorClassName = 'flex items-center justify-center data-unchecked:hidden before:size-1.5 before:rounded-full before:bg-current'
|
||||
|
||||
const radioControlClassName = radioRootClassName
|
||||
|
||||
const radioSkeletonClassName = 'size-4 shrink-0 rounded-full bg-text-quaternary opacity-20'
|
||||
|
||||
export type RadioRootProps<Value = string>
|
||||
= Omit<BaseRadioNS.Root.Props<Value>, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
variant?: 'control' | 'unstyled'
|
||||
}
|
||||
|
||||
export function RadioRoot<Value = string>({
|
||||
className,
|
||||
variant = 'control',
|
||||
...props
|
||||
}: RadioRootProps<Value>) {
|
||||
return (
|
||||
<BaseRadio.Root
|
||||
className={cn(variant === 'control' && radioRootClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type RadioIndicatorProps
|
||||
= Omit<BaseRadioNS.Indicator.Props, 'className' | 'children'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function RadioIndicator({
|
||||
className,
|
||||
...props
|
||||
}: RadioIndicatorProps) {
|
||||
return (
|
||||
<BaseRadio.Indicator
|
||||
className={cn(radioIndicatorClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type RadioControlProps
|
||||
= Omit<RadioIndicatorProps, 'keepMounted'>
|
||||
|
||||
export function RadioControl({
|
||||
className,
|
||||
...props
|
||||
}: RadioControlProps) {
|
||||
return (
|
||||
<BaseRadio.Indicator
|
||||
keepMounted
|
||||
className={cn(radioControlClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type RadioProps<Value = string>
|
||||
= Omit<RadioRootProps<Value>, 'children'>
|
||||
|
||||
export function Radio<Value = string>({
|
||||
...props
|
||||
}: RadioProps<Value>) {
|
||||
return <RadioRoot {...props} />
|
||||
}
|
||||
|
||||
export type RadioSkeletonProps
|
||||
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function RadioSkeleton({
|
||||
className,
|
||||
...props
|
||||
}: RadioSkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(radioSkeletonClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
32
pnpm-lock.yaml
generated
32
pnpm-lock.yaml
generated
@ -16,8 +16,8 @@ catalogs:
|
||||
specifier: 9.0.0
|
||||
version: 9.0.0
|
||||
'@base-ui/react':
|
||||
specifier: 1.5.0
|
||||
version: 1.5.0
|
||||
specifier: 1.4.1
|
||||
version: 1.4.1
|
||||
'@chromatic-com/storybook':
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1
|
||||
@ -741,7 +741,7 @@ importers:
|
||||
devDependencies:
|
||||
'@base-ui/react':
|
||||
specifier: 'catalog:'
|
||||
version: 1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@chromatic-com/storybook':
|
||||
specifier: 'catalog:'
|
||||
version: 5.2.1(storybook@10.4.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.21(@types/node@25.9.0)(@vitest/coverage-v8@4.1.6(@types/node@25.9.0)(@voidzero-dev/vite-plus-core@0.1.21(@types/node@25.9.0)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.2)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.2)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.21(@types/node@25.9.0)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.2)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.2)(typescript@6.0.3)(yaml@2.8.3)))
|
||||
@ -900,7 +900,7 @@ importers:
|
||||
version: 1.30.4(@amplitude/rrweb@2.0.0-alpha.40)
|
||||
'@base-ui/react':
|
||||
specifier: 'catalog:'
|
||||
version: 1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@emoji-mart/data':
|
||||
specifier: 'catalog:'
|
||||
version: 1.2.1
|
||||
@ -1652,8 +1652,8 @@ packages:
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@base-ui/react@1.5.0':
|
||||
resolution: {integrity: sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A==}
|
||||
'@base-ui/react@1.4.1':
|
||||
resolution: {integrity: sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@date-fns/tz': ^1.2.0
|
||||
@ -1669,8 +1669,8 @@ packages:
|
||||
date-fns:
|
||||
optional: true
|
||||
|
||||
'@base-ui/utils@0.2.9':
|
||||
resolution: {integrity: sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw==}
|
||||
'@base-ui/utils@0.2.8':
|
||||
resolution: {integrity: sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
react: ^17 || ^18 || ^19
|
||||
@ -7926,8 +7926,8 @@ packages:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reselect@5.2.0:
|
||||
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
reserved-identifiers@1.2.0:
|
||||
resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==}
|
||||
@ -9239,10 +9239,10 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@base-ui/react@1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
'@base-ui/react@1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@base-ui/utils': 0.2.9(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@base-ui/utils': 0.2.8(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@floating-ui/utils': 0.2.11
|
||||
react: 19.2.6
|
||||
@ -9251,13 +9251,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@base-ui/utils@0.2.9(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
'@base-ui/utils@0.2.8(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@floating-ui/utils': 0.2.11
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
reselect: 5.2.0
|
||||
reselect: 5.1.1
|
||||
use-sync-external-store: 1.6.0(react@19.2.6)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
@ -16002,7 +16002,7 @@ snapshots:
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
reselect@5.2.0: {}
|
||||
reselect@5.1.1: {}
|
||||
|
||||
reserved-identifiers@1.2.0: {}
|
||||
|
||||
@ -17028,7 +17028,7 @@ time:
|
||||
'@amplitude/analytics-browser@2.42.3': '2026-05-13T17:32:46.705Z'
|
||||
'@amplitude/plugin-session-replay-browser@1.30.4': '2026-05-14T00:11:02.360Z'
|
||||
'@antfu/eslint-config@9.0.0': '2026-05-11T06:18:58.474Z'
|
||||
'@base-ui/react@1.5.0': '2026-05-19T13:22:48.843Z'
|
||||
'@base-ui/react@1.4.1': '2026-04-20T12:24:35.520Z'
|
||||
'@chromatic-com/storybook@5.2.1': '2026-05-14T07:49:29.364Z'
|
||||
'@cucumber/cucumber@12.9.0': '2026-05-15T16:02:12.674Z'
|
||||
'@egoist/tailwindcss-icons@1.9.2': '2026-01-31T10:48:44.594Z'
|
||||
|
||||
@ -63,7 +63,7 @@ catalog:
|
||||
'@amplitude/analytics-browser': 2.42.3
|
||||
'@amplitude/plugin-session-replay-browser': 1.30.4
|
||||
'@antfu/eslint-config': 9.0.0
|
||||
'@base-ui/react': 1.5.0
|
||||
'@base-ui/react': 1.4.1
|
||||
'@chromatic-com/storybook': 5.2.1
|
||||
'@cucumber/cucumber': 12.9.0
|
||||
'@egoist/tailwindcss-icons': 1.9.2
|
||||
|
||||
@ -167,7 +167,7 @@ The Dify community can be found on [Discord community], where you can ask questi
|
||||
[Storybook]: https://storybook.js.org
|
||||
[Vite+]: https://viteplus.dev
|
||||
[Vitest]: https://vitest.dev
|
||||
[index.spec.tsx]: ./app/components/base/action-button/__tests__/index.spec.tsx
|
||||
[index.spec.tsx]: ./app/components/base/radio/__tests__/index.spec.tsx
|
||||
[pnpm]: https://pnpm.io
|
||||
[vinext]: https://github.com/cloudflare/vinext
|
||||
[web/docs/test.md]: ./docs/test.md
|
||||
|
||||
@ -2,15 +2,11 @@
|
||||
import type { FC } from 'react'
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
@ -19,6 +15,7 @@ import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows
|
||||
import { Settings04 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { CuteRobot } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import { BubbleText } from '@/app/components/base/icons/src/vender/solid/education'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
import AgentSetting from '../agent/agent-setting'
|
||||
|
||||
type Props = {
|
||||
@ -38,26 +35,26 @@ type ItemProps = {
|
||||
isChecked: boolean
|
||||
description: string
|
||||
Icon: any
|
||||
onClick: (value: string) => void
|
||||
}
|
||||
|
||||
const SelectItem: FC<ItemProps> = ({ text, value, Icon, isChecked, description, disabled }) => {
|
||||
const SelectItem: FC<ItemProps> = ({ text, value, Icon, isChecked, description, onClick, disabled }) => {
|
||||
return (
|
||||
<FieldItem>
|
||||
<FieldLabel
|
||||
className={cn(disabled ? 'opacity-50' : 'cursor-pointer', isChecked ? 'border-2 border-indigo-600 shadow-sm' : 'border border-gray-100', 'mb-2 rounded-xl bg-gray-25 p-3 pr-4 hover:bg-gray-50')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-3 rounded-lg bg-indigo-50 p-1">
|
||||
<Icon className="size-4 text-indigo-600" />
|
||||
</div>
|
||||
<div className="text-sm/5 font-medium text-gray-900">{text}</div>
|
||||
<div
|
||||
className={cn(disabled ? 'opacity-50' : 'cursor-pointer', isChecked ? 'border-2 border-indigo-600 shadow-sm' : 'border border-gray-100', 'mb-2 rounded-xl bg-gray-25 p-3 pr-4 hover:bg-gray-50')}
|
||||
onClick={() => !disabled && onClick(value)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-3 rounded-lg bg-indigo-50 p-1">
|
||||
<Icon className="size-4 text-indigo-600" />
|
||||
</div>
|
||||
<Radio value={value} disabled={disabled} />
|
||||
<div className="text-sm/5 font-medium text-gray-900">{text}</div>
|
||||
</div>
|
||||
<div className="ml-9 text-xs leading-[18px] font-normal text-gray-500">{description}</div>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<Radio isChecked={isChecked} />
|
||||
</div>
|
||||
<div className="ml-9 text-xs leading-[18px] font-normal text-gray-500">{description}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -130,38 +127,25 @@ const AssistantTypePicker: FC<Props> = ({
|
||||
alignOffset={-2}
|
||||
popupClassName="relative left-0.5 w-[480px] rounded-xl border border-black/8 bg-white p-6 shadow-lg"
|
||||
>
|
||||
<FieldRoot name="assistant_type" className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup
|
||||
value={isAgent ? 'agent' : 'chat'}
|
||||
onValueChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="flex-col items-stretch gap-0"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="mb-2 py-0 text-sm/5 font-semibold text-gray-900">
|
||||
{t('assistantType.name', { ns: 'appDebug' })}
|
||||
</FieldsetLegend>
|
||||
<SelectItem
|
||||
Icon={BubbleText}
|
||||
value="chat"
|
||||
disabled={disabled}
|
||||
text={t('assistantType.chatAssistant.name', { ns: 'appDebug' })}
|
||||
description={t('assistantType.chatAssistant.description', { ns: 'appDebug' })}
|
||||
isChecked={!isAgent}
|
||||
/>
|
||||
<SelectItem
|
||||
Icon={CuteRobot}
|
||||
value="agent"
|
||||
disabled={disabled}
|
||||
text={t('assistantType.agentAssistant.name', { ns: 'appDebug' })}
|
||||
description={t('assistantType.agentAssistant.description', { ns: 'appDebug' })}
|
||||
isChecked={isAgent}
|
||||
/>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
<div className="mb-2 text-sm/5 font-semibold text-gray-900">{t('assistantType.name', { ns: 'appDebug' })}</div>
|
||||
<SelectItem
|
||||
Icon={BubbleText}
|
||||
value="chat"
|
||||
disabled={disabled}
|
||||
text={t('assistantType.chatAssistant.name', { ns: 'appDebug' })}
|
||||
description={t('assistantType.chatAssistant.description', { ns: 'appDebug' })}
|
||||
isChecked={!isAgent}
|
||||
onClick={handleChange}
|
||||
/>
|
||||
<SelectItem
|
||||
Icon={CuteRobot}
|
||||
value="agent"
|
||||
disabled={disabled}
|
||||
text={t('assistantType.agentAssistant.name', { ns: 'appDebug' })}
|
||||
description={t('assistantType.agentAssistant.description', { ns: 'appDebug' })}
|
||||
isChecked={isAgent}
|
||||
onClick={handleChange}
|
||||
/>
|
||||
{!disabled && agentConfigUI}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@ -17,20 +17,6 @@ vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
isCloudEdition: true,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.isCloudEdition
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mockApp: App = {
|
||||
can_trial: true,
|
||||
app: {
|
||||
@ -84,7 +70,6 @@ describe('AppCard', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.isCloudEdition = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@ -276,13 +261,6 @@ describe('AppCard', () => {
|
||||
app: mockApp,
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide try button outside cloud edition', () => {
|
||||
mockConfig.isCloudEdition = false
|
||||
renderWithProvider(<AppCard {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /explore\.appCard\.try/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard Accessibility', () => {
|
||||
|
||||
@ -4,13 +4,14 @@ import { PlusIcon } from '@heroicons/react/20/solid'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiInformation2Line } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import AppListContext from '@/context/app-list-context'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
|
||||
|
||||
type AppCardProps = {
|
||||
@ -26,7 +27,8 @@ const AppCard = ({
|
||||
}: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { app: appBasicInfo } = app
|
||||
const canViewApp = IS_CLOUD_EDITION
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
|
||||
const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel)
|
||||
const handleShowTryAppPanel = useCallback(() => {
|
||||
trackEvent('preview_template', {
|
||||
@ -67,21 +69,19 @@ const AppCard = ({
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
{(canCreate || canViewApp) && (
|
||||
{(canCreate || isTrialApp) && (
|
||||
<div className={cn('absolute right-0 bottom-0 left-0 hidden bg-linear-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', canCreate && canViewApp && 'grid-cols-2')}>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', canCreate && 'grid-cols-2')}>
|
||||
{canCreate && (
|
||||
<Button variant="primary" onClick={() => onCreate()}>
|
||||
<PlusIcon className="mr-1 size-4" />
|
||||
<span className="text-xs">{t('newApp.useTemplate', { ns: 'app' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
{canViewApp && (
|
||||
<Button onClick={handleShowTryAppPanel}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleShowTryAppPanel}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -162,18 +162,18 @@ describe('Filter', () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = vi.fn()
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ status: 'succeeded' })}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
const statusTrigger = screen.getByRole('combobox', { name: 'Success' })
|
||||
const statusChip = statusTrigger.parentElement!
|
||||
const clearButton = within(statusChip).getByRole('button', { name: 'common.operation.clear' })
|
||||
// Find the clear icon (div with group/clear class) in the status chip
|
||||
const clearIcon = container.querySelector('.group\\/clear')
|
||||
|
||||
await user.click(clearButton)
|
||||
expect(clearIcon)!.toBeInTheDocument()
|
||||
await user.click(clearIcon!)
|
||||
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'all',
|
||||
@ -235,24 +235,6 @@ describe('Filter', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply period chip sizing classes to trigger and panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams()}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
const periodTrigger = screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' })
|
||||
expect(periodTrigger).toHaveClass('min-w-[150px]')
|
||||
|
||||
await user.click(periodTrigger)
|
||||
const listbox = await screen.findByRole('listbox')
|
||||
expect(listbox.parentElement).toHaveClass('w-[270px]')
|
||||
})
|
||||
|
||||
it('should call setQueryParams when period is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = vi.fn()
|
||||
@ -284,15 +266,17 @@ describe('Filter', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const periodTrigger = screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' })
|
||||
const periodChip = periodTrigger.parentElement!
|
||||
const clearButton = within(periodChip).getByRole('button', { name: 'common.operation.clear' })
|
||||
// Find the period chip's clear button
|
||||
const periodChip = screen.getByText('appLog.filter.period.last7days').closest('div')
|
||||
const clearButton = periodChip?.querySelector('button[type="button"]')
|
||||
|
||||
await user.click(clearButton)
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'all',
|
||||
period: '9',
|
||||
})
|
||||
if (clearButton) {
|
||||
await user.click(clearButton)
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'all',
|
||||
period: '9',
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { Item } from '../index'
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Chip from '../index'
|
||||
|
||||
@ -28,39 +27,27 @@ describe('Chip', () => {
|
||||
|
||||
// Helper function to render Chip with default props
|
||||
const renderChip = (props: Partial<React.ComponentProps<typeof Chip>> = {}) => {
|
||||
const user = userEvent.setup()
|
||||
return {
|
||||
user,
|
||||
...render(
|
||||
<Chip
|
||||
value="all"
|
||||
items={items}
|
||||
onSelect={onSelect}
|
||||
onClear={onClear}
|
||||
{...props}
|
||||
/>,
|
||||
),
|
||||
}
|
||||
return render(
|
||||
<Chip
|
||||
value="all"
|
||||
items={items}
|
||||
onSelect={onSelect}
|
||||
onClear={onClear}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to get the trigger element
|
||||
const getTrigger = (container: HTMLElement) => {
|
||||
return container.querySelector('button[role="combobox"]') as HTMLElement | null
|
||||
return container.querySelector('button[aria-haspopup="menu"], [role="button"][aria-haspopup="menu"]') as HTMLElement | null
|
||||
}
|
||||
|
||||
// Helper function to open dropdown panel
|
||||
const openPanel = async (user: ReturnType<typeof userEvent.setup>, container: HTMLElement) => {
|
||||
const openPanel = (container: HTMLElement) => {
|
||||
const trigger = getTrigger(container)
|
||||
expect(trigger).toBeInTheDocument()
|
||||
await user.click(trigger!)
|
||||
return screen.findByRole('listbox')
|
||||
}
|
||||
|
||||
const expectPanelClosed = async (trigger: HTMLElement | null) => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(trigger).not.toHaveAttribute('data-popup-open')
|
||||
})
|
||||
if (trigger)
|
||||
fireEvent.click(trigger)
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -73,7 +60,7 @@ describe('Chip', () => {
|
||||
it('should display current selected item name', () => {
|
||||
renderChip({ value: 'active' })
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'Active' }))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Active'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display empty content when value does not match any item', () => {
|
||||
@ -99,13 +86,15 @@ describe('Chip', () => {
|
||||
onClear={onClear}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('combobox', { name: 'Archived' }))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Archived'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show left icon by default', () => {
|
||||
const { container } = renderChip()
|
||||
|
||||
expect(container.querySelector('.i-ri-filter-3-line')).toBeInTheDocument()
|
||||
// The filter icon should be visible
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide left icon when showLeftIcon is false', () => {
|
||||
@ -113,8 +102,10 @@ describe('Chip', () => {
|
||||
|
||||
// When showLeftIcon is false, there should be no filter icon before the text
|
||||
const trigger = getTrigger(document.body)
|
||||
expect(trigger?.querySelector('.i-ri-filter-3-line')).not.toBeInTheDocument()
|
||||
expect(trigger?.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
const icons = trigger?.querySelectorAll('svg')
|
||||
|
||||
// Should only have the arrow icon, not the filter icon
|
||||
expect(icons?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should render custom left icon', () => {
|
||||
@ -134,11 +125,11 @@ describe('Chip', () => {
|
||||
expect(chipElement)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom panelClassName to dropdown panel', async () => {
|
||||
it('should apply custom panelClassName to dropdown panel', () => {
|
||||
const customPanelClass = 'custom-panel-class'
|
||||
|
||||
const { container, user } = renderChip({ panelClassName: customPanelClass })
|
||||
await openPanel(user, container)
|
||||
const { container } = renderChip({ panelClassName: customPanelClass })
|
||||
openPanel(container)
|
||||
|
||||
// Panel is rendered in a portal, so check document.body
|
||||
const panel = document.body.querySelector(`.${customPanelClass}`)
|
||||
@ -147,90 +138,102 @@ describe('Chip', () => {
|
||||
})
|
||||
|
||||
describe('State Management', () => {
|
||||
it('should toggle dropdown panel on trigger click', async () => {
|
||||
const { container, user } = renderChip()
|
||||
it('should toggle dropdown panel on trigger click', () => {
|
||||
const { container } = renderChip()
|
||||
|
||||
// Initially closed - check aria-expanded attribute
|
||||
const trigger = getTrigger(container)
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(trigger).not.toHaveAttribute('data-popup-open')
|
||||
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
const listbox = await openPanel(user, container)
|
||||
expect(trigger).toHaveAttribute('data-popup-open')
|
||||
expect(within(listbox).getByRole('option', { name: 'All Items' })).toBeInTheDocument()
|
||||
// Open panel
|
||||
openPanel(container)
|
||||
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
|
||||
// Panel items should be visible
|
||||
expect(screen.getAllByText('All Items').length).toBeGreaterThan(1)
|
||||
|
||||
// Close panel
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
await expectPanelClosed(trigger)
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('should close panel after selecting an item', async () => {
|
||||
const { container, user } = renderChip()
|
||||
it('should close panel after selecting an item', () => {
|
||||
const { container } = renderChip()
|
||||
|
||||
const listbox = await openPanel(user, container)
|
||||
openPanel(container)
|
||||
const trigger = getTrigger(container)
|
||||
expect(trigger).toHaveAttribute('data-popup-open')
|
||||
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
await user.click(within(listbox).getByRole('option', { name: 'Active' }))
|
||||
// Click on an item in the dropdown panel
|
||||
const activeItems = screen.getAllByText('Active')
|
||||
// The second one should be in the dropdown
|
||||
fireEvent.click(activeItems[activeItems.length - 1]!)
|
||||
|
||||
await expectPanelClosed(trigger)
|
||||
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handlers', () => {
|
||||
it('should call onSelect with correct item when item is clicked', async () => {
|
||||
const { container, user } = renderChip()
|
||||
it('should call onSelect with correct item when item is clicked', () => {
|
||||
const { container } = renderChip()
|
||||
|
||||
const listbox = await openPanel(user, container)
|
||||
await user.click(within(listbox).getByRole('option', { name: 'Active' }))
|
||||
openPanel(container)
|
||||
// Get all "Active" texts and click the one in the dropdown (should be the last one)
|
||||
const activeItems = screen.getAllByText('Active')
|
||||
fireEvent.click(activeItems[activeItems.length - 1]!)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
expect(onSelect).toHaveBeenCalledWith(items[1])
|
||||
})
|
||||
|
||||
it('should call onClear when clear button is clicked', async () => {
|
||||
const { user } = renderChip({ value: 'active' })
|
||||
it('should call onClear when clear button is clicked', () => {
|
||||
const { container } = renderChip({ value: 'active' })
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
|
||||
const clearButton = container.querySelector('button[aria-label="common.operation.clear"]')
|
||||
|
||||
await user.click(clearButton)
|
||||
expect(clearButton)!.toBeInTheDocument()
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should stop event propagation when clear button is clicked', async () => {
|
||||
const { container, user } = renderChip({ value: 'active' })
|
||||
it('should stop event propagation when clear button is clicked', () => {
|
||||
const { container } = renderChip({ value: 'active' })
|
||||
|
||||
const trigger = getTrigger(container)
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(trigger).not.toHaveAttribute('data-popup-open')
|
||||
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
|
||||
const clearButton = container.querySelector('button[aria-label="common.operation.clear"]')
|
||||
|
||||
await user.click(clearButton)
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(trigger).not.toHaveAttribute('data-popup-open')
|
||||
// Panel should remain closed
|
||||
// Panel should remain closed
|
||||
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle multiple rapid clicks on trigger', async () => {
|
||||
const { container, user } = renderChip()
|
||||
it('should handle multiple rapid clicks on trigger', () => {
|
||||
const { container } = renderChip()
|
||||
|
||||
const trigger = getTrigger(container)
|
||||
|
||||
// Click 1: open
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
expect(await screen.findByRole('listbox')).toBeInTheDocument()
|
||||
expect(trigger).toHaveAttribute('data-popup-open')
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
// Click 2: close
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
await expectPanelClosed(trigger)
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
// Click 3: open again
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
expect(await screen.findByRole('listbox')).toBeInTheDocument()
|
||||
expect(trigger).toHaveAttribute('data-popup-open')
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@ -238,13 +241,17 @@ describe('Chip', () => {
|
||||
it('should show arrow down icon when no value is selected', () => {
|
||||
const { container } = renderChip({ value: '' })
|
||||
|
||||
expect(container.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
// Should have SVG icons (filter icon and arrow down icon)
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show clear button when value is selected', () => {
|
||||
const { container } = renderChip({ value: 'active' })
|
||||
|
||||
expect(container.querySelector('.i-ri-close-circle-fill')).toBeInTheDocument()
|
||||
// When value is selected, there should be an icon (the close icon)
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not show clear button when no value is selected', () => {
|
||||
@ -252,43 +259,57 @@ describe('Chip', () => {
|
||||
|
||||
const trigger = getTrigger(container)
|
||||
|
||||
expect(trigger?.querySelector('.i-ri-filter-3-line')).toBeInTheDocument()
|
||||
expect(trigger?.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-close-circle-fill')).not.toBeInTheDocument()
|
||||
// When value is empty, the trigger should only have 2 SVGs (filter icon + arrow)
|
||||
// When value is selected, it would have 2 SVGs (filter icon + close icon)
|
||||
const svgs = trigger?.querySelectorAll('svg')
|
||||
// Arrow icon should be present, close icon should not
|
||||
expect(svgs?.length).toBe(2)
|
||||
|
||||
// Verify onClear hasn't been called
|
||||
expect(onClear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show dropdown content only when panel is open', async () => {
|
||||
const { container, user } = renderChip()
|
||||
it('should show dropdown content only when panel is open', () => {
|
||||
const { container } = renderChip()
|
||||
|
||||
const trigger = getTrigger(container)
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(trigger).not.toHaveAttribute('data-popup-open')
|
||||
// Closed by default
|
||||
// Closed by default
|
||||
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
const listbox = await openPanel(user, container)
|
||||
expect(trigger).toHaveAttribute('data-popup-open')
|
||||
expect(within(listbox).getByRole('option', { name: 'All Items' })).toBeInTheDocument()
|
||||
openPanel(container)
|
||||
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
|
||||
// Items should be duplicated (once in trigger, once in panel)
|
||||
expect(screen.getAllByText('All Items').length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('should show check icon on selected item in dropdown', async () => {
|
||||
const { container, user } = renderChip({ value: 'active' })
|
||||
it('should show check icon on selected item in dropdown', () => {
|
||||
const { container } = renderChip({ value: 'active' })
|
||||
|
||||
const listbox = await openPanel(user, container)
|
||||
openPanel(container)
|
||||
|
||||
expect(within(listbox).getByRole('option', { name: 'Active' })).toHaveAttribute('aria-selected', 'true')
|
||||
// Find the dropdown panel items
|
||||
const allActiveTexts = screen.getAllByText('Active')
|
||||
// The dropdown item should be the last one
|
||||
const dropdownItem = allActiveTexts[allActiveTexts.length - 1]
|
||||
const parentContainer = dropdownItem!.parentElement
|
||||
|
||||
// The check icon should be a sibling within the parent
|
||||
const checkIcon = parentContainer?.querySelector('svg')
|
||||
expect(checkIcon)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all items in dropdown when open', async () => {
|
||||
const { container, user } = renderChip()
|
||||
it('should render all items in dropdown when open', () => {
|
||||
const { container } = renderChip()
|
||||
|
||||
const listbox = await openPanel(user, container)
|
||||
openPanel(container)
|
||||
|
||||
expect(within(listbox).getByRole('option', { name: 'All Items' })).toBeInTheDocument()
|
||||
expect(within(listbox).getByRole('option', { name: 'Active' })).toBeInTheDocument()
|
||||
expect(within(listbox).getByRole('option', { name: 'Archived' })).toBeInTheDocument()
|
||||
// Each item should appear at least twice (once in potential selected state, once in dropdown)
|
||||
// Use getAllByText to handle multiple occurrences
|
||||
expect(screen.getAllByText('All Items').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Active').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Archived').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@ -309,65 +330,56 @@ describe('Chip', () => {
|
||||
|
||||
// The trigger should not display any item name text
|
||||
expect(trigger?.textContent?.trim()).toBeFalsy()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.clear' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow selecting already selected item', async () => {
|
||||
const { container, user } = renderChip({ value: 'active' })
|
||||
it('should allow selecting already selected item', () => {
|
||||
const { container } = renderChip({ value: 'active' })
|
||||
|
||||
const listbox = await openPanel(user, container)
|
||||
openPanel(container)
|
||||
|
||||
await user.click(within(listbox).getByRole('option', { name: 'Active' }))
|
||||
// Click on the already selected item in the dropdown
|
||||
const activeItems = screen.getAllByText('Active')
|
||||
fireEvent.click(activeItems[activeItems.length - 1]!)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
expect(onSelect).toHaveBeenCalledWith(items[1])
|
||||
})
|
||||
|
||||
it('should handle numeric values', async () => {
|
||||
it('should handle numeric values', () => {
|
||||
const numericItems: Item[] = [
|
||||
{ value: 1, name: 'First' },
|
||||
{ value: 2, name: 'Second' },
|
||||
{ value: 3, name: 'Third' },
|
||||
]
|
||||
|
||||
const { container, user } = renderChip({ value: 2, items: numericItems })
|
||||
const { container } = renderChip({ value: 2, items: numericItems })
|
||||
|
||||
expect(screen.getByText('Second'))!.toBeInTheDocument()
|
||||
|
||||
// Open panel and select Third
|
||||
const listbox = await openPanel(user, container)
|
||||
openPanel(container)
|
||||
|
||||
await user.click(within(listbox).getByRole('option', { name: 'Third' }))
|
||||
const thirdItems = screen.getAllByText('Third')
|
||||
fireEvent.click(thirdItems[thirdItems.length - 1]!)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(numericItems[2])
|
||||
})
|
||||
|
||||
it('should treat numeric zero as a selected value', () => {
|
||||
const numericItems: Item[] = [
|
||||
{ value: 0, name: 'Zero' },
|
||||
{ value: 1, name: 'One' },
|
||||
]
|
||||
|
||||
renderChip({ value: 0, items: numericItems })
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'Zero' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle items with additional properties', async () => {
|
||||
it('should handle items with additional properties', () => {
|
||||
const itemsWithExtra: Item[] = [
|
||||
{ value: 'a', name: 'Item A', customProp: 'extra1' },
|
||||
{ value: 'b', name: 'Item B', customProp: 'extra2' },
|
||||
]
|
||||
|
||||
const { container, user } = renderChip({ value: 'a', items: itemsWithExtra })
|
||||
const { container } = renderChip({ value: 'a', items: itemsWithExtra })
|
||||
|
||||
expect(screen.getByText('Item A'))!.toBeInTheDocument()
|
||||
|
||||
// Open panel and select Item B
|
||||
const listbox = await openPanel(user, container)
|
||||
openPanel(container)
|
||||
|
||||
await user.click(within(listbox).getByRole('option', { name: 'Item B' }))
|
||||
const itemBs = screen.getAllByText('Item B')
|
||||
fireEvent.click(itemBs[itemBs.length - 1]!)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(itemsWithExtra[1])
|
||||
})
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectTrigger,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { RiArrowDownSLine, RiCheckLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ItemValue = number | string
|
||||
@ -39,82 +40,79 @@ function Chip<T extends ItemValue>({
|
||||
onClear,
|
||||
}: Props<T>) {
|
||||
const { t } = useTranslation()
|
||||
const selectedItem = items.find(item => Object.is(item.value, value))
|
||||
const triggerContent = selectedItem?.name || ''
|
||||
const hasValue = selectedItem !== undefined && value !== ''
|
||||
const triggerContent = useMemo(() => {
|
||||
return items.find(item => item.value === value)?.name || ''
|
||||
}, [items, value])
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedItem?.value ?? null}
|
||||
itemToStringLabel={(itemValue: T) => items.find(item => Object.is(item.value, itemValue))?.name ?? ''}
|
||||
itemToStringValue={itemValue => String(itemValue)}
|
||||
onValueChange={(nextValue) => {
|
||||
if (nextValue === null)
|
||||
return
|
||||
const selected = items.find(item => Object.is(item.value, nextValue))
|
||||
if (selected)
|
||||
onSelect(selected)
|
||||
}}
|
||||
>
|
||||
<div className="relative w-fit max-w-full">
|
||||
<SelectTrigger
|
||||
aria-label={triggerContent || t('placeholder.select', { ns: 'common' })}
|
||||
<DropdownMenu>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'h-auto min-h-8 w-fit max-w-full cursor-pointer items-center rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt! data-popup-open:hover:bg-state-base-hover-alt [&>*:last-child]:hidden',
|
||||
hasValue && 'border-components-button-secondary-border! bg-components-button-secondary-bg! pr-6 shadow-xs hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover! data-popup-open:border-components-button-secondary-border-hover! data-popup-open:bg-components-button-secondary-bg-hover! data-popup-open:hover:border-components-button-secondary-border-hover data-popup-open:hover:bg-components-button-secondary-bg-hover!',
|
||||
'flex min-h-8 cursor-pointer items-center rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
|
||||
!value && 'has-data-popup-open:bg-state-base-hover-alt! has-data-popup-open:hover:bg-state-base-hover-alt',
|
||||
!!value && 'border-components-button-secondary-border! bg-components-button-secondary-bg! shadow-xs hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover! has-data-popup-open:border-components-button-secondary-border-hover! has-data-popup-open:bg-components-button-secondary-bg-hover! has-data-popup-open:hover:border-components-button-secondary-border-hover has-data-popup-open:hover:bg-components-button-secondary-bg-hover!',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 grow items-center gap-0 text-left">
|
||||
<DropdownMenuTrigger className="flex min-w-0 grow items-center border-none bg-transparent p-0 text-left">
|
||||
{showLeftIcon && (
|
||||
<span className="p-0.5">
|
||||
<div className="p-0.5">
|
||||
{leftIcon || (
|
||||
<span aria-hidden className={cn('i-ri-filter-3-line block size-4 text-text-tertiary', hasValue && 'text-text-secondary')} />
|
||||
<RiFilter3Line className={cn('size-4 text-text-tertiary', !!value && 'text-text-secondary')} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="flex grow items-center gap-0.5 first-line:p-1">
|
||||
<span className={cn('system-sm-regular text-text-tertiary', hasValue && 'text-text-secondary')}>
|
||||
<div className="flex grow items-center gap-0.5 first-line:p-1">
|
||||
<div className={cn('system-sm-regular text-text-tertiary', !!value && 'text-text-secondary')}>
|
||||
{triggerContent}
|
||||
</span>
|
||||
</span>
|
||||
{!hasValue && <span aria-hidden className="i-ri-arrow-down-s-line block size-4 text-text-tertiary" />}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
{hasValue && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="group/clear absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer border-none bg-transparent p-px"
|
||||
onClick={onClear}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill block size-3.5 text-text-quaternary group-hover/clear:text-text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
<SelectContent
|
||||
</div>
|
||||
</div>
|
||||
{!value && <RiArrowDownSLine className="size-4 text-text-tertiary" />}
|
||||
</DropdownMenuTrigger>
|
||||
{!!value && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="group/clear cursor-pointer border-none bg-transparent p-px"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClear()
|
||||
}}
|
||||
>
|
||||
<RiCloseCircleFill className="size-3.5 text-text-quaternary group-hover/clear:text-text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName={cn(
|
||||
'relative w-[240px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-0 text-sm text-text-secondary shadow-lg outline-hidden backdrop-blur-[5px] focus:outline-hidden focus-visible:outline-hidden',
|
||||
panelClassName,
|
||||
)}
|
||||
listClassName="max-h-72 p-1"
|
||||
popupClassName={cn('relative w-[240px] rounded-xl border-[0.5px] bg-components-panel-bg-blur p-0', panelClassName)}
|
||||
>
|
||||
{items.map(item => (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
className="mx-1 gap-2 rounded-lg px-2 py-[6px] pl-3 select-none"
|
||||
>
|
||||
<SelectItemText className="mr-0 px-0">
|
||||
<span title={item.name} className="block truncate system-sm-medium text-text-secondary">{item.name}</span>
|
||||
</SelectItemText>
|
||||
<SelectItemIndicator className="text-util-colors-blue-light-blue-light-600" />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={(nextValue) => {
|
||||
const selected = items.find(item => item.value === nextValue)
|
||||
if (selected)
|
||||
onSelect(selected)
|
||||
}}
|
||||
className="max-h-72 overflow-auto p-1"
|
||||
>
|
||||
{items.map(item => (
|
||||
<DropdownMenuRadioItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
closeOnClick
|
||||
className="gap-2 rounded-lg px-2 py-[6px] pl-3"
|
||||
>
|
||||
<div title={item.name} className="grow truncate system-sm-medium text-text-secondary">{item.name}</div>
|
||||
{value === item.value && <RiCheckLine className="size-4 shrink-0 text-util-colors-blue-light-blue-light-600" />}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</div>
|
||||
</Select>
|
||||
</DropdownMenu>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,13 +8,10 @@ import type {
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
@ -148,30 +145,22 @@ const FollowUpSettingModal = ({
|
||||
hideDebugWithMultipleModel
|
||||
/>
|
||||
</div>
|
||||
<FieldRoot name="follow_up_prompt_mode" className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<PromptMode>
|
||||
className="flex-col items-stretch gap-3"
|
||||
value={promptMode}
|
||||
onValueChange={setPromptMode}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="mb-1.5 py-0 system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })}
|
||||
</FieldsetLegend>
|
||||
<RadioRoot
|
||||
value={PROMPT_MODE.default}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
<div>
|
||||
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<div className="space-y-3" role="radiogroup" aria-label={t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' }) || ''}>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={promptMode === PROMPT_MODE.default}
|
||||
className={cn(
|
||||
'w-full rounded-xl border p-4 text-left transition-colors',
|
||||
promptMode === PROMPT_MODE.default
|
||||
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
|
||||
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => setPromptMode(PROMPT_MODE.default)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
@ -182,7 +171,9 @@ const FollowUpSettingModal = ({
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOptionDescription', { ns: 'appDebug' })}
|
||||
</div>
|
||||
</div>
|
||||
<RadioControl aria-hidden="true" />
|
||||
<div aria-hidden="true">
|
||||
<Radio isChecked={promptMode === PROMPT_MODE.default} />
|
||||
</div>
|
||||
</div>
|
||||
{promptMode === PROMPT_MODE.default && (
|
||||
<div className="mt-3 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-3 py-2">
|
||||
@ -191,18 +182,18 @@ const FollowUpSettingModal = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</RadioRoot>
|
||||
<RadioRoot
|
||||
value={PROMPT_MODE.custom}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={promptMode === PROMPT_MODE.custom}
|
||||
className={cn(
|
||||
'w-full rounded-xl border p-4 text-left transition-colors',
|
||||
promptMode === PROMPT_MODE.custom
|
||||
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
|
||||
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => setPromptMode(PROMPT_MODE.custom)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
@ -213,7 +204,9 @@ const FollowUpSettingModal = ({
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOptionDescription', { ns: 'appDebug' })}
|
||||
</div>
|
||||
</div>
|
||||
<RadioControl aria-hidden="true" />
|
||||
<div aria-hidden="true">
|
||||
<Radio isChecked={promptMode === PROMPT_MODE.custom} />
|
||||
</div>
|
||||
</div>
|
||||
{promptMode === PROMPT_MODE.custom && (
|
||||
<Textarea
|
||||
@ -224,9 +217,9 @@ const FollowUpSettingModal = ({
|
||||
placeholder={t('feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', { ns: 'appDebug' }) || ''}
|
||||
/>
|
||||
)}
|
||||
</RadioRoot>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button onClick={onCancel}>
|
||||
|
||||
@ -174,11 +174,6 @@ describe('BaseField', () => {
|
||||
onChange,
|
||||
})
|
||||
|
||||
const radioGroup = screen.getByRole('radiogroup', { name: 'Visibility' })
|
||||
expect(radioGroup).toHaveClass('flex')
|
||||
expect(radioGroup).toHaveClass('items-center')
|
||||
expect(radioGroup).not.toHaveClass('flex-col')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Private'))
|
||||
})
|
||||
@ -454,11 +449,6 @@ describe('BaseField', () => {
|
||||
expect(screen.getByText('O1')).toBeInTheDocument()
|
||||
expect(screen.getByText('O2')).toBeInTheDocument()
|
||||
expect(screen.getByText('O3')).toBeInTheDocument()
|
||||
|
||||
const radioGroup = screen.getByRole('radiogroup', { name: 'Vertical' })
|
||||
expect(radioGroup).toHaveClass('flex-col')
|
||||
expect(radioGroup).toHaveClass('items-stretch')
|
||||
expect(radioGroup).not.toHaveClass('items-center')
|
||||
})
|
||||
|
||||
it('should render radio UI when showRadioUI is true', () => {
|
||||
@ -473,7 +463,7 @@ describe('BaseField', () => {
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument()
|
||||
expect(screen.getByRole('radiogroup', { name: 'UI Radio' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('radio-group')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply disabled styles', () => {
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
import type { AnyFieldApi } from '@tanstack/react-form'
|
||||
import type { FieldState, FormSchema, TypeWithI18N } from '@/app/components/base/form/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -26,6 +22,8 @@ import { CheckboxList } from '@/app/components/base/checkbox-list'
|
||||
import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import RadioE from '@/app/components/base/radio/ui'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
|
||||
|
||||
@ -182,8 +180,6 @@ const BaseField = ({
|
||||
}, [options, renderI18nObject, watchedValues])
|
||||
|
||||
const value = useStore(field.form.store, s => s.values[field.name])
|
||||
const stringValue = typeof value === 'string' ? value : undefined
|
||||
const booleanValue = typeof value === 'boolean' ? value : undefined
|
||||
|
||||
const { data: dynamicOptionsData, isLoading: isDynamicOptionsLoading, error: dynamicOptionsError } = useTriggerPluginDynamicOptions(
|
||||
dynamicSelectParams || {
|
||||
@ -392,82 +388,49 @@ const BaseField = ({
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.radio && (
|
||||
<FieldRoot name={name} className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup
|
||||
value={stringValue}
|
||||
onValueChange={optionValue => handleChange(optionValue)}
|
||||
<div
|
||||
className={cn(
|
||||
memorizedOptions.length < 3 ? 'flex items-center space-x-2' : 'space-y-2',
|
||||
)}
|
||||
data-testid="radio-group"
|
||||
>
|
||||
{
|
||||
memorizedOptions.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
memorizedOptions.length >= 3 && 'flex-col items-stretch',
|
||||
'hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-1 grow cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary',
|
||||
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
inputClassName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="sr-only">{translatedLabel || name}</FieldsetLegend>
|
||||
{
|
||||
memorizedOptions.map(option => (
|
||||
<FieldItem key={option.value} className={cn('min-w-0', memorizedOptions.length < 3 && 'flex-1 grow')}>
|
||||
<FieldLabel
|
||||
className={cn(
|
||||
'hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary',
|
||||
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
inputClassName,
|
||||
)}
|
||||
>
|
||||
{
|
||||
formSchema.showRadioUI && (
|
||||
<Radio
|
||||
className="mr-2"
|
||||
value={option.value}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{!formSchema.showRadioUI && (
|
||||
<Radio
|
||||
className="sr-only"
|
||||
value={option.value}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{option.label}
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))
|
||||
}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
onClick={() => !disabled && handleChange(option.value)}
|
||||
>
|
||||
{
|
||||
formSchema.showRadioUI && (
|
||||
<RadioE
|
||||
className="mr-2"
|
||||
isChecked={value === option.value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{option.label}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.boolean && (
|
||||
<FieldRoot name={name} className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<boolean>
|
||||
className="w-fit gap-3"
|
||||
value={booleanValue}
|
||||
onValueChange={v => field.handleChange(v)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="sr-only">{translatedLabel || name}</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={true} />
|
||||
True
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={false} />
|
||||
False
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
<Radio.Group
|
||||
className="flex w-fit items-center"
|
||||
value={value}
|
||||
onChange={v => field.handleChange(v)}
|
||||
>
|
||||
<Radio value={true} className="mr-1!">True</Radio>
|
||||
<Radio value={false}>False</Radio>
|
||||
</Radio.Group>
|
||||
)
|
||||
}
|
||||
{fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && (
|
||||
|
||||
@ -22,11 +22,6 @@ export const inputVariants = cva(
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* @deprecated Use `@langgenius/dify-ui/input` for primitive inputs and
|
||||
* `@langgenius/dify-ui/field` for form composition. Search inputs should use
|
||||
* a dedicated composition built on the primitive input.
|
||||
*/
|
||||
export type InputProps = {
|
||||
showLeftIcon?: boolean
|
||||
showClearIcon?: boolean
|
||||
@ -41,11 +36,6 @@ export type InputProps = {
|
||||
|
||||
const removeLeadingZeros = (value: string) => value.replace(/^(-?)0+(?=\d)/, '$1')
|
||||
|
||||
/**
|
||||
* @deprecated Use `@langgenius/dify-ui/input` for primitive inputs and
|
||||
* `@langgenius/dify-ui/field` for form composition. Search inputs should use
|
||||
* a dedicated composition built on the primitive input.
|
||||
*/
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({
|
||||
size,
|
||||
disabled,
|
||||
|
||||
@ -2,11 +2,11 @@ import type { CSSProperties } from 'react'
|
||||
import type { NotionPageRow as NotionPageRowData, NotionPageSelectionMode } from './types'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NotionIcon from '@/app/components/base/notion-icon'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
|
||||
type NotionPageRowProps = {
|
||||
checked: boolean
|
||||
@ -58,9 +58,9 @@ const NotionPageRow = ({
|
||||
: (
|
||||
<Radio
|
||||
className="mr-2 shrink-0"
|
||||
value={pageId}
|
||||
isChecked={checked}
|
||||
disabled={disabled}
|
||||
aria-label={row.page.page_name}
|
||||
onCheck={() => onSelect(pageId)}
|
||||
/>
|
||||
)}
|
||||
{!searchValue && row.hasChild && (
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import type { NotionPageRow, NotionPageSelectionMode } from './types'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PageRow from './page-row'
|
||||
|
||||
type VirtualPageListProps = {
|
||||
@ -34,7 +32,6 @@ const VirtualPageList = ({
|
||||
selectionMode,
|
||||
showPreview,
|
||||
}: VirtualPageListProps) => {
|
||||
const { t } = useTranslation()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
@ -47,35 +44,6 @@ const VirtualPageList = ({
|
||||
})
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems()
|
||||
const selectedPageId = checkedIds.values().next().value
|
||||
const rowNodes = virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]!
|
||||
const pageId = row.page.page_id
|
||||
|
||||
return (
|
||||
<PageRow
|
||||
key={pageId}
|
||||
checked={checkedIds.has(pageId)}
|
||||
disabled={disabledValue.has(pageId)}
|
||||
isPreviewed={previewPageId === pageId}
|
||||
onPreview={onPreview}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
row={row}
|
||||
searchValue={searchValue}
|
||||
selectionMode={selectionMode}
|
||||
showPreview={showPreview}
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
left: 8,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
width: 'calc(100% - 16px)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -89,18 +57,34 @@ const VirtualPageList = ({
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{selectionMode === 'single'
|
||||
? (
|
||||
<RadioGroup
|
||||
aria-label={t('dataSource.notion.selector.headerTitle', { ns: 'common' })}
|
||||
value={selectedPageId}
|
||||
onValueChange={onSelect}
|
||||
className="contents"
|
||||
>
|
||||
{rowNodes}
|
||||
</RadioGroup>
|
||||
)
|
||||
: rowNodes}
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]!
|
||||
const pageId = row!.page.page_id
|
||||
|
||||
return (
|
||||
<PageRow
|
||||
key={pageId}
|
||||
checked={checkedIds.has(pageId)}
|
||||
disabled={disabledValue.has(pageId)}
|
||||
isPreviewed={previewPageId === pageId}
|
||||
onPreview={onPreview}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
row={row}
|
||||
searchValue={searchValue}
|
||||
selectionMode={selectionMode}
|
||||
showPreview={showPreview}
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
left: 8,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
width: 'calc(100% - 16px)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,100 +1,137 @@
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
// index.spec.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import RadioCard from '../index'
|
||||
|
||||
function renderSelectableCard({
|
||||
selected = false,
|
||||
onValueChange = vi.fn(),
|
||||
}: {
|
||||
selected?: boolean
|
||||
onValueChange?: (value: string) => void
|
||||
} = {}) {
|
||||
render(
|
||||
<RadioGroup
|
||||
aria-label="Options"
|
||||
value={selected ? 'card' : undefined}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
describe('RadioCard', () => {
|
||||
it('renders icon, title and description', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
value="card"
|
||||
icon={<span data-testid="icon">ICON</span>}
|
||||
title="Card Title"
|
||||
description="Some description"
|
||||
chosenConfig={<div>Config</div>}
|
||||
/>
|
||||
</RadioGroup>,
|
||||
)
|
||||
|
||||
return {
|
||||
radio: screen.getByRole('radio', { name: /Card Title/ }),
|
||||
onValueChange,
|
||||
}
|
||||
}
|
||||
|
||||
describe('RadioCard', () => {
|
||||
it('should render selectable card content and expose radio semantics', () => {
|
||||
const { radio } = renderSelectableCard()
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Card Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Some description')).toBeInTheDocument()
|
||||
expect(radio).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('should emit RadioGroup value change when selected', async () => {
|
||||
it('calls onChosen when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onValueChange = vi.fn()
|
||||
const { radio } = renderSelectableCard({ onValueChange })
|
||||
const onChosen = vi.fn()
|
||||
|
||||
await user.click(radio)
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('card', expect.any(Object))
|
||||
})
|
||||
|
||||
it('should show selected styles and configuration when checked', () => {
|
||||
const { radio } = renderSelectableCard({ selected: true })
|
||||
|
||||
expect(radio).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByText('Config')).toBeInTheDocument()
|
||||
expect(radio.parentElement).toHaveClass('has-[[data-checked]]:border-[1.5px]')
|
||||
expect(radio.parentElement).toHaveClass('has-[[data-checked]]:bg-components-option-card-option-selected-bg')
|
||||
})
|
||||
|
||||
it('should apply custom className to the card root and config wrapper', () => {
|
||||
render(
|
||||
<RadioGroup aria-label="Options" value="card">
|
||||
<RadioCard
|
||||
value="card"
|
||||
icon={<span>i</span>}
|
||||
title="Custom"
|
||||
description="desc"
|
||||
className="my-root-class"
|
||||
chosenConfig={<div>cfg</div>}
|
||||
chosenConfigWrapClassName="my-config-wrap"
|
||||
/>
|
||||
</RadioGroup>,
|
||||
)
|
||||
|
||||
const radio = screen.getByRole('radio', { name: /Custom/ })
|
||||
expect(radio.parentElement).toHaveClass('my-root-class')
|
||||
expect(screen.getByText('cfg').parentElement).toHaveClass('my-config-wrap')
|
||||
})
|
||||
|
||||
it('should render noRadio card as static content without radio role', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
noRadio
|
||||
icon={<span>i</span>}
|
||||
title="No Radio"
|
||||
title="Clickable"
|
||||
description="desc"
|
||||
chosenConfig={<div>Static config</div>}
|
||||
onChosen={onChosen}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('No Radio')).toBeInTheDocument()
|
||||
expect(screen.getByText('Static config')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: /Clickable/ }))
|
||||
expect(onChosen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides radio element when noRadio is true and still shows chosen-config area (wrapper)', () => {
|
||||
const { container } = render(
|
||||
<RadioCard
|
||||
icon={<span>i</span>}
|
||||
title="No Radio"
|
||||
description="desc"
|
||||
noRadio
|
||||
/>,
|
||||
)
|
||||
|
||||
const radioWrapper = container.querySelector('.absolute.right-3.top-3')
|
||||
expect(radioWrapper).toBeNull()
|
||||
|
||||
// chosen-config area should appear because noRadio true triggers the block
|
||||
const chosenArea = container.querySelector('.mt-2')
|
||||
expect(chosenArea).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows radio checked styles when isChosen and shows chosenConfig', () => {
|
||||
const { container } = render(
|
||||
<RadioCard
|
||||
icon={<span>i</span>}
|
||||
title="Chosen"
|
||||
description="desc"
|
||||
isChosen
|
||||
chosenConfig={<div data-testid="chosen-config">config</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
// radio absolute wrapper exists
|
||||
const radioWrapper = container.querySelector('.absolute.right-3.top-3')
|
||||
expect(radioWrapper).toBeTruthy()
|
||||
|
||||
// inner circle div should have checked fragment in class list
|
||||
const inner = radioWrapper?.querySelector('div')
|
||||
expect(inner).toBeTruthy()
|
||||
expect(inner?.className).toContain('border-components-radio-border-checked')
|
||||
|
||||
// chosenConfig rendered
|
||||
expect(screen.getByTestId('chosen-config')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className to root and merges chosenConfigWrapClassName', () => {
|
||||
const { container } = render(
|
||||
<RadioCard
|
||||
icon={<span>i</span>}
|
||||
title="Custom"
|
||||
description="desc"
|
||||
className="my-root-class"
|
||||
isChosen
|
||||
chosenConfig={<div>cfg</div>}
|
||||
chosenConfigWrapClassName="my-config-wrap"
|
||||
/>,
|
||||
)
|
||||
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root).toBeTruthy()
|
||||
expect(root.className).toContain('my-root-class')
|
||||
expect(root.className).toContain('border-[1.5px]')
|
||||
expect(root.className).toContain('bg-components-option-card-option-selected-bg')
|
||||
|
||||
const chosenWrap = container.querySelector('.mt-2 .my-config-wrap')
|
||||
expect(chosenWrap).toBeTruthy()
|
||||
expect(chosenWrap?.textContent).toBe('cfg')
|
||||
})
|
||||
|
||||
it('does not render radio when noRadio true and still allows clicking on whole card', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChosen = vi.fn()
|
||||
|
||||
const { container } = render(
|
||||
<RadioCard
|
||||
icon={<span>i</span>}
|
||||
title="ClickNoRadio"
|
||||
description="desc"
|
||||
noRadio
|
||||
onChosen={onChosen}
|
||||
/>,
|
||||
)
|
||||
|
||||
// click title should trigger onChosen
|
||||
await user.click(screen.getByRole('button', { name: /ClickNoRadio/ }))
|
||||
expect(onChosen).toHaveBeenCalledTimes(1)
|
||||
|
||||
// radio area should be absent
|
||||
expect(container.querySelector('.absolute.right-3.top-3')).toBeNull()
|
||||
})
|
||||
|
||||
it('memo export renders correctly', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
icon={<span>i</span>}
|
||||
title="Memo"
|
||||
description="desc"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Memo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { RiDatabase2Line, RiFileList3Line, RiRocketLine } from '@remixicon/react'
|
||||
import { RiCloudLine, RiCpuLine, RiDatabase2Line, RiLightbulbLine, RiRocketLine, RiShieldLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import RadioCard from '.'
|
||||
|
||||
@ -11,89 +10,519 @@ const meta = {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Radio card for rich single-choice options. Put selectable cards inside `RadioGroup`; the card passes radio props through to `RadioRoot` and uses `RadioControl` for the visual dot.',
|
||||
component: 'Radio card component for selecting options with rich content. Features icon, title, description, and optional configuration panel when selected.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
noRadio: true,
|
||||
icon: <RiRocketLine className="size-5 text-purple-600" />,
|
||||
title: 'Standard',
|
||||
description: 'Balanced defaults for most retrieval workflows.',
|
||||
argTypes: {
|
||||
icon: {
|
||||
description: 'Icon element to display',
|
||||
},
|
||||
iconBgClassName: {
|
||||
control: 'text',
|
||||
description: 'Background color class for icon container',
|
||||
},
|
||||
title: {
|
||||
control: 'text',
|
||||
description: 'Card title',
|
||||
},
|
||||
description: {
|
||||
control: 'text',
|
||||
description: 'Card description',
|
||||
},
|
||||
isChosen: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the card is selected',
|
||||
},
|
||||
noRadio: {
|
||||
control: 'boolean',
|
||||
description: 'Hide the radio button indicator',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof RadioCard>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function SelectableCardsDemo() {
|
||||
// Single card demo
|
||||
const RadioCardDemo = (args: any) => {
|
||||
const [isChosen, setIsChosen] = useState(args.isChosen || false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<RadioCard
|
||||
{...args}
|
||||
isChosen={isChosen}
|
||||
onChosen={() => setIsChosen(!isChosen)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <RadioCardDemo {...args} />,
|
||||
args: {
|
||||
icon: <RiRocketLine className="size-5 text-purple-600" />,
|
||||
iconBgClassName: 'bg-purple-100',
|
||||
title: 'Quick Start',
|
||||
description: 'Get started quickly with default settings',
|
||||
isChosen: false,
|
||||
noRadio: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Selected state
|
||||
export const Selected: Story = {
|
||||
render: args => <RadioCardDemo {...args} />,
|
||||
args: {
|
||||
icon: <RiRocketLine className="size-5 text-purple-600" />,
|
||||
iconBgClassName: 'bg-purple-100',
|
||||
title: 'Quick Start',
|
||||
description: 'Get started quickly with default settings',
|
||||
isChosen: true,
|
||||
noRadio: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Without radio indicator
|
||||
export const NoRadio: Story = {
|
||||
render: args => <RadioCardDemo {...args} />,
|
||||
args: {
|
||||
icon: <RiRocketLine className="size-5 text-purple-600" />,
|
||||
iconBgClassName: 'bg-purple-100',
|
||||
title: 'Information Card',
|
||||
description: 'Card without radio indicator',
|
||||
noRadio: true,
|
||||
},
|
||||
}
|
||||
|
||||
// With configuration panel
|
||||
const WithConfigurationDemo = () => {
|
||||
const [isChosen, setIsChosen] = useState(true)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<RadioCard
|
||||
icon={<RiDatabase2Line className="size-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="Database Storage"
|
||||
description="Store data in a managed database"
|
||||
isChosen={isChosen}
|
||||
onChosen={() => setIsChosen(!isChosen)}
|
||||
chosenConfig={(
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600">Region:</label>
|
||||
<select className="rounded-sm border border-gray-300 px-2 py-1 text-xs">
|
||||
<option>US East</option>
|
||||
<option>EU West</option>
|
||||
<option>Asia Pacific</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600">Size:</label>
|
||||
<select className="rounded-sm border border-gray-300 px-2 py-1 text-xs">
|
||||
<option>Small (10GB)</option>
|
||||
<option>Medium (50GB)</option>
|
||||
<option>Large (100GB)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithConfiguration: Story = {
|
||||
render: () => <WithConfigurationDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Multiple cards selection
|
||||
const MultipleCardsDemo = () => {
|
||||
const [selected, setSelected] = useState('standard')
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'standard',
|
||||
icon: <RiRocketLine className="size-5 text-purple-600" />,
|
||||
iconBgClassName: 'bg-purple-100',
|
||||
iconBg: 'bg-purple-100',
|
||||
title: 'Standard',
|
||||
description: 'Balanced defaults for most retrieval workflows.',
|
||||
description: 'Perfect for most use cases',
|
||||
},
|
||||
{
|
||||
value: 'advanced',
|
||||
icon: <RiDatabase2Line className="size-5 text-blue-600" />,
|
||||
iconBgClassName: 'bg-blue-100',
|
||||
icon: <RiCpuLine className="size-5 text-blue-600" />,
|
||||
iconBg: 'bg-blue-100',
|
||||
title: 'Advanced',
|
||||
description: 'Expose extra controls when this option is selected.',
|
||||
chosenConfig: (
|
||||
<div className="rounded-lg bg-components-panel-bg-blur p-3 system-xs-regular text-text-tertiary">
|
||||
Additional configuration appears below the selected card.
|
||||
</div>
|
||||
),
|
||||
description: 'More features and customization',
|
||||
},
|
||||
{
|
||||
value: 'enterprise',
|
||||
icon: <RiShieldLine className="size-5 text-green-600" />,
|
||||
iconBg: 'bg-green-100',
|
||||
title: 'Enterprise',
|
||||
description: 'Full features with premium support',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
aria-label="Retrieval mode"
|
||||
value={selected}
|
||||
onValueChange={setSelected}
|
||||
className="w-110 flex-col items-stretch gap-2"
|
||||
>
|
||||
<div style={{ width: '450px' }} className="space-y-3">
|
||||
{options.map(option => (
|
||||
<RadioCard
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
icon={option.icon}
|
||||
iconBgClassName={option.iconBgClassName}
|
||||
iconBgClassName={option.iconBg}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
chosenConfig={option.chosenConfig}
|
||||
isChosen={selected === option.value}
|
||||
onChosen={() => setSelected(option.value)}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
Selected:
|
||||
{' '}
|
||||
<span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SelectableCards: Story = {
|
||||
render: () => <SelectableCardsDemo />,
|
||||
export const MultipleCards: Story = {
|
||||
render: () => <MultipleCardsDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Cloud provider selection
|
||||
const CloudProviderSelectionDemo = () => {
|
||||
const [provider, setProvider] = useState('aws')
|
||||
const [region, setRegion] = useState('us-east-1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Select Cloud Provider</h3>
|
||||
<div className="space-y-3">
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="size-5 text-orange-600" />}
|
||||
iconBgClassName="bg-orange-100"
|
||||
title="Amazon Web Services"
|
||||
description="Industry-leading cloud infrastructure"
|
||||
isChosen={provider === 'aws'}
|
||||
onChosen={() => setProvider('aws')}
|
||||
chosenConfig={(
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-gray-700">Region</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
value={region}
|
||||
onChange={e => setRegion(e.target.value)}
|
||||
>
|
||||
<option value="us-east-1">US East (N. Virginia)</option>
|
||||
<option value="us-west-2">US West (Oregon)</option>
|
||||
<option value="eu-west-1">EU (Ireland)</option>
|
||||
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="size-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="Microsoft Azure"
|
||||
description="Enterprise-grade cloud platform"
|
||||
isChosen={provider === 'azure'}
|
||||
onChosen={() => setProvider('azure')}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="size-5 text-red-600" />}
|
||||
iconBgClassName="bg-red-100"
|
||||
title="Google Cloud Platform"
|
||||
description="Scalable and reliable infrastructure"
|
||||
isChosen={provider === 'gcp'}
|
||||
onChosen={() => setProvider('gcp')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StaticInfoCard: Story = {
|
||||
render: () => (
|
||||
<div className="w-110">
|
||||
export const CloudProviderSelection: Story = {
|
||||
render: () => <CloudProviderSelectionDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Deployment strategy
|
||||
const DeploymentStrategyDemo = () => {
|
||||
const [strategy, setStrategy] = useState('rolling')
|
||||
|
||||
return (
|
||||
<div style={{ width: '550px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">Deployment Strategy</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Choose how you want to deploy your application</p>
|
||||
<div className="space-y-3">
|
||||
<RadioCard
|
||||
icon={<RiRocketLine className="size-5 text-green-600" />}
|
||||
iconBgClassName="bg-green-100"
|
||||
title="Rolling Deployment"
|
||||
description="Gradually replace instances with zero downtime"
|
||||
isChosen={strategy === 'rolling'}
|
||||
onChosen={() => setStrategy('rolling')}
|
||||
chosenConfig={(
|
||||
<div className="rounded-lg bg-green-50 p-3 text-xs text-gray-700">
|
||||
✓ Recommended for production environments
|
||||
<br />
|
||||
✓ Minimal risk with automatic rollback
|
||||
<br />
|
||||
✓ Takes 5-10 minutes
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCpuLine className="size-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="Blue-Green Deployment"
|
||||
description="Switch between two identical environments"
|
||||
isChosen={strategy === 'blue-green'}
|
||||
onChosen={() => setStrategy('blue-green')}
|
||||
chosenConfig={(
|
||||
<div className="rounded-lg bg-blue-50 p-3 text-xs text-gray-700">
|
||||
✓ Instant rollback capability
|
||||
<br />
|
||||
✓ Requires double the resources
|
||||
<br />
|
||||
✓ Takes 2-5 minutes
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiLightbulbLine className="size-5 text-yellow-600" />}
|
||||
iconBgClassName="bg-yellow-100"
|
||||
title="Canary Deployment"
|
||||
description="Test with a small subset of users first"
|
||||
isChosen={strategy === 'canary'}
|
||||
onChosen={() => setStrategy('canary')}
|
||||
chosenConfig={(
|
||||
<div className="rounded-lg bg-yellow-50 p-3 text-xs text-gray-700">
|
||||
✓ Test changes with real traffic
|
||||
<br />
|
||||
✓ Gradual rollout reduces risk
|
||||
<br />
|
||||
✓ Takes 15-30 minutes
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Deploy with
|
||||
{' '}
|
||||
{strategy}
|
||||
{' '}
|
||||
strategy
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DeploymentStrategy: Story = {
|
||||
render: () => <DeploymentStrategyDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Storage options
|
||||
const StorageOptionsDemo = () => {
|
||||
const [storage, setStorage] = useState('ssd')
|
||||
|
||||
const storageOptions = [
|
||||
{
|
||||
value: 'ssd',
|
||||
icon: <RiDatabase2Line className="size-5 text-purple-600" />,
|
||||
iconBg: 'bg-purple-100',
|
||||
title: 'SSD Storage',
|
||||
description: 'Fast and reliable solid state drives',
|
||||
price: '$0.10/GB/month',
|
||||
speed: 'Up to 3000 IOPS',
|
||||
},
|
||||
{
|
||||
value: 'hdd',
|
||||
icon: <RiDatabase2Line className="size-5 text-gray-600" />,
|
||||
iconBg: 'bg-gray-100',
|
||||
title: 'HDD Storage',
|
||||
description: 'Cost-effective magnetic disk storage',
|
||||
price: '$0.05/GB/month',
|
||||
speed: 'Up to 500 IOPS',
|
||||
},
|
||||
{
|
||||
value: 'nvme',
|
||||
icon: <RiDatabase2Line className="size-5 text-red-600" />,
|
||||
iconBg: 'bg-red-100',
|
||||
title: 'NVMe Storage',
|
||||
description: 'Ultra-fast PCIe-based storage',
|
||||
price: '$0.20/GB/month',
|
||||
speed: 'Up to 10000 IOPS',
|
||||
},
|
||||
]
|
||||
|
||||
const selectedOption = storageOptions.find(opt => opt.value === storage)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Storage Type</h3>
|
||||
<div className="space-y-3">
|
||||
{storageOptions.map(option => (
|
||||
<RadioCard
|
||||
key={option.value}
|
||||
icon={option.icon}
|
||||
iconBgClassName={option.iconBg}
|
||||
title={(
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{option.title}</span>
|
||||
<span className="text-xs font-normal text-gray-500">{option.price}</span>
|
||||
</div>
|
||||
)}
|
||||
description={`${option.description} - ${option.speed}`}
|
||||
isChosen={storage === option.value}
|
||||
onChosen={() => setStorage(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{selectedOption && (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4">
|
||||
<div className="text-sm text-gray-700">
|
||||
<strong>Selected:</strong>
|
||||
{' '}
|
||||
{selectedOption.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{selectedOption.price}
|
||||
{' '}
|
||||
•
|
||||
{selectedOption.speed}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StorageOptions: Story = {
|
||||
render: () => <StorageOptionsDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - API authentication method
|
||||
const APIAuthMethodDemo = () => {
|
||||
const [authMethod, setAuthMethod] = useState('api_key')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '550px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">API Authentication</h3>
|
||||
<div className="space-y-3">
|
||||
<RadioCard
|
||||
icon={<RiShieldLine className="size-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="API Key"
|
||||
description="Simple authentication using a secret key"
|
||||
isChosen={authMethod === 'api_key'}
|
||||
onChosen={() => setAuthMethod('api_key')}
|
||||
chosenConfig={(
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-gray-700">Your API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">Keep your API key secure and never share it publicly</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiShieldLine className="size-5 text-green-600" />}
|
||||
iconBgClassName="bg-green-100"
|
||||
title="OAuth 2.0"
|
||||
description="Industry-standard authorization protocol"
|
||||
isChosen={authMethod === 'oauth'}
|
||||
onChosen={() => setAuthMethod('oauth')}
|
||||
chosenConfig={(
|
||||
<div className="rounded-lg bg-green-50 p-3">
|
||||
<p className="mb-2 text-xs text-gray-700">
|
||||
Configure OAuth 2.0 authentication for secure access
|
||||
</p>
|
||||
<button className="text-xs font-medium text-green-600 hover:underline">
|
||||
Configure OAuth Settings →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiShieldLine className="size-5 text-purple-600" />}
|
||||
iconBgClassName="bg-purple-100"
|
||||
title="JWT Token"
|
||||
description="JSON Web Token based authentication"
|
||||
isChosen={authMethod === 'jwt'}
|
||||
onChosen={() => setAuthMethod('jwt')}
|
||||
chosenConfig={(
|
||||
<div className="rounded-lg bg-purple-50 p-3 text-xs text-gray-700">
|
||||
JWT tokens provide stateless authentication with expiration and refresh capabilities
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const APIAuthMethod: Story = {
|
||||
render: () => <APIAuthMethodDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Interactive playground
|
||||
const PlaygroundDemo = () => {
|
||||
const [selected, setSelected] = useState('option1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="space-y-3">
|
||||
<RadioCard
|
||||
noRadio
|
||||
icon={<RiFileList3Line className="size-5 text-indigo-600" />}
|
||||
iconBgClassName="bg-indigo-100"
|
||||
title="Current Retrieval Method"
|
||||
description="This card summarizes the active method and is not a selectable radio option."
|
||||
icon={<RiRocketLine className="size-5 text-purple-600" />}
|
||||
iconBgClassName="bg-purple-100"
|
||||
title="Option 1"
|
||||
description="First option with icon and description"
|
||||
isChosen={selected === 'option1'}
|
||||
onChosen={() => setSelected('option1')}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiDatabase2Line className="size-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="Option 2"
|
||||
description="Second option with different styling"
|
||||
isChosen={selected === 'option2'}
|
||||
onChosen={() => setSelected('option2')}
|
||||
chosenConfig={(
|
||||
<div className="flex gap-6 system-xs-regular text-text-tertiary">
|
||||
<span>Top K: 5</span>
|
||||
<span>Score: 0.8</span>
|
||||
<div className="rounded-sm bg-blue-50 p-2 text-xs text-gray-600">
|
||||
Additional configuration appears when selected
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="size-5 text-green-600" />}
|
||||
iconBgClassName="bg-green-100"
|
||||
title="Option 3"
|
||||
description="Third option to demonstrate selection"
|
||||
isChosen={selected === 'option3'}
|
||||
onChosen={() => setSelected('option3')}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PlaygroundDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
@ -1,124 +1,76 @@
|
||||
'use client'
|
||||
import type { RadioRootProps } from '@langgenius/dify-ui/radio'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
|
||||
type BaseProps = {
|
||||
type Props = {
|
||||
className?: string
|
||||
icon: ReactNode
|
||||
icon: React.ReactNode
|
||||
iconBgClassName?: string
|
||||
title: ReactNode
|
||||
title: React.ReactNode
|
||||
description: string
|
||||
chosenConfig?: ReactNode
|
||||
noRadio?: boolean
|
||||
isChosen?: boolean
|
||||
onChosen?: () => void
|
||||
chosenConfig?: React.ReactNode
|
||||
chosenConfigWrapClassName?: string
|
||||
}
|
||||
|
||||
type SelectableRadioCardProps = BaseProps & {
|
||||
noRadio?: false
|
||||
} & Omit<RadioRootProps<string>, 'children' | 'className' | 'variant' | 'render' | 'nativeButton'>
|
||||
|
||||
type StaticRadioCardProps = BaseProps & {
|
||||
noRadio: true
|
||||
value?: never
|
||||
checked?: never
|
||||
}
|
||||
|
||||
type Props = SelectableRadioCardProps | StaticRadioCardProps
|
||||
|
||||
function RadioCard(props: Props) {
|
||||
if (props.noRadio) {
|
||||
const {
|
||||
icon,
|
||||
iconBgClassName = 'bg-[#F5F3FF]',
|
||||
title,
|
||||
description,
|
||||
chosenConfig,
|
||||
chosenConfigWrapClassName,
|
||||
className,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg p-3',
|
||||
const RadioCard: FC<Props> = ({
|
||||
icon,
|
||||
iconBgClassName = 'bg-[#F5F3FF]',
|
||||
title,
|
||||
description,
|
||||
noRadio,
|
||||
isChosen,
|
||||
onChosen = noop,
|
||||
chosenConfig,
|
||||
chosenConfigWrapClassName,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg p-3',
|
||||
isChosen && 'border-[1.5px] bg-components-option-card-option-selected-bg',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full gap-x-2 border-none bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={onChosen}
|
||||
>
|
||||
<div className="flex w-full gap-x-2 text-left">
|
||||
<div className={cn(iconBgClassName, 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md')}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 grow pr-8">
|
||||
<div className="mb-1 system-sm-semibold text-text-secondary">{title}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{description}</div>
|
||||
</div>
|
||||
<div className={cn(iconBgClassName, 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md')}>
|
||||
{icon}
|
||||
</div>
|
||||
{Boolean(chosenConfig) && (
|
||||
<div className="mt-2 flex gap-x-2">
|
||||
<div className="size-8 shrink-0"></div>
|
||||
<div className={cn(chosenConfigWrapClassName, 'grow')}>
|
||||
{chosenConfig}
|
||||
<div className="grow">
|
||||
<div className="mb-1 system-sm-semibold text-text-secondary">{title}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{description}</div>
|
||||
</div>
|
||||
{!noRadio && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<div
|
||||
className={cn(
|
||||
'size-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs',
|
||||
isChosen && 'border-[5px] border-components-radio-border-checked',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
icon,
|
||||
iconBgClassName = 'bg-[#F5F3FF]',
|
||||
title,
|
||||
description,
|
||||
chosenConfig,
|
||||
chosenConfigWrapClassName,
|
||||
className,
|
||||
noRadio: _noRadio,
|
||||
...radioRootProps
|
||||
} = props
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className={cn(iconBgClassName, 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md')}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 grow pr-8">
|
||||
<div className="mb-1 system-sm-semibold text-text-secondary">{title}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{description}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
const rootClassName = cn(
|
||||
'group/radio-card relative rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg p-3 transition-colors',
|
||||
'has-[[data-checked]]:border-[1.5px] has-[[data-checked]]:bg-components-option-card-option-selected-bg',
|
||||
className,
|
||||
)
|
||||
const config = !!chosenConfig && (
|
||||
<div className="mt-2 hidden gap-x-2 group-has-data-checked/radio-card:flex">
|
||||
<div className="size-8 shrink-0"></div>
|
||||
<div className={cn(chosenConfigWrapClassName, 'grow')}>
|
||||
{chosenConfig}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={rootClassName}
|
||||
>
|
||||
<RadioRoot
|
||||
{...radioRootProps}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
className="flex w-full cursor-pointer gap-x-2 border-none bg-transparent p-0 text-left outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-active"
|
||||
>
|
||||
{content}
|
||||
<RadioControl className="absolute top-3 right-3" aria-hidden="true" />
|
||||
</RadioRoot>
|
||||
{config}
|
||||
</button>
|
||||
{!!((isChosen && chosenConfig) || noRadio) && (
|
||||
<div className="mt-2 flex gap-x-2">
|
||||
<div className="size-8 shrink-0"></div>
|
||||
<div className={cn(chosenConfigWrapClassName, 'grow')}>
|
||||
{chosenConfig}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RadioCard
|
||||
export default React.memo(RadioCard)
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
// index.spec.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import RadioCard from '../index'
|
||||
|
||||
describe('RadioCard', () => {
|
||||
it('renders title and description', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
title="Card Title"
|
||||
description="Card Description"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Card Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Card Description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders JSX title correctly', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
title={<span data-testid="jsx-title">JSX Title</span>}
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('jsx-title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders icon when provided', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
title="With Icon"
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
icon={<span data-testid="icon">ICON</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders extra content when provided', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
title="With Extra"
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
extra={<div data-testid="extra">Extra Content</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('extra')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChosen when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChosen = vi.fn()
|
||||
|
||||
render(
|
||||
<RadioCard
|
||||
title="Clickable"
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={onChosen}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Clickable'))
|
||||
expect(onChosen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('applies active class when isChosen is true', () => {
|
||||
const { container: inactiveContainer } = render(
|
||||
<RadioCard
|
||||
title="Inactive"
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const inactiveClassName = (inactiveContainer.firstChild as HTMLElement).className
|
||||
|
||||
const { container: activeContainer } = render(
|
||||
<RadioCard
|
||||
title="Active"
|
||||
description="Desc"
|
||||
isChosen
|
||||
onChosen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const activeRoot = activeContainer.firstChild as HTMLElement
|
||||
expect(activeRoot.className).not.toBe(inactiveClassName)
|
||||
// Since it uses CSS modules, we expect the active class to be appended or changed
|
||||
// In index.tsx it's cn(s.item, isChosen && s.active)
|
||||
expect(activeRoot.className.length).toBeGreaterThan(inactiveClassName.length)
|
||||
expect(activeRoot.className).toContain(inactiveClassName)
|
||||
})
|
||||
|
||||
it('does not apply active styling logic when isChosen is false', () => {
|
||||
const { container } = render(
|
||||
<RadioCard
|
||||
title="Inactive"
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root).toBeTruthy()
|
||||
// It should have some classes but not the active one
|
||||
expect(root.className).not.toBe('')
|
||||
expect(root.className).not.toContain('active') // CSS modules usually append _active
|
||||
})
|
||||
|
||||
it('memo export renders correctly', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
title="Memo"
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Memo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
45
web/app/components/base/radio-card/simple/index.tsx
Normal file
45
web/app/components/base/radio-card/simple/index.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import s from './style.module.css'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
title: string | React.JSX.Element | null
|
||||
description: string
|
||||
isChosen: boolean
|
||||
onChosen: () => void
|
||||
chosenConfig?: React.ReactNode
|
||||
icon?: React.JSX.Element
|
||||
extra?: React.ReactNode
|
||||
}
|
||||
|
||||
const RadioCard: FC<Props> = ({
|
||||
title,
|
||||
description,
|
||||
isChosen,
|
||||
onChosen,
|
||||
icon,
|
||||
extra,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(s.item, isChosen && s.active)}
|
||||
onClick={onChosen}
|
||||
>
|
||||
<div className="flex px-3 py-2">
|
||||
{icon}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm/5 font-medium text-gray-900">{title}</div>
|
||||
<div className={s.radio}></div>
|
||||
</div>
|
||||
<div className="text-xs leading-[18px] font-normal text-gray-500">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
{extra}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(RadioCard)
|
||||
27
web/app/components/base/radio-card/simple/style.module.css
Normal file
27
web/app/components/base/radio-card/simple/style.module.css
Normal file
@ -0,0 +1,27 @@
|
||||
@reference "../../../../styles/globals.css";
|
||||
|
||||
.item {
|
||||
@apply relative rounded-xl border border-gray-100 cursor-pointer;
|
||||
background-color: #fcfcfd;
|
||||
}
|
||||
|
||||
.item.active {
|
||||
border-width: 1.5px;
|
||||
border-color: #528BFF;
|
||||
box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06);
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: #ffffff;
|
||||
border-color: #B2CCFF;
|
||||
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
}
|
||||
|
||||
.radio {
|
||||
@apply w-4 h-4 border-[2px] border-gray-200 rounded-full;
|
||||
}
|
||||
|
||||
.item.active .radio {
|
||||
border-width: 5px;
|
||||
border-color: #155EEF;
|
||||
}
|
||||
44
web/app/components/base/radio/__tests__/index.spec.tsx
Normal file
44
web/app/components/base/radio/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
// index.spec.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Group from '../component/group'
|
||||
import Radio from '../index'
|
||||
|
||||
describe('Radio (index)', () => {
|
||||
it('attaches Group as a property on the default export', () => {
|
||||
expect(Radio.Group).toBe(Group)
|
||||
})
|
||||
|
||||
it('renders Radio when used as a component', () => {
|
||||
render(<Radio>RootLabel</Radio>)
|
||||
expect(screen.getByText('RootLabel')).toBeInTheDocument()
|
||||
const label = screen.getByText('RootLabel')
|
||||
expect(label.tagName.toLowerCase()).toBe('label')
|
||||
})
|
||||
|
||||
it('Radio.Group provides context to nested Radio and group onChange is called on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const groupOnChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Radio.Group value="val" onChange={groupOnChange}>
|
||||
<Radio value="val">InnerRadio</Radio>
|
||||
</Radio.Group>,
|
||||
)
|
||||
|
||||
const root = screen.getByText('InnerRadio').closest('div') as HTMLElement
|
||||
await user.click(root)
|
||||
expect(groupOnChange).toHaveBeenCalledTimes(1)
|
||||
expect(groupOnChange).toHaveBeenCalledWith('val')
|
||||
})
|
||||
|
||||
it('Radio.Group can render arbitrary children', () => {
|
||||
render(
|
||||
<Radio.Group value={undefined} onChange={() => {}}>
|
||||
<div data-testid="plain-child">child</div>
|
||||
</Radio.Group>,
|
||||
)
|
||||
expect(screen.getByTestId('plain-child')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
88
web/app/components/base/radio/__tests__/ui.spec.tsx
Normal file
88
web/app/components/base/radio/__tests__/ui.spec.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
// radio-ui.spec.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import RadioUI from '../ui'
|
||||
|
||||
describe('RadioUI component', () => {
|
||||
it('renders with correct role and aria attributes', () => {
|
||||
render(<RadioUI isChecked />)
|
||||
|
||||
const radio = screen.getByRole('radio')
|
||||
expect(radio).toBeInTheDocument()
|
||||
expect(radio).toHaveAttribute('aria-checked', 'true')
|
||||
expect(radio).toHaveAttribute('aria-disabled', 'false')
|
||||
})
|
||||
|
||||
it('applies checked + enabled styles', () => {
|
||||
render(<RadioUI isChecked />)
|
||||
const radio = screen.getByRole('radio')
|
||||
expect(radio.className).toContain('border-[5px]')
|
||||
expect(radio.className).toContain('border-components-radio-border-checked')
|
||||
})
|
||||
|
||||
it('applies unchecked + enabled styles', () => {
|
||||
render(<RadioUI isChecked={false} />)
|
||||
const radio = screen.getByRole('radio')
|
||||
expect(radio.className).toContain('border-components-radio-border')
|
||||
})
|
||||
|
||||
it('applies checked + disabled styles', () => {
|
||||
render(<RadioUI isChecked disabled />)
|
||||
const radio = screen.getByRole('radio')
|
||||
expect(radio).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(radio.className).toContain(
|
||||
'border-components-radio-border-checked-disabled',
|
||||
)
|
||||
})
|
||||
|
||||
it('applies unchecked + disabled styles', () => {
|
||||
render(<RadioUI isChecked={false} disabled />)
|
||||
const radio = screen.getByRole('radio')
|
||||
expect(radio.className).toContain(
|
||||
'border-components-radio-border-disabled',
|
||||
)
|
||||
expect(radio.className).toContain(
|
||||
'bg-components-radio-bg-disabled',
|
||||
)
|
||||
})
|
||||
|
||||
it('calls onCheck when clicked if not disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleCheck = vi.fn()
|
||||
|
||||
render(<RadioUI isChecked={false} onCheck={handleCheck} />)
|
||||
|
||||
const radio = screen.getByRole('radio')
|
||||
await user.click(radio)
|
||||
|
||||
expect(handleCheck).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not call onCheck when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleCheck = vi.fn()
|
||||
|
||||
render(
|
||||
<RadioUI isChecked={false} disabled onCheck={handleCheck} />,
|
||||
)
|
||||
|
||||
const radio = screen.getByRole('radio')
|
||||
await user.click(radio)
|
||||
|
||||
expect(handleCheck).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(
|
||||
<RadioUI isChecked={false} className="my-extra-class" />,
|
||||
)
|
||||
const radio = screen.getByRole('radio')
|
||||
expect(radio.className).toContain('my-extra-class')
|
||||
})
|
||||
|
||||
it('memo export renders correctly', () => {
|
||||
render(<RadioUI isChecked />)
|
||||
expect(screen.getByRole('radio')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,108 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
// Group.test.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import RadioGroupContext from '../../../context'
|
||||
import Group from '../index'
|
||||
|
||||
// small consumer that uses the same context as your component
|
||||
function ContextConsumer({ showButton = true }: { showButton?: boolean }) {
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const ctx = useContextSelector(RadioGroupContext, (v: any) => v)
|
||||
const value = ctx?.value
|
||||
const onChange = ctx?.onChange
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="radio-value">{String(value)}</span>
|
||||
{showButton && (
|
||||
<button
|
||||
data-testid="radio-change-btn"
|
||||
onClick={() => onChange?.('clicked-from-test')}
|
||||
>
|
||||
change
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Group component', () => {
|
||||
it('renders children and exposes provided value through context', () => {
|
||||
render(
|
||||
<Group value="initial-value">
|
||||
<ContextConsumer />
|
||||
</Group>,
|
||||
)
|
||||
|
||||
const valueNode = screen.getByTestId('radio-value')
|
||||
expect(valueNode).toBeInTheDocument()
|
||||
expect(valueNode).toHaveTextContent('initial-value')
|
||||
})
|
||||
|
||||
it('merges custom className with existing classes on root element', () => {
|
||||
const { container } = render(
|
||||
<Group value="v" className="my-extra-class">
|
||||
<ContextConsumer />
|
||||
</Group>,
|
||||
)
|
||||
|
||||
const root = container.firstChild as HTMLElement
|
||||
|
||||
expect(root).toBeInTheDocument()
|
||||
expect(root.className).toContain('my-extra-class')
|
||||
|
||||
// ensure it still has other classes (from cn + css module)
|
||||
expect(root.className.length).toBeGreaterThan('my-extra-class'.length)
|
||||
})
|
||||
|
||||
it('calls onChange from context when consumer triggers it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Group value="whatever" onChange={handleChange}>
|
||||
<ContextConsumer />
|
||||
</Group>,
|
||||
)
|
||||
|
||||
const btn = screen.getByTestId('radio-change-btn')
|
||||
await user.click(btn)
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleChange).toHaveBeenCalledWith('clicked-from-test')
|
||||
})
|
||||
|
||||
it('does not throw if onChange is not provided and consumer calls it', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Group value={0}>
|
||||
{/* the consumer will call onChange which is undefined */}
|
||||
<ContextConsumer />
|
||||
</Group>,
|
||||
)
|
||||
|
||||
const btn = screen.getByTestId('radio-change-btn')
|
||||
// clicking should not throw (if it threw the test would fail)
|
||||
await user.click(btn)
|
||||
// value still rendered correctly (verifies consumer reads numeric/false-y values too)
|
||||
expect(screen.getByTestId('radio-value')).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('correctly passes boolean and numeric values through context', () => {
|
||||
render(
|
||||
<>
|
||||
<Group value={false}>
|
||||
<ContextConsumer />
|
||||
</Group>
|
||||
<Group value={123}>
|
||||
<ContextConsumer showButton={false} />
|
||||
</Group>
|
||||
</>,
|
||||
)
|
||||
|
||||
const nodes = screen.getAllByTestId('radio-value')
|
||||
// first should be "false", second "123"
|
||||
expect(nodes[0]).toHaveTextContent('false')
|
||||
expect(nodes[1]).toHaveTextContent('123')
|
||||
})
|
||||
})
|
||||
24
web/app/components/base/radio/component/group/index.tsx
Normal file
24
web/app/components/base/radio/component/group/index.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import RadioGroupContext from '../../context'
|
||||
import s from '../../style.module.css'
|
||||
|
||||
type TRadioGroupProps = {
|
||||
children?: ReactNode | ReactNode[]
|
||||
value?: string | number | boolean
|
||||
className?: string
|
||||
onChange?: (value: any) => void
|
||||
}
|
||||
|
||||
export default function Group({ children, value, onChange, className = '' }: TRadioGroupProps): React.JSX.Element {
|
||||
const onRadioChange = (value: any) => {
|
||||
onChange?.(value)
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex items-center bg-workflow-block-parma-bg text-text-secondary', s.container, className)}>
|
||||
<RadioGroupContext.Provider value={{ value, onChange: onRadioChange }}>
|
||||
{children}
|
||||
</RadioGroupContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
// index.spec.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import RadioGroupContext from '../../../context'
|
||||
import Radio from '../index'
|
||||
|
||||
describe('Radio component', () => {
|
||||
it('renders label children and assigns an id to the label', () => {
|
||||
const { container } = render(<Radio>My Label</Radio>)
|
||||
|
||||
const label = screen.getByText('My Label')
|
||||
expect(label).toBeInTheDocument()
|
||||
// label must be an HTMLLabelElement with an id assigned by useId
|
||||
expect(label.tagName.toLowerCase()).toBe('label')
|
||||
expect(label).toHaveAttribute('id')
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render a label when children is falsey', () => {
|
||||
render(<Radio />)
|
||||
// there should be no <label> in the document
|
||||
const labels = screen.queryAllByRole('label')
|
||||
expect(labels.length).toBe(0)
|
||||
// also ensure no textual children
|
||||
expect(screen.queryByText(/./)).toBeNull()
|
||||
})
|
||||
|
||||
it('calls both local onChange and group onChange when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const localChange = vi.fn()
|
||||
const groupChange = vi.fn()
|
||||
|
||||
render(
|
||||
<RadioGroupContext.Provider value={{ value: null, onChange: groupChange }}>
|
||||
<Radio value="v1" onChange={localChange}>
|
||||
ClickMe
|
||||
</Radio>
|
||||
</RadioGroupContext.Provider>,
|
||||
)
|
||||
|
||||
const root = screen.getByText('ClickMe').closest('div') as HTMLElement
|
||||
await user.click(root)
|
||||
expect(localChange).toHaveBeenCalledTimes(1)
|
||||
expect(localChange).toHaveBeenCalledWith('v1')
|
||||
expect(groupChange).toHaveBeenCalledTimes(1)
|
||||
expect(groupChange).toHaveBeenCalledWith('v1')
|
||||
})
|
||||
|
||||
it('does not call onChange handlers when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const localChange = vi.fn()
|
||||
const groupChange = vi.fn()
|
||||
|
||||
render(
|
||||
<RadioGroupContext.Provider value={{ value: null, onChange: groupChange }}>
|
||||
<Radio value="v2" onChange={localChange} disabled>
|
||||
DisabledLabel
|
||||
</Radio>
|
||||
</RadioGroupContext.Provider>,
|
||||
)
|
||||
|
||||
const root = screen.getByText('DisabledLabel').closest('div') as HTMLElement
|
||||
await user.click(root)
|
||||
expect(localChange).not.toHaveBeenCalled()
|
||||
expect(groupChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses group value to determine checked state and applies checked class fragment', () => {
|
||||
const { container: c1 } = render(
|
||||
<RadioGroupContext.Provider value={{ value: 'yes', onChange: () => {} }}>
|
||||
<Radio value="yes">CheckedByGroup</Radio>
|
||||
</RadioGroupContext.Provider>,
|
||||
)
|
||||
const root1 = c1.firstChild as HTMLElement
|
||||
expect(root1).toBeTruthy()
|
||||
// component conditionally adds the 'bg-components-option-card-option-bg-hover' fragment when checked
|
||||
expect(root1.className).toContain('bg-components-option-card-option-bg-hover')
|
||||
|
||||
const { container: c2 } = render(<Radio checked>CheckedByProp</Radio>)
|
||||
const root2 = c2.firstChild as HTMLElement
|
||||
expect(root2).toBeTruthy()
|
||||
expect(root2.className).toContain('bg-components-option-card-option-bg-hover')
|
||||
})
|
||||
|
||||
it('merges custom className with component classes', () => {
|
||||
const { container } = render(<Radio className="my-custom-class">Label</Radio>)
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root).toBeInTheDocument()
|
||||
expect(root.className).toContain('my-custom-class')
|
||||
// ensure other classes still exist (merged)
|
||||
expect(root.className.length).toBeGreaterThan('my-custom-class'.length)
|
||||
})
|
||||
})
|
||||
67
web/app/components/base/radio/component/radio/index.tsx
Normal file
67
web/app/components/base/radio/component/radio/index.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useId } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import RadioGroupContext from '../../context'
|
||||
import s from '../../style.module.css'
|
||||
|
||||
export type IRadioProps = {
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
children?: string | ReactNode
|
||||
checked?: boolean
|
||||
value?: string | number | boolean
|
||||
disabled?: boolean
|
||||
onChange?: (e?: IRadioProps['value']) => void
|
||||
}
|
||||
|
||||
export default function Radio({
|
||||
className = '',
|
||||
labelClassName,
|
||||
children = '',
|
||||
checked,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: IRadioProps): React.JSX.Element {
|
||||
const groupContext = useContext(RadioGroupContext)
|
||||
const labelId = useId()
|
||||
const handleChange = (e: IRadioProps['value']) => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
onChange?.(e)
|
||||
groupContext?.onChange(e)
|
||||
}
|
||||
|
||||
const isChecked = groupContext ? groupContext.value === value : checked
|
||||
const divClassName = `
|
||||
flex items-center py-1 relative
|
||||
px-7 cursor-pointer text-text-secondary rounded
|
||||
hover:bg-components-option-card-option-bg-hover hover:shadow-xs
|
||||
`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
s.label,
|
||||
disabled ? s.disabled : '',
|
||||
isChecked ? 'bg-components-option-card-option-bg-hover shadow-xs' : '',
|
||||
divClassName,
|
||||
className,
|
||||
)}
|
||||
onClick={() => handleChange(value)}
|
||||
>
|
||||
{!!children && (
|
||||
<label
|
||||
className={
|
||||
cn(labelClassName, 'cursor-pointer text-sm')
|
||||
}
|
||||
id={labelId}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
// context.spec.tsx
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import RadioGroupContext from '../index'
|
||||
|
||||
function Consumer() {
|
||||
const value = useContextSelector(RadioGroupContext, v => v)
|
||||
return <div data-testid="ctx-value">{JSON.stringify(value)}</div>
|
||||
}
|
||||
|
||||
describe('RadioGroupContext', () => {
|
||||
it('provides null as default value when no provider is used', () => {
|
||||
render(<Consumer />)
|
||||
|
||||
const node = screen.getByTestId('ctx-value')
|
||||
expect(node).toBeInTheDocument()
|
||||
expect(node).toHaveTextContent('null')
|
||||
})
|
||||
|
||||
it('provides value from provider when wrapped', () => {
|
||||
const providedValue = { value: 'radio', onChange: () => {} }
|
||||
|
||||
render(
|
||||
<RadioGroupContext.Provider value={providedValue}>
|
||||
<Consumer />
|
||||
</RadioGroupContext.Provider>,
|
||||
)
|
||||
|
||||
const node = screen.getByTestId('ctx-value')
|
||||
expect(node).toBeInTheDocument()
|
||||
expect(node).toHaveTextContent(JSON.stringify(providedValue))
|
||||
})
|
||||
|
||||
it('updates when provider value changes', () => {
|
||||
const first = { value: 'first', onChange: () => {} }
|
||||
const second = { value: 'second', onChange: () => {} }
|
||||
|
||||
const { rerender } = render(
|
||||
<RadioGroupContext.Provider value={first}>
|
||||
<Consumer />
|
||||
</RadioGroupContext.Provider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('ctx-value')).toHaveTextContent(
|
||||
JSON.stringify(first),
|
||||
)
|
||||
|
||||
rerender(
|
||||
<RadioGroupContext.Provider value={second}>
|
||||
<Consumer />
|
||||
</RadioGroupContext.Provider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('ctx-value')).toHaveTextContent(
|
||||
JSON.stringify(second),
|
||||
)
|
||||
})
|
||||
})
|
||||
6
web/app/components/base/radio/context/index.ts
Normal file
6
web/app/components/base/radio/context/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { createContext } from 'use-context-selector'
|
||||
|
||||
const RadioGroupContext = createContext<any>(null)
|
||||
export default RadioGroupContext
|
||||
442
web/app/components/base/radio/index.stories.tsx
Normal file
442
web/app/components/base/radio/index.stories.tsx
Normal file
@ -0,0 +1,442 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useState } from 'react'
|
||||
import Radio from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/Radio',
|
||||
component: Radio,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Radio component for single selection. Usually used with Radio.Group for multiple options.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
checked: {
|
||||
control: 'boolean',
|
||||
description: 'Checked state (for standalone radio)',
|
||||
},
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Value of the radio option',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
children: {
|
||||
control: 'text',
|
||||
description: 'Label content',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Radio>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Single radio demo
|
||||
const SingleRadioDemo = (args: any) => {
|
||||
const [checked, setChecked] = useState(args.checked || false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<Radio
|
||||
{...args}
|
||||
checked={checked}
|
||||
onChange={() => setChecked(!checked)}
|
||||
>
|
||||
{args.children || 'Radio option'}
|
||||
</Radio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default single radio
|
||||
export const Default: Story = {
|
||||
render: args => <SingleRadioDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
children: 'Single radio option',
|
||||
},
|
||||
}
|
||||
|
||||
// Checked state
|
||||
export const Checked: Story = {
|
||||
render: args => <SingleRadioDemo {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: false,
|
||||
children: 'Selected option',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <SingleRadioDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: true,
|
||||
children: 'Disabled option',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled and checked
|
||||
export const DisabledChecked: Story = {
|
||||
render: args => <SingleRadioDemo {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: true,
|
||||
children: 'Disabled selected option',
|
||||
},
|
||||
}
|
||||
|
||||
// Radio Group - Basic
|
||||
const RadioGroupDemo = () => {
|
||||
const [value, setValue] = useState('option1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Radio.Group value={value} onChange={setValue}>
|
||||
<Radio value="option1">Option 1</Radio>
|
||||
<Radio value="option2">Option 2</Radio>
|
||||
<Radio value="option3">Option 3</Radio>
|
||||
</Radio.Group>
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
Selected:
|
||||
{' '}
|
||||
<span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RadioGroup: Story = {
|
||||
render: () => <RadioGroupDemo />,
|
||||
}
|
||||
|
||||
// Radio Group - With descriptions
|
||||
const RadioGroupWithDescriptionsDemo = () => {
|
||||
const [value, setValue] = useState('basic')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }}>
|
||||
<h3 className="mb-3 text-sm font-medium text-gray-700">Select a plan</h3>
|
||||
<Radio.Group value={value} onChange={setValue}>
|
||||
<Radio value="basic">
|
||||
<div>
|
||||
<div className="font-medium">Basic Plan</div>
|
||||
<div className="text-xs text-gray-500">Free forever - Perfect for personal use</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="pro">
|
||||
<div>
|
||||
<div className="font-medium">Pro Plan</div>
|
||||
<div className="text-xs text-gray-500">$19/month - Advanced features for professionals</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="enterprise">
|
||||
<div>
|
||||
<div className="font-medium">Enterprise Plan</div>
|
||||
<div className="text-xs text-gray-500">Custom pricing - Full features and support</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RadioGroupWithDescriptions: Story = {
|
||||
render: () => <RadioGroupWithDescriptionsDemo />,
|
||||
}
|
||||
|
||||
// Radio Group - With disabled option
|
||||
const RadioGroupWithDisabledDemo = () => {
|
||||
const [value, setValue] = useState('available')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Radio.Group value={value} onChange={setValue}>
|
||||
<Radio value="available">Available option</Radio>
|
||||
<Radio value="disabled" disabled>Disabled option</Radio>
|
||||
<Radio value="another">Another available option</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RadioGroupWithDisabled: Story = {
|
||||
render: () => <RadioGroupWithDisabledDemo />,
|
||||
}
|
||||
|
||||
// Radio Group - Vertical layout
|
||||
const VerticalLayoutDemo = () => {
|
||||
const [value, setValue] = useState('email')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<h3 className="mb-3 text-sm font-medium text-gray-700">Notification preferences</h3>
|
||||
<Radio.Group value={value} onChange={setValue} className="flex-col gap-2">
|
||||
<Radio value="email">Email notifications</Radio>
|
||||
<Radio value="sms">SMS notifications</Radio>
|
||||
<Radio value="push">Push notifications</Radio>
|
||||
<Radio value="none">No notifications</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VerticalLayout: Story = {
|
||||
render: () => <VerticalLayoutDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Settings panel
|
||||
const SettingsPanelDemo = () => {
|
||||
const [theme, setTheme] = useState('light')
|
||||
const [language, setLanguage] = useState('en')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-6 text-lg font-semibold">Application Settings</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium text-gray-700">Theme</h4>
|
||||
<Radio.Group value={theme} onChange={setTheme} className="flex-col gap-2">
|
||||
<Radio value="light">Light mode</Radio>
|
||||
<Radio value="dark">Dark mode</Radio>
|
||||
<Radio value="auto">Auto (system preference)</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h4 className="mb-3 text-sm font-medium text-gray-700">Language</h4>
|
||||
<Radio.Group value={language} onChange={setLanguage} className="flex-col gap-2">
|
||||
<Radio value="en">English</Radio>
|
||||
<Radio value="zh">中文 (Chinese)</Radio>
|
||||
<Radio value="es">Español (Spanish)</Radio>
|
||||
<Radio value="fr">Français (French)</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg bg-blue-50 p-3">
|
||||
<div className="text-xs text-gray-600">
|
||||
<strong>Current settings:</strong>
|
||||
{' '}
|
||||
Theme:
|
||||
{theme}
|
||||
, Language:
|
||||
{language}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsPanel: Story = {
|
||||
render: () => <SettingsPanelDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Payment method selector
|
||||
const PaymentMethodSelectorDemo = () => {
|
||||
const [paymentMethod, setPaymentMethod] = useState('credit_card')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Payment Method</h3>
|
||||
<Radio.Group value={paymentMethod} onChange={setPaymentMethod} className="flex-col gap-3">
|
||||
<Radio value="credit_card">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Credit Card</div>
|
||||
<div className="text-xs text-gray-500">Visa, Mastercard, Amex</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">💳</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="paypal">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">PayPal</div>
|
||||
<div className="text-xs text-gray-500">Fast and secure</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">🅿️</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="bank_transfer">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Bank Transfer</div>
|
||||
<div className="text-xs text-gray-500">1-3 business days</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">🏦</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Continue with
|
||||
{' '}
|
||||
{paymentMethod.replace('_', ' ')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PaymentMethodSelector: Story = {
|
||||
render: () => <PaymentMethodSelectorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Shipping options
|
||||
const ShippingOptionsDemo = () => {
|
||||
const [shipping, setShipping] = useState('standard')
|
||||
|
||||
const shippingCosts = {
|
||||
standard: 5.99,
|
||||
express: 14.99,
|
||||
overnight: 29.99,
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Shipping Method</h3>
|
||||
<Radio.Group value={shipping} onChange={setShipping} className="flex-col gap-3">
|
||||
<Radio value="standard">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Standard Shipping</div>
|
||||
<div className="text-xs text-gray-500">5-7 business days</div>
|
||||
</div>
|
||||
<div className="font-semibold text-gray-700">
|
||||
$
|
||||
{shippingCosts.standard}
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="express">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Express Shipping</div>
|
||||
<div className="text-xs text-gray-500">2-3 business days</div>
|
||||
</div>
|
||||
<div className="font-semibold text-gray-700">
|
||||
$
|
||||
{shippingCosts.express}
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="overnight">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Overnight Shipping</div>
|
||||
<div className="text-xs text-gray-500">Next business day</div>
|
||||
</div>
|
||||
<div className="font-semibold text-gray-700">
|
||||
$
|
||||
{shippingCosts.overnight}
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<div className="mt-6 border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Shipping cost:</span>
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
$
|
||||
{shippingCosts[shipping as keyof typeof shippingCosts]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ShippingOptions: Story = {
|
||||
render: () => <ShippingOptionsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Survey question
|
||||
const SurveyQuestionDemo = () => {
|
||||
const [satisfaction, setSatisfaction] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-base font-semibold">Customer Satisfaction Survey</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">How satisfied are you with our service?</p>
|
||||
|
||||
<Radio.Group value={satisfaction} onChange={setSatisfaction} className="flex-col gap-2">
|
||||
<Radio value="very_satisfied">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>😄</span>
|
||||
<span>Very satisfied</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="satisfied">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>🙂</span>
|
||||
<span>Satisfied</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="neutral">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>😐</span>
|
||||
<span>Neutral</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="dissatisfied">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>😟</span>
|
||||
<span>Dissatisfied</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="very_dissatisfied">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>😢</span>
|
||||
<span>Very dissatisfied</span>
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<button
|
||||
className="mt-6 w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!satisfaction}
|
||||
>
|
||||
Submit Feedback
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SurveyQuestion: Story = {
|
||||
render: () => <SurveyQuestionDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
const PlaygroundDemo = () => {
|
||||
const [value, setValue] = useState('option1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Radio.Group value={value} onChange={setValue}>
|
||||
<Radio value="option1">Option 1</Radio>
|
||||
<Radio value="option2">Option 2</Radio>
|
||||
<Radio value="option3">Option 3</Radio>
|
||||
<Radio value="option4" disabled>Disabled option</Radio>
|
||||
</Radio.Group>
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
Selected:
|
||||
{' '}
|
||||
<span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PlaygroundDemo />,
|
||||
}
|
||||
15
web/app/components/base/radio/index.tsx
Normal file
15
web/app/components/base/radio/index.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import type * as React from 'react'
|
||||
import type { IRadioProps } from './component/radio'
|
||||
import Group from './component/group'
|
||||
import RadioComps from './component/radio'
|
||||
|
||||
type CompoundedComponent = {
|
||||
Group: typeof Group
|
||||
} & React.ForwardRefExoticComponent<IRadioProps & React.RefAttributes<HTMLElement>>
|
||||
|
||||
const Radio = RadioComps as CompoundedComponent
|
||||
/**
|
||||
* Radio 组件出现一般是以一组的形式出现
|
||||
*/
|
||||
Radio.Group = Group
|
||||
export default Radio
|
||||
13
web/app/components/base/radio/style.module.css
Normal file
13
web/app/components/base/radio/style.module.css
Normal file
@ -0,0 +1,13 @@
|
||||
.container {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: relative;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.label:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
41
web/app/components/base/radio/ui.tsx
Normal file
41
web/app/components/base/radio/ui.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
|
||||
type Props = {
|
||||
isChecked: boolean
|
||||
disabled?: boolean
|
||||
onCheck?: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const RadioUI: FC<Props> = ({
|
||||
isChecked,
|
||||
disabled = false,
|
||||
onCheck,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
role="radio"
|
||||
aria-checked={isChecked}
|
||||
aria-disabled={disabled}
|
||||
className={cn(
|
||||
'size-4 rounded-full',
|
||||
isChecked && !disabled && 'border-[5px] border-components-radio-border-checked hover:border-components-radio-border-checked-hover',
|
||||
!isChecked && !disabled && 'border border-components-radio-border hover:border-components-radio-border-hover',
|
||||
isChecked && disabled && 'border-[5px] border-components-radio-border-checked-disabled',
|
||||
!isChecked && disabled && 'border border-components-radio-border-disabled bg-components-radio-bg-disabled',
|
||||
!disabled && 'bg-components-radio-bg shadow-xs shadow-shadow-shadow-3 hover:bg-components-radio-bg-hover',
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
if (disabled)
|
||||
return
|
||||
onCheck?.(event)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(RadioUI)
|
||||
@ -1,8 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { retrievalIcon } from '../../../create/icons'
|
||||
import RetrievalMethodInfo, { getIcon } from '../index'
|
||||
|
||||
// Mock RadioCard
|
||||
vi.mock('@/app/components/base/radio-card', () => ({
|
||||
default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => (
|
||||
<div data-testid="radio-card">
|
||||
<div data-testid="card-title">{title}</div>
|
||||
<div data-testid="card-description">{description}</div>
|
||||
<div data-testid="card-icon">{icon}</div>
|
||||
<div data-testid="chosen-config">{chosenConfig}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('../../../create/icons', () => ({
|
||||
retrievalIcon: {
|
||||
@ -32,9 +45,11 @@ describe('RetrievalMethodInfo', () => {
|
||||
it('should render correctly with full config', () => {
|
||||
const { container } = render(<RetrievalMethodInfo value={defaultConfig} />)
|
||||
|
||||
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
|
||||
|
||||
// Check Title & Description (mocked i18n returns key prefixed with ns)
|
||||
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.retrieval.semantic_search.description')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.semantic_search.title')
|
||||
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
|
||||
|
||||
// Check Icon
|
||||
const icon = container.querySelector('img')
|
||||
@ -67,7 +82,7 @@ describe('RetrievalMethodInfo', () => {
|
||||
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
|
||||
const { container, unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
|
||||
|
||||
expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'hybrid-icon.png')
|
||||
|
||||
unmount()
|
||||
@ -75,7 +90,7 @@ describe('RetrievalMethodInfo', () => {
|
||||
// Test FullText
|
||||
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
|
||||
const { container: fullTextContainer } = render(<RetrievalMethodInfo value={fullTextConfig} />)
|
||||
expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
|
||||
expect(fullTextContainer.querySelector('img')).toHaveAttribute('src', 'fulltext-icon.png')
|
||||
})
|
||||
|
||||
|
||||
@ -103,6 +103,25 @@ vi.mock('@/app/components/base/param-item/score-threshold-item', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/radio-card', () => ({
|
||||
default: ({ isChosen, onChosen, title, description }: {
|
||||
isChosen: boolean
|
||||
onChosen: () => void
|
||||
title: string
|
||||
description: string
|
||||
}) => (
|
||||
<div
|
||||
data-testid="radio-card"
|
||||
data-chosen={isChosen}
|
||||
data-title={title}
|
||||
onClick={onChosen}
|
||||
>
|
||||
{title}
|
||||
<span data-testid="radio-description">{description}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/switch', () => ({
|
||||
Switch: ({ checked, onCheckedChange }: { checked: boolean, onCheckedChange?: (v: boolean) => void }) => (
|
||||
<button
|
||||
@ -458,7 +477,8 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(2)
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
expect(radioCards).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should have WeightedScore option', () => {
|
||||
@ -534,7 +554,9 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /dataset\.weightedScore\.title/ }))
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
|
||||
fireEvent.click(weightedScoreCard!)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled()
|
||||
const calledWith = mockOnChange.mock.calls[0]![0]
|
||||
@ -551,7 +573,9 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /common\.modelProvider\.rerankModel\.key/ }))
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'common.modelProvider.rerankModel.key')
|
||||
fireEvent.click(rerankModelCard!)
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled()
|
||||
})
|
||||
@ -581,7 +605,9 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /common\.modelProvider\.rerankModel\.key/ }))
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'common.modelProvider.rerankModel.key')
|
||||
fireEvent.click(rerankModelCard!)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith('workflow.errorMsg.rerankModelRequired')
|
||||
})
|
||||
@ -801,7 +827,9 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /dataset\.weightedScore\.title/ }))
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
|
||||
fireEvent.click(weightedScoreCard!)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled()
|
||||
const calledWith = mockOnChange.mock.calls[0]![0]
|
||||
@ -833,7 +861,9 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /dataset\.weightedScore\.title/ }))
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
|
||||
fireEvent.click(weightedScoreCard!)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled()
|
||||
const calledWith = mockOnChange.mock.calls[0]![0]
|
||||
|
||||
@ -3,7 +3,6 @@ import type { FC } from 'react'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
@ -208,17 +207,13 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
{
|
||||
isHybridSearch && (
|
||||
<>
|
||||
<RadioGroup<RerankingModeEnum>
|
||||
aria-label={t('modelProvider.rerankModel.key', { ns: 'common' })}
|
||||
value={value.reranking_mode}
|
||||
onValueChange={handleChangeRerankMode}
|
||||
className="mb-4 flex gap-2"
|
||||
>
|
||||
<div className="mb-4 flex gap-2">
|
||||
{
|
||||
rerankingModeOptions.map(option => (
|
||||
<RadioCard
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
isChosen={value.reranking_mode === option.value}
|
||||
onChosen={() => handleChangeRerankMode(option.value)}
|
||||
icon={(
|
||||
<img
|
||||
src={
|
||||
@ -235,7 +230,7 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
/>
|
||||
))
|
||||
}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
{
|
||||
value.reranking_mode === RerankingModeEnum.WeightedScore && (
|
||||
<WeightedScore
|
||||
|
||||
@ -5,7 +5,6 @@ import type { ParentChildConfig } from '../hooks'
|
||||
import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { RiSearchEyeLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
@ -116,39 +115,36 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
</div>
|
||||
<Divider className="grow" bgStyle="gradient" />
|
||||
</div>
|
||||
<RadioGroup<ParentMode>
|
||||
aria-label={t('stepTwo.parentChunkForContext', { ns: 'datasetCreation' })}
|
||||
value={parentChildConfig.chunkForContext}
|
||||
onValueChange={value => onChunkForContextChange(value)}
|
||||
className="mt-1 flex-col items-stretch gap-2"
|
||||
>
|
||||
<RadioCard
|
||||
value="paragraph"
|
||||
icon={<img src={Note.src} alt="" />}
|
||||
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
|
||||
chosenConfig={(
|
||||
<div className="flex gap-3">
|
||||
<DelimiterInput
|
||||
value={parentChildConfig.parent.delimiter}
|
||||
tooltip={t('stepTwo.parentChildDelimiterTip', { ns: 'datasetCreation' })!}
|
||||
onChange={e => onParentDelimiterChange(e.target.value)}
|
||||
/>
|
||||
<MaxLengthInput
|
||||
unit="characters"
|
||||
value={parentChildConfig.parent.maxLength}
|
||||
onChange={onParentMaxLengthChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
value="full-doc"
|
||||
icon={<img src={FileList.src} alt="" />}
|
||||
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
|
||||
/>
|
||||
</RadioGroup>
|
||||
<RadioCard
|
||||
className="mt-1"
|
||||
icon={<img src={Note.src} alt="" />}
|
||||
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
|
||||
isChosen={parentChildConfig.chunkForContext === 'paragraph'}
|
||||
onChosen={() => onChunkForContextChange('paragraph')}
|
||||
chosenConfig={(
|
||||
<div className="flex gap-3">
|
||||
<DelimiterInput
|
||||
value={parentChildConfig.parent.delimiter}
|
||||
tooltip={t('stepTwo.parentChildDelimiterTip', { ns: 'datasetCreation' })!}
|
||||
onChange={e => onParentDelimiterChange(e.target.value)}
|
||||
/>
|
||||
<MaxLengthInput
|
||||
unit="characters"
|
||||
value={parentChildConfig.parent.maxLength}
|
||||
onChange={onParentMaxLengthChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
className="mt-2"
|
||||
icon={<img src={FileList.src} alt="" />}
|
||||
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
|
||||
onChosen={() => onChunkForContextChange('full-doc')}
|
||||
isChosen={parentChildConfig.chunkForContext === 'full-doc'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Child chunk for retrieval */}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { OnlineDriveFileType } from '@/models/pipeline'
|
||||
@ -1379,11 +1378,7 @@ describe('Item', () => {
|
||||
|
||||
it('should show radio as checked when isSelected is true', () => {
|
||||
const props = createItemProps({ isSelected: true, isMultipleChoice: false })
|
||||
render(
|
||||
<RadioGroup aria-label="Files" value={props.file.id}>
|
||||
<ActualItem {...props} />
|
||||
</RadioGroup>,
|
||||
)
|
||||
render(<ActualItem {...props} />)
|
||||
const radio = getRadio()
|
||||
expect(radio).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
@ -1486,17 +1481,7 @@ describe('Item', () => {
|
||||
const onSelect = vi.fn()
|
||||
const file = createMockOnlineDriveFile()
|
||||
const props = createItemProps({ file, onSelect, isMultipleChoice: false })
|
||||
render(
|
||||
<RadioGroup
|
||||
aria-label="Files"
|
||||
onValueChange={(fileId) => {
|
||||
if (fileId === file.id)
|
||||
onSelect(file)
|
||||
}}
|
||||
>
|
||||
<ActualItem {...props} />
|
||||
</RadioGroup>,
|
||||
)
|
||||
render(<ActualItem {...props} />)
|
||||
const radio = getRadio()
|
||||
fireEvent.click(radio)
|
||||
expect(onSelect).toHaveBeenCalledWith(file)
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Item from '../item'
|
||||
|
||||
vi.mock('@/app/components/base/radio/ui', () => ({
|
||||
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
|
||||
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../file-icon', () => ({
|
||||
default: () => <span data-testid="file-icon" />,
|
||||
}))
|
||||
@ -38,13 +43,8 @@ describe('Item', () => {
|
||||
})
|
||||
|
||||
it('should render radio for file type in single choice mode', () => {
|
||||
render(
|
||||
<RadioGroup aria-label="Files" value={defaultProps.file.id}>
|
||||
<Item {...defaultProps} isMultipleChoice={false} />
|
||||
</RadioGroup>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'test.pdf' })).toBeInTheDocument()
|
||||
render(<Item {...defaultProps} isMultipleChoice={false} />)
|
||||
expect(screen.getByTestId('radio')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render checkbox for bucket type', () => {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
@ -54,25 +53,6 @@ const List = ({
|
||||
const isPartialLoading = isLoading && fileList.length > 0
|
||||
const isEmptyFolder = !isLoading && fileList.length === 0 && keywords.length === 0
|
||||
const isSearchResultEmpty = !isLoading && fileList.length === 0 && keywords.length > 0
|
||||
const selectedFileId = selectedFileIds[0]
|
||||
const handleRadioChange = (fileId: string) => {
|
||||
const selectedFile = fileList.find(file => file.id === fileId)
|
||||
if (selectedFile)
|
||||
handleSelectFile(selectedFile)
|
||||
}
|
||||
const fileItems = fileList.map((file) => {
|
||||
const isSelected = selectedFileIds.includes(file.id)
|
||||
return (
|
||||
<Item
|
||||
key={file.id}
|
||||
file={file}
|
||||
isSelected={isSelected}
|
||||
onSelect={handleSelectFile}
|
||||
onOpen={handleOpenFolder}
|
||||
isMultipleChoice={supportBatchUpload}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="grow overflow-hidden p-1 pt-0">
|
||||
@ -93,18 +73,21 @@ const List = ({
|
||||
}
|
||||
{fileList.length > 0 && (
|
||||
<div className="flex h-full flex-col gap-y-px overflow-y-auto rounded-[10px] bg-background-section px-1 py-1.5">
|
||||
{supportBatchUpload
|
||||
? fileItems
|
||||
: (
|
||||
<RadioGroup
|
||||
aria-label={t('onlineDrive.breadcrumbs.allFiles', { ns: 'datasetPipeline' })}
|
||||
value={selectedFileId}
|
||||
onValueChange={handleRadioChange}
|
||||
className="contents"
|
||||
>
|
||||
{fileItems}
|
||||
</RadioGroup>
|
||||
)}
|
||||
{
|
||||
fileList.map((file) => {
|
||||
const isSelected = selectedFileIds.includes(file.id)
|
||||
return (
|
||||
<Item
|
||||
key={file.id}
|
||||
file={file}
|
||||
isSelected={isSelected}
|
||||
onSelect={handleSelectFile}
|
||||
onOpen={handleOpenFolder}
|
||||
isMultipleChoice={supportBatchUpload}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
{
|
||||
isPartialLoading && (
|
||||
<div
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import FileIcon from './file-icon'
|
||||
|
||||
@ -33,6 +33,11 @@ const Item = ({
|
||||
|
||||
const disabledTip = t('onlineDrive.notSupportedFileType', { ns: 'datasetPipeline' })
|
||||
|
||||
const handleSelect = useCallback((e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
onSelect(file)
|
||||
}, [file, onSelect])
|
||||
|
||||
const handleCheckboxSelect = useCallback(() => {
|
||||
onSelect(file)
|
||||
}, [file, onSelect])
|
||||
@ -65,14 +70,12 @@ const Item = ({
|
||||
</span>
|
||||
)}
|
||||
{!isBucket && !isMultipleChoice && (
|
||||
<span onClick={event => event.stopPropagation()}>
|
||||
<Radio
|
||||
className="shrink-0"
|
||||
disabled={disabled}
|
||||
value={file.id}
|
||||
aria-label={name}
|
||||
/>
|
||||
</span>
|
||||
<Radio
|
||||
className="shrink-0"
|
||||
disabled={disabled}
|
||||
isChecked={isSelected}
|
||||
onCheck={handleSelect}
|
||||
/>
|
||||
)}
|
||||
{disabled
|
||||
? (
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import CrawledResultItem from '../crawled-result-item'
|
||||
@ -10,6 +9,12 @@ vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/radio/ui', () => ({
|
||||
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
|
||||
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
|
||||
),
|
||||
}))
|
||||
|
||||
describe('CrawledResultItem', () => {
|
||||
const defaultProps = {
|
||||
payload: {
|
||||
@ -42,13 +47,8 @@ describe('CrawledResultItem', () => {
|
||||
})
|
||||
|
||||
it('should render radio in single choice mode', () => {
|
||||
render(
|
||||
<RadioGroup aria-label="Crawled pages" value={defaultProps.payload.source_url}>
|
||||
<CrawledResultItem {...defaultProps} isMultipleChoice={false} />
|
||||
</RadioGroup>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('radio', { name: /Test Page/ })).toBeInTheDocument()
|
||||
render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />)
|
||||
expect(screen.getByTestId('radio')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show preview button when showPreview is true', () => {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
@ -261,24 +260,18 @@ describe('CrawledResultItem', () => {
|
||||
|
||||
it('should toggle radio state when isMultipleChoice is false', () => {
|
||||
const mockOnCheckChange = vi.fn()
|
||||
render(
|
||||
<RadioGroup
|
||||
aria-label="Crawled pages"
|
||||
onValueChange={(sourceUrl) => {
|
||||
if (sourceUrl === defaultProps.payload.source_url)
|
||||
mockOnCheckChange(true)
|
||||
}}
|
||||
>
|
||||
<CrawledResultItem
|
||||
{...defaultProps}
|
||||
isMultipleChoice={false}
|
||||
isChecked={false}
|
||||
onCheckChange={mockOnCheckChange}
|
||||
/>
|
||||
</RadioGroup>,
|
||||
const { container } = render(
|
||||
<CrawledResultItem
|
||||
{...defaultProps}
|
||||
isMultipleChoice={false}
|
||||
isChecked={false}
|
||||
onCheckChange={mockOnCheckChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /Test Page Title/ }))
|
||||
// Act - Radio uses size-4 rounded-full classes
|
||||
const radio = container.querySelector('.size-4.rounded-full')!
|
||||
fireEvent.click(radio)
|
||||
|
||||
expect(mockOnCheckChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
@ -3,9 +3,10 @@ import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
|
||||
type CrawledResultItemProps = {
|
||||
payload: CrawlResultItemType
|
||||
@ -28,6 +29,10 @@ const CrawledResultItem = ({
|
||||
}: CrawledResultItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleCheckChange = useCallback(() => {
|
||||
onCheckChange(!isChecked)
|
||||
}, [isChecked, onCheckChange])
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative flex gap-x-2 rounded-lg p-2',
|
||||
@ -60,10 +65,11 @@ const CrawledResultItem = ({
|
||||
</label>
|
||||
)
|
||||
: (
|
||||
<label className="flex min-w-0 grow cursor-pointer gap-x-2">
|
||||
<>
|
||||
<Radio
|
||||
className="shrink-0"
|
||||
value={payload.source_url}
|
||||
isChecked={isChecked}
|
||||
onCheck={handleCheckChange}
|
||||
/>
|
||||
<div className="flex min-w-0 grow flex-col gap-y-0.5">
|
||||
<div
|
||||
@ -79,7 +85,7 @@ const CrawledResultItem = ({
|
||||
{payload.source_url}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{showPreview && (
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import type { CrawlResultItem } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -63,26 +62,6 @@ const CrawledResult = ({
|
||||
onPreview(list[index]!, index)
|
||||
}, [list, onPreview])
|
||||
|
||||
const selectedSourceUrl = checkedList[0]?.source_url
|
||||
const handleRadioChange = useCallback((sourceUrl: string) => {
|
||||
const selectedItem = list.find(item => item.source_url === sourceUrl)
|
||||
if (selectedItem)
|
||||
onSelectedChange([selectedItem])
|
||||
}, [list, onSelectedChange])
|
||||
|
||||
const resultItems = list.map((item, index) => (
|
||||
<CrawledResultItem
|
||||
key={item.source_url}
|
||||
payload={item}
|
||||
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
|
||||
onCheckChange={handleItemCheckChange(item)}
|
||||
isPreview={index === previewIndex}
|
||||
onPreview={handlePreview.bind(null, index)}
|
||||
showPreview={showPreview}
|
||||
isMultipleChoice={isMultipleChoice}
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)}>
|
||||
<div className="pt-2 system-sm-medium text-text-primary">
|
||||
@ -102,26 +81,20 @@ const CrawledResult = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isMultipleChoice
|
||||
? (
|
||||
<div className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2">
|
||||
{resultItems}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<RadioGroup
|
||||
aria-label={t(`${I18N_PREFIX}.scrapTimeInfo`, {
|
||||
ns: 'datasetCreation',
|
||||
total: list.length,
|
||||
time: usedTime.toFixed(1),
|
||||
})}
|
||||
value={selectedSourceUrl}
|
||||
onValueChange={handleRadioChange}
|
||||
className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2"
|
||||
>
|
||||
{resultItems}
|
||||
</RadioGroup>
|
||||
)}
|
||||
<div className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2">
|
||||
{list.map((item, index) => (
|
||||
<CrawledResultItem
|
||||
key={item.source_url}
|
||||
payload={item}
|
||||
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
|
||||
onCheckChange={handleItemCheckChange(item)}
|
||||
isPreview={index === previewIndex}
|
||||
onPreview={handlePreview.bind(null, index)}
|
||||
showPreview={showPreview}
|
||||
isMultipleChoice={isMultipleChoice}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -47,9 +47,12 @@ describe('DocTypeSelector', () => {
|
||||
})
|
||||
|
||||
it('should render icon buttons for each doc type', () => {
|
||||
render(<DocTypeSelector {...defaultProps} />)
|
||||
const { container } = render(<DocTypeSelector {...defaultProps} />)
|
||||
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(3)
|
||||
// Each doc type renders an IconButton wrapped in Radio
|
||||
const iconButtons = container.querySelectorAll('button[type="button"]')
|
||||
// 3 doc types + 1 confirm button = 4 buttons
|
||||
expect(iconButtons.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should render confirm button disabled when tempDocType is empty', () => {
|
||||
|
||||
@ -3,12 +3,9 @@ import type { FC } from 'react'
|
||||
import type { DocType } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import { useMetadataMap } from '@/hooks/use-metadata'
|
||||
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
|
||||
import s from '../style.module.css'
|
||||
@ -23,12 +20,12 @@ const IconButton: FC<{ type: DocType, isChecked: boolean }> = ({ type, isChecked
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
|
||||
<button type="button" className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
|
||||
<TypeIcon
|
||||
iconName={metadataMap[type].iconName || ''}
|
||||
className={`group-hover:bg-primary-600 ${isChecked ? 'bg-primary-600!' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
@ -56,7 +53,6 @@ const DocTypeSelector: FC<DocTypeSelectorProps> = ({
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const metadataMap = useMetadataMap()
|
||||
const isFirstTime = !docType && !documentType
|
||||
const currValue = tempDocType ?? documentType
|
||||
|
||||
@ -66,44 +62,22 @@ const DocTypeSelector: FC<DocTypeSelectorProps> = ({
|
||||
<div className={s.desc}>{t('metadata.desc', { ns: 'datasetDocuments' })}</div>
|
||||
)}
|
||||
<div className={s.operationWrapper}>
|
||||
<FieldRoot name="document_type" className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup
|
||||
value={currValue ?? ''}
|
||||
onValueChange={onTempDocTypeChange}
|
||||
className={s.radioGroup}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className={s.title}>
|
||||
{isFirstTime
|
||||
? t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })
|
||||
: t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}
|
||||
</FieldsetLegend>
|
||||
{documentType && (
|
||||
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
|
||||
)}
|
||||
{CUSTOMIZABLE_DOC_TYPES.map(type => (
|
||||
<FieldItem key={type}>
|
||||
<FieldLabel
|
||||
className={cn(
|
||||
s.radio,
|
||||
'focus-within:ring-2 focus-within:ring-components-input-border-hover focus-within:ring-offset-1 focus-within:outline-hidden',
|
||||
currValue === type && 'shadow-none',
|
||||
)}
|
||||
>
|
||||
<Radio
|
||||
value={type}
|
||||
aria-label={metadataMap[type].text}
|
||||
className="sr-only"
|
||||
/>
|
||||
<IconButton type={type} isChecked={currValue === type} />
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
{isFirstTime && (
|
||||
<span className={s.title}>{t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })}</span>
|
||||
)}
|
||||
{documentType && (
|
||||
<>
|
||||
<span className={s.title}>{t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}</span>
|
||||
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
|
||||
</>
|
||||
)}
|
||||
<Radio.Group value={currValue ?? ''} onChange={onTempDocTypeChange} className={s.radioGroup}>
|
||||
{CUSTOMIZABLE_DOC_TYPES.map(type => (
|
||||
<Radio key={type} value={type} className={`${s.radio} ${currValue === type ? 'shadow-none' : ''}`}>
|
||||
<IconButton type={type} isChecked={currValue === type} />
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
{isFirstTime && (
|
||||
<Button variant="primary" onClick={onConfirm} disabled={!tempDocType}>
|
||||
{t('metadata.firstMetaAction', { ns: 'datasetDocuments' })}
|
||||
|
||||
@ -15,20 +15,6 @@ vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
isCloudEdition: true,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.isCloudEdition
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const createApp = (overrides?: Partial<App>): App => ({
|
||||
can_trial: true,
|
||||
app_id: 'app-id',
|
||||
@ -76,7 +62,6 @@ describe('AppCard', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.isCloudEdition = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@ -128,13 +113,6 @@ describe('AppCard', () => {
|
||||
|
||||
expect(screen.getByText('explore.appCard.try')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide try button outside cloud edition', () => {
|
||||
mockConfig.isCloudEdition = false
|
||||
renderComponent({ canCreate: true, isExplore: true })
|
||||
|
||||
expect(screen.queryByText('explore.appCard.try')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
|
||||
@ -5,10 +5,11 @@ import { PlusIcon } from '@heroicons/react/20/solid'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiInformation2Line } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppTypeIcon } from '../../app/type-selector'
|
||||
|
||||
@ -29,7 +30,8 @@ const AppCard = ({
|
||||
}: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { app: appBasicInfo } = app
|
||||
const canViewApp = IS_CLOUD_EDITION
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
|
||||
const handleTryApp = () => {
|
||||
trackEvent('preview_template', {
|
||||
template_id: app.app_id,
|
||||
@ -76,9 +78,9 @@ const AppCard = ({
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
{isExplore && (canCreate || canViewApp) && (
|
||||
{isExplore && (canCreate || isTrialApp) && (
|
||||
<div className={cn('absolute right-0 bottom-0 left-0 hidden bg-linear-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 space-x-2', canCreate && canViewApp && 'grid-cols-2')}>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 space-x-2', canCreate && 'grid-cols-2')}>
|
||||
{
|
||||
canCreate && (
|
||||
<Button variant="primary" className="h-7" onClick={() => onCreate()}>
|
||||
@ -87,12 +89,10 @@ const AppCard = ({
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{canViewApp && (
|
||||
<Button className="h-7" onClick={handleTryApp}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button className="h-7" onClick={handleTryApp}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -51,20 +51,6 @@ vi.mock('@/utils/create-app-tracking', () => ({
|
||||
trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args),
|
||||
}))
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
isCloudEdition: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.isCloudEdition
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/explore/create-app-modal', () => ({
|
||||
default: (props: CreateAppModalProps) => {
|
||||
if (!props.show)
|
||||
@ -151,7 +137,6 @@ const mockMemberRole = (hasEditPermission: boolean) => {
|
||||
|
||||
type RenderOptions = {
|
||||
enableExploreBanner?: boolean
|
||||
isCloudEdition?: boolean
|
||||
}
|
||||
|
||||
const renderAppList = (
|
||||
@ -160,7 +145,6 @@ const renderAppList = (
|
||||
searchParams?: Record<string, string>,
|
||||
options: RenderOptions = {},
|
||||
) => {
|
||||
mockConfig.isCloudEdition = options.isCloudEdition ?? false
|
||||
mockMemberRole(hasEditPermission)
|
||||
const { wrapper: SystemFeaturesWrapper, queryClient } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { enable_explore_banner: options.enableExploreBanner ?? false },
|
||||
@ -182,7 +166,6 @@ describe('AppList', () => {
|
||||
mockExploreData = { categories: [], allList: [] }
|
||||
mockIsLoading = false
|
||||
mockIsError = false
|
||||
mockConfig.isCloudEdition = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -417,7 +400,7 @@ describe('AppList', () => {
|
||||
allList: [createApp()],
|
||||
}
|
||||
|
||||
renderAppList(true, undefined, undefined, { isCloudEdition: true })
|
||||
renderAppList(true)
|
||||
|
||||
fireEvent.click(screen.getByText('explore.appCard.try'))
|
||||
expect(screen.getByTestId('try-app-panel')).toBeInTheDocument()
|
||||
@ -440,7 +423,7 @@ describe('AppList', () => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderAppList(true, undefined, undefined, { isCloudEdition: true })
|
||||
renderAppList(true)
|
||||
|
||||
fireEvent.click(screen.getByText('explore.appCard.try'))
|
||||
fireEvent.click(screen.getByTestId('try-app-create'))
|
||||
@ -461,7 +444,7 @@ describe('AppList', () => {
|
||||
allList: [createApp()],
|
||||
}
|
||||
|
||||
renderAppList(true, undefined, undefined, { isCloudEdition: true })
|
||||
renderAppList(true)
|
||||
|
||||
fireEvent.click(screen.getByText('explore.appCard.try'))
|
||||
expect(screen.getByTestId('try-app-panel')).toBeInTheDocument()
|
||||
|
||||
@ -13,13 +13,11 @@ import type {
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import RadioE from '@/app/components/base/radio/ui'
|
||||
import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector'
|
||||
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
|
||||
import MultipleToolSelector from '@/app/components/plugins/plugin-detail-panel/multiple-tool-selector'
|
||||
@ -220,53 +218,41 @@ function Form<
|
||||
|
||||
const disabled = isEditMode && (variable === '__model_type' || variable === '__model_name')
|
||||
const gridColumnsClassName = radioGridColumnsClassNames[options.length] ?? 'grid-cols-1'
|
||||
const selectedValue = typeof value[variable] === 'string' ? value[variable] : undefined
|
||||
const translatedLabel = label[language] || label.en_US
|
||||
|
||||
return (
|
||||
<FieldRoot key={variable} name={variable} className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup
|
||||
value={selectedValue}
|
||||
onValueChange={val => handleFormChange(variable, val)}
|
||||
className={cn(itemClassName, 'grid gap-3 py-3', gridColumnsClassName)}
|
||||
/>
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className={cn(fieldLabelClassName, 'col-span-full flex items-center py-2 system-sm-semibold text-text-secondary')}>
|
||||
<span>{translatedLabel}</span>
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
)}
|
||||
{infotipContent}
|
||||
</FieldsetLegend>
|
||||
{infotipContent}
|
||||
</div>
|
||||
<div className={cn('grid gap-3', gridColumnsClassName)}>
|
||||
{options.filter((option) => {
|
||||
if (option.show_on.length)
|
||||
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
|
||||
|
||||
return true
|
||||
}).map(option => (
|
||||
<FieldItem key={`${variable}-${option.value}`} className="min-w-0">
|
||||
<FieldLabel
|
||||
className={`
|
||||
<div
|
||||
className={`
|
||||
flex cursor-pointer items-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg px-3 py-2
|
||||
${value[variable] === option.value && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm'}
|
||||
${disabled && 'cursor-not-allowed! opacity-60'}
|
||||
`}
|
||||
>
|
||||
<Radio value={option.value} disabled={disabled} />
|
||||
onClick={() => handleFormChange(variable, option.value)}
|
||||
key={`${variable}-${option.value}`}
|
||||
>
|
||||
<RadioE isChecked={value[variable] === option.value} />
|
||||
|
||||
<div className="system-sm-regular text-text-secondary">{option.label[language] || option.label.en_US}</div>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<div className="system-sm-regular text-text-secondary">{option.label[language] || option.label.en_US}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="col-span-full">
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
</div>
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -341,44 +327,26 @@ function Form<
|
||||
|
||||
if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
|
||||
return null
|
||||
const booleanValue = typeof value[variable] === 'boolean' ? value[variable] : undefined
|
||||
const translatedLabel = label[language] || label.en_US
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<FieldRoot name={variable} className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<boolean>
|
||||
className="flex items-center justify-between gap-3 py-2"
|
||||
value={booleanValue}
|
||||
onValueChange={val => handleFormChange(variable, val)}
|
||||
/>
|
||||
<div className="flex items-center justify-between py-2 system-sm-semibold text-text-secondary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}>{label[language] || label.en_US}</span>
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
)}
|
||||
{infotipContent}
|
||||
</div>
|
||||
<Radio.Group
|
||||
className="flex items-center"
|
||||
value={value[variable]}
|
||||
onChange={val => handleFormChange(variable, val)}
|
||||
>
|
||||
<FieldsetLegend className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}>
|
||||
<span>{translatedLabel}</span>
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
)}
|
||||
{infotipContent}
|
||||
</FieldsetLegend>
|
||||
<div className="flex items-center gap-3">
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={true} />
|
||||
True
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={false} />
|
||||
False
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
<Radio value={true} className="mr-1!">True</Radio>
|
||||
<Radio value={false}>False</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -4,10 +4,6 @@ import type {
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select'
|
||||
import { Slider } from '@langgenius/dify-ui/slider'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
@ -15,6 +11,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import TagInput from '@/app/components/base/tag-input'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useLanguage } from '../hooks'
|
||||
@ -221,35 +218,15 @@ function ParameterItem({
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'boolean') {
|
||||
const booleanValue = typeof renderValue === 'boolean' ? renderValue : undefined
|
||||
const translatedLabel = parameterRule.label[language] || parameterRule.label.en_US
|
||||
|
||||
return (
|
||||
<FieldRoot name={parameterRule.name} className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<boolean>
|
||||
className="w-[150px] gap-3"
|
||||
value={booleanValue}
|
||||
onValueChange={handleRadioChange}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="sr-only">{translatedLabel}</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex w-[70px] items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={true} />
|
||||
True
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex w-[70px] items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={false} />
|
||||
False
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
<Radio.Group
|
||||
className="flex w-[150px] items-center"
|
||||
value={renderValue as boolean}
|
||||
onChange={handleRadioChange}
|
||||
>
|
||||
<Radio value={true} className="w-[70px] px-[18px]">True</Radio>
|
||||
<Radio value={false} className="w-[70px] px-[18px]">False</Radio>
|
||||
</Radio.Group>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -12,14 +12,11 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
|
||||
|
||||
type Props = {
|
||||
@ -33,21 +30,22 @@ type ItemProps = {
|
||||
text: string
|
||||
value: AuthType | AuthHeaderPrefix
|
||||
isChecked: boolean
|
||||
onClick: (value: AuthType | AuthHeaderPrefix) => void
|
||||
}
|
||||
|
||||
function SelectItem({ text, value, isChecked }: ItemProps) {
|
||||
function SelectItem({ text, value, isChecked, onClick }: ItemProps) {
|
||||
return (
|
||||
<FieldItem>
|
||||
<FieldLabel
|
||||
className={cn(
|
||||
isChecked ? 'border-2 border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' : 'border border-components-card-border',
|
||||
'mb-2 flex h-9 w-37.5 cursor-pointer items-center space-x-2 rounded-xl bg-components-panel-on-panel-item-bg pl-3 text-left outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
|
||||
)}
|
||||
>
|
||||
<Radio value={value} />
|
||||
<div className="system-sm-regular text-text-primary">{text}</div>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
isChecked ? 'border-2 border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' : 'border border-components-card-border',
|
||||
'mb-2 flex h-9 w-37.5 cursor-pointer items-center space-x-2 rounded-xl bg-components-panel-on-panel-item-bg pl-3 text-left outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
|
||||
)}
|
||||
onClick={() => onClick(value)}
|
||||
>
|
||||
<Radio isChecked={isChecked} />
|
||||
<div className="system-sm-regular text-text-primary">{text}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -59,28 +57,6 @@ export default function ConfigCredential({
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [tempCredential, setTempCredential] = useState<Credential>(credential)
|
||||
const handleAuthTypeChange = (value: AuthType) => {
|
||||
if (value === AuthType.none) {
|
||||
setTempCredential({ auth_type: value })
|
||||
return
|
||||
}
|
||||
|
||||
if (value === AuthType.apiKeyHeader) {
|
||||
setTempCredential({
|
||||
auth_type: value,
|
||||
api_key_header: tempCredential.api_key_header || 'Authorization',
|
||||
api_key_value: tempCredential.api_key_value || '',
|
||||
api_key_header_prefix: tempCredential.api_key_header_prefix || AuthHeaderPrefix.custom,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setTempCredential({
|
||||
auth_type: value,
|
||||
api_key_query_param: tempCredential.api_key_query_param || 'key',
|
||||
api_key_value: tempCredential.api_key_value || '',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@ -118,68 +94,65 @@ export default function ConfigCredential({
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-2">
|
||||
<div className="space-y-4">
|
||||
<FieldRoot name="auth_type" className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<AuthType>
|
||||
className="space-x-3"
|
||||
value={tempCredential.auth_type}
|
||||
onValueChange={handleAuthTypeChange}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.authMethod.type', { ns: 'tools' })}
|
||||
</FieldsetLegend>
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.type', { ns: 'tools' })}</div>
|
||||
<div className="flex space-x-3">
|
||||
<SelectItem
|
||||
text={t('createTool.authMethod.types.none', { ns: 'tools' })}
|
||||
value={AuthType.none}
|
||||
isChecked={tempCredential.auth_type === AuthType.none}
|
||||
onClick={value => setTempCredential({
|
||||
auth_type: value as AuthType,
|
||||
})}
|
||||
/>
|
||||
<SelectItem
|
||||
text={t('createTool.authMethod.types.api_key_header', { ns: 'tools' })}
|
||||
value={AuthType.apiKeyHeader}
|
||||
isChecked={tempCredential.auth_type === AuthType.apiKeyHeader}
|
||||
onClick={value => setTempCredential({
|
||||
auth_type: value as AuthType,
|
||||
api_key_header: tempCredential.api_key_header || 'Authorization',
|
||||
api_key_value: tempCredential.api_key_value || '',
|
||||
api_key_header_prefix: tempCredential.api_key_header_prefix || AuthHeaderPrefix.custom,
|
||||
})}
|
||||
/>
|
||||
<SelectItem
|
||||
text={t('createTool.authMethod.types.api_key_query', { ns: 'tools' })}
|
||||
value={AuthType.apiKeyQuery}
|
||||
isChecked={tempCredential.auth_type === AuthType.apiKeyQuery}
|
||||
onClick={value => setTempCredential({
|
||||
auth_type: value as AuthType,
|
||||
api_key_query_param: tempCredential.api_key_query_param || 'key',
|
||||
api_key_value: tempCredential.api_key_value || '',
|
||||
})}
|
||||
/>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
</div>
|
||||
</div>
|
||||
{tempCredential.auth_type === AuthType.apiKeyHeader && (
|
||||
<>
|
||||
<FieldRoot name="api_key_header_prefix" className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<AuthHeaderPrefix>
|
||||
className="space-x-3"
|
||||
value={tempCredential.api_key_header_prefix}
|
||||
onValueChange={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value })}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.authHeaderPrefix.title', { ns: 'tools' })}
|
||||
</FieldsetLegend>
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authHeaderPrefix.title', { ns: 'tools' })}</div>
|
||||
<div className="flex space-x-3">
|
||||
<SelectItem
|
||||
text={t('createTool.authHeaderPrefix.types.basic', { ns: 'tools' })}
|
||||
value={AuthHeaderPrefix.basic}
|
||||
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic}
|
||||
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
|
||||
/>
|
||||
<SelectItem
|
||||
text={t('createTool.authHeaderPrefix.types.bearer', { ns: 'tools' })}
|
||||
value={AuthHeaderPrefix.bearer}
|
||||
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer}
|
||||
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
|
||||
/>
|
||||
<SelectItem
|
||||
text={t('createTool.authHeaderPrefix.types.custom', { ns: 'tools' })}
|
||||
value={AuthHeaderPrefix.custom}
|
||||
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom}
|
||||
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
|
||||
/>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.authMethod.key', { ns: 'tools' })}
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { changePasswordWithToken } from '@/service/common'
|
||||
import { useVerifyForgotPasswordToken } from '@/service/use-common'
|
||||
import ChangePasswordForm from './ChangePasswordForm'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams('token=url-token-t1'),
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useVerifyForgotPasswordToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
changePasswordWithToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({ basePath: '' }))
|
||||
|
||||
type UseVerifyResult = ReturnType<typeof useVerifyForgotPasswordToken>
|
||||
const mockUseVerify = vi.mocked(useVerifyForgotPasswordToken)
|
||||
const mockChangePassword = vi.mocked(changePasswordWithToken)
|
||||
|
||||
const VALID_PASSWORD = 'ValidPass123!'
|
||||
|
||||
describe('ChangePasswordForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('when token is valid', () => {
|
||||
const T2 = 'verified-token-t2'
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseVerify.mockReturnValue({
|
||||
data: { result: 'success', is_valid: true, email: 'user@example.com', token: T2 },
|
||||
refetch: vi.fn(),
|
||||
} as unknown as UseVerifyResult)
|
||||
})
|
||||
|
||||
it('renders the password form', () => {
|
||||
render(<ChangePasswordForm />)
|
||||
expect(screen.getByText('login.changePassword')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits with T2 (from validity response), NOT T1 (from URL)', async () => {
|
||||
mockChangePassword.mockResolvedValue({ result: 'success' })
|
||||
|
||||
render(<ChangePasswordForm />)
|
||||
|
||||
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('input[type="password"]')) as [HTMLInputElement, HTMLInputElement]
|
||||
fireEvent.change(inputs[0], { target: { value: VALID_PASSWORD } })
|
||||
fireEvent.change(inputs[1], { target: { value: VALID_PASSWORD } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.reset/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangePassword).toHaveBeenCalledWith({
|
||||
url: '/forgot-password/resets',
|
||||
body: {
|
||||
token: T2,
|
||||
new_password: VALID_PASSWORD,
|
||||
password_confirm: VALID_PASSWORD,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when token is invalid', () => {
|
||||
beforeEach(() => {
|
||||
mockUseVerify.mockReturnValue({
|
||||
data: { result: 'success', is_valid: false, email: '', token: '' },
|
||||
refetch: vi.fn(),
|
||||
} as unknown as UseVerifyResult)
|
||||
})
|
||||
|
||||
it('shows invalid token state and no form', () => {
|
||||
render(<ChangePasswordForm />)
|
||||
expect(screen.getByText('login.invalid')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /common\.operation\.reset/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -49,7 +49,7 @@ const ChangePasswordForm = () => {
|
||||
}, [password, confirmPassword, showErrorMessage, t])
|
||||
|
||||
const handleChangePassword = useCallback(async () => {
|
||||
const resetToken = verifyTokenRes?.token ?? ''
|
||||
const token = searchParams.get('token') || ''
|
||||
|
||||
if (!valid())
|
||||
return
|
||||
@ -57,7 +57,7 @@ const ChangePasswordForm = () => {
|
||||
await changePasswordWithToken({
|
||||
url: '/forgot-password/resets',
|
||||
body: {
|
||||
token: resetToken,
|
||||
token,
|
||||
new_password: password,
|
||||
password_confirm: confirmPassword,
|
||||
},
|
||||
@ -67,7 +67,7 @@ const ChangePasswordForm = () => {
|
||||
catch {
|
||||
await revalidateToken()
|
||||
}
|
||||
}, [confirmPassword, password, revalidateToken, verifyTokenRes?.token, valid])
|
||||
}, [confirmPassword, password, revalidateToken, searchParams, valid])
|
||||
|
||||
return (
|
||||
<div className={
|
||||
|
||||
@ -547,4 +547,4 @@ Test examples in the project:
|
||||
[Testing Library Best Practices]: https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
|
||||
[Vitest Documentation]: https://vitest.dev/guide
|
||||
[Vitest Mocking Guide]: https://vitest.dev/guide/mocking.html
|
||||
[index.spec.tsx]: ../app/components/base/action-button/__tests__/index.spec.tsx
|
||||
[index.spec.tsx]: ../app/components/base/radio/__tests__/index.spec.tsx
|
||||
|
||||
@ -48,21 +48,10 @@ const FLOATING_UI_RESTRICTED_IMPORT_PATTERNS = [
|
||||
},
|
||||
]
|
||||
|
||||
const LEGACY_WEB_INPUT_RESTRICTED_IMPORT_PATTERNS = [
|
||||
{
|
||||
group: [
|
||||
'**/base/input',
|
||||
'**/base/input/*',
|
||||
],
|
||||
message: 'Do not import the deprecated web base Input. Use @langgenius/dify-ui/input for standalone inputs, and @langgenius/dify-ui/field for labelled or validated form composition.',
|
||||
},
|
||||
]
|
||||
|
||||
export const WEB_RESTRICTED_IMPORT_PATTERNS = [
|
||||
...NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
|
||||
...BASE_UI_RESTRICTED_IMPORT_PATTERNS,
|
||||
...FLOATING_UI_RESTRICTED_IMPORT_PATTERNS,
|
||||
...LEGACY_WEB_INPUT_RESTRICTED_IMPORT_PATTERNS,
|
||||
]
|
||||
|
||||
export const HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS = {
|
||||
|
||||
@ -374,13 +374,13 @@ Options:
|
||||
|
||||
Examples:
|
||||
# Analyze a component and generate test prompt
|
||||
pnpm analyze-component app/components/base/action-button/index.tsx
|
||||
pnpm analyze-component app/components/base/radio/index.tsx
|
||||
|
||||
# Output as JSON
|
||||
pnpm analyze-component app/components/base/action-button/index.tsx --json
|
||||
pnpm analyze-component app/components/base/radio/index.tsx --json
|
||||
|
||||
# Review existing test
|
||||
pnpm analyze-component app/components/base/action-button/index.tsx --review
|
||||
pnpm analyze-component app/components/base/radio/index.tsx --review
|
||||
|
||||
For complete testing guidelines, see: web/docs/test.md
|
||||
`)
|
||||
|
||||
@ -250,7 +250,7 @@ export const useLogout = () => {
|
||||
})
|
||||
}
|
||||
|
||||
type ForgotPasswordValidity = CommonResponse & { is_valid: boolean, email: string, token: string }
|
||||
type ForgotPasswordValidity = CommonResponse & { is_valid: boolean, email: string }
|
||||
export const useVerifyForgotPasswordToken = (token?: string | null) => {
|
||||
return useQuery<ForgotPasswordValidity>({
|
||||
queryKey: commonQueryKeys.forgotPasswordValidity(token),
|
||||
|
||||
Reference in New Issue
Block a user