Compare commits
280
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fa13cdf86 | ||
|
|
39de7673eb | ||
|
|
d930d8cc4a | ||
|
|
97626a3ba5 | ||
|
|
b7f7d04639 | ||
|
|
13674bd859 | ||
|
|
fb9cbc0471 | ||
|
|
2f60288d86 | ||
|
|
ee3ded0fc2 | ||
|
|
351bad9ec4 | ||
|
|
9bf7473bbf | ||
|
|
fa09c88f5c | ||
|
|
83df78d0c8 | ||
|
|
79266f7302 | ||
|
|
7fecc7236c | ||
|
|
9c7f6b7b71 | ||
|
|
b46da93e99 | ||
|
|
e299a1fb20 | ||
|
|
122033cadb | ||
|
|
df9bd1b3b5 | ||
|
|
f74492eb59 | ||
|
|
eaf1ae37dd | ||
|
|
8e3b412ff6 | ||
|
|
ba17f576e9 | ||
|
|
c11cdf7468 | ||
|
|
6217c96576 | ||
|
|
977690590e | ||
|
|
fd845c8b6c | ||
|
|
d7d9abb007 | ||
|
|
9f22b2726b | ||
|
|
f28b519556 | ||
|
|
762cf91133 | ||
|
|
9dd3dcff2b | ||
|
|
9415ce4512 | ||
|
|
239536933b | ||
|
|
80b34598e9 | ||
|
|
9c66b92c34 | ||
|
|
79872ea5e2 | ||
|
|
cbf181bd76 | ||
|
|
1393d21858 | ||
|
|
3a46b7bd18 | ||
|
|
0bbfd81d26 | ||
|
|
86db517142 | ||
|
|
50151f4007 | ||
|
|
0395d1f91f | ||
|
|
5f4c1e4057 | ||
|
|
d14413f3b0 | ||
|
|
4fd968270c | ||
|
|
708a7dd362 | ||
|
|
cd85b75312 | ||
|
|
d685da377e | ||
|
|
8583992d23 | ||
|
|
23fec75c90 | ||
|
|
ebe7303894 | ||
|
|
79fb977f10 | ||
|
|
c0af3414a3 | ||
|
|
1857d37fae | ||
|
|
60fdbb56a9 | ||
|
|
4c7853164d | ||
|
|
6c7a3ce4bb | ||
|
|
a9e74b21f1 | ||
|
|
e6730f7164 | ||
|
|
3344723393 | ||
|
|
c571185a91 | ||
|
|
325c1cfa41 | ||
|
|
1069421753 | ||
|
|
b33a97ea5b | ||
|
|
d2c1d4c337 | ||
|
|
67762cf1d8 | ||
|
|
eadce0287c | ||
|
|
ecaff5b63f | ||
|
|
a300c9ef96 | ||
|
|
44fe71e4db | ||
|
|
0ac32188c5 | ||
|
|
9aaace706b | ||
|
|
b22de5a824 | ||
|
|
97463661c1 | ||
|
|
239a11855a | ||
|
|
0632557d91 | ||
|
|
44be7d4c51 | ||
|
|
efb4a9d327 | ||
|
|
a077a3f609 | ||
|
|
3ccec0aab0 | ||
|
|
3006133f0e | ||
|
|
79beb25530 | ||
|
|
b47b228164 | ||
|
|
be91db14d9 | ||
|
|
120893209e | ||
|
|
f19630bcf5 | ||
|
|
9d93fda471 | ||
|
|
d986659add | ||
|
|
00dab7ca5f | ||
|
|
a4add403fb | ||
|
|
e9cdc96c74 | ||
|
|
6af1fea232 | ||
|
|
45d5d9e44f | ||
|
|
376a084aca | ||
|
|
d1f42d47fe | ||
|
|
64b8fd87ad | ||
|
|
364be48248 | ||
|
|
2bce046278 | ||
|
|
1120d552b6 | ||
|
|
69cab0817f | ||
|
|
c4d03bf378 | ||
|
|
6c039be2ca | ||
|
|
832dabc8a4 | ||
|
|
1da2028d9d | ||
|
|
7c3f6dcc8d | ||
|
|
1472884eb5 | ||
|
|
ec22b1c706 | ||
|
|
a1712df7c2 | ||
|
|
a40e11cb3e | ||
|
|
61c46bea40 | ||
|
|
1c5c28a82c | ||
|
|
2310145937 | ||
|
|
6a9c9cadd0 | ||
|
|
7774ff9944 | ||
|
|
33d4c95470 | ||
|
|
659cbc05a9 | ||
|
|
6ce65de2cd | ||
|
|
93b2eb3ff6 | ||
|
|
bf71300635 | ||
|
|
37ecd4a0bc | ||
|
|
827a1b181b | ||
|
|
c4e7cb75cd | ||
|
|
98e4bfcda8 | ||
|
|
ee48ca7671 | ||
|
|
4ba6de1116 | ||
|
|
bfbe636555 | ||
|
|
54ae43ef47 | ||
|
|
7a74b5ee3e | ||
|
|
0e9d43d605 | ||
|
|
cc54363c27 | ||
|
|
89affe3139 | ||
|
|
2c4977dbb1 | ||
|
|
e240175116 | ||
|
|
2398ed6fe8 | ||
|
|
a8420ac33c | ||
|
|
8470be6411 | ||
|
|
3d6295c622 | ||
|
|
ff2f7206f3 | ||
|
|
b937fc8978 | ||
|
|
86a9a51952 | ||
|
|
4188c9a1dd | ||
|
|
8c00f89e36 | ||
|
|
9e8ac5c96b | ||
|
|
05a67f4716 | ||
|
|
f49476a206 | ||
|
|
c1e9c56e25 | ||
|
|
d5dd73cacf | ||
|
|
21f7a49b4e | ||
|
|
716ac04e13 | ||
|
|
c28a32fc47 | ||
|
|
31cba28e8a | ||
|
|
48cd7e6481 | ||
|
|
47aba1c9f9 | ||
|
|
0f3f8bc0d9 | ||
|
|
e0df12c212 | ||
|
|
eb448d9bb8 | ||
|
|
0ba77f13db | ||
|
|
f0a2eb843c | ||
|
|
5cf3d9e4d9 | ||
|
|
41958f55cd | ||
|
|
600ad232e1 | ||
|
|
7a3825cfce | ||
|
|
9519653422 | ||
|
|
efa2307c73 | ||
|
|
068fa3d0e3 | ||
|
|
13d8dbd542 | ||
|
|
b442ba8b2b | ||
|
|
10e36d2355 | ||
|
|
13c53fedad | ||
|
|
4bda1bd884 | ||
|
|
3abe7850d6 | ||
|
|
b50284d864 | ||
|
|
81c6e52401 | ||
|
|
847d257366 | ||
|
|
687662cf1f | ||
|
|
6432d98469 | ||
|
|
088ccf8b8d | ||
|
|
e8683bf957 | ||
|
|
4653981b6b | ||
|
|
e2547413d3 | ||
|
|
ea17f41b5b | ||
|
|
29178d8adf | ||
|
|
7e86ead574 | ||
|
|
72debcb228 | ||
|
|
72737dabc7 | ||
|
|
f6e5cb4381 | ||
|
|
ffad3b5fb1 | ||
|
|
cba9fc3020 | ||
|
|
e776accaf3 | ||
|
|
3eac26929a | ||
|
|
4d3adec738 | ||
|
|
89bed479e4 | ||
|
|
fdd673a3a9 | ||
|
|
22f6d285c7 | ||
|
|
10aa16b471 | ||
|
|
b3838581fd | ||
|
|
affbe7ccdb | ||
|
|
dd8577f832 | ||
|
|
d7f5da5df4 | ||
|
|
9fda130b3a | ||
|
|
72cdbdba0f | ||
|
|
b92a153902 | ||
|
|
9f2927979b | ||
|
|
75257232c3 | ||
|
|
1721314c62 | ||
|
|
fc230bcc59 | ||
|
|
b4636ddf44 | ||
|
|
b1140301a4 | ||
|
|
58cd785da6 | ||
|
|
2035186cd2 | ||
|
|
53ba6aadff | ||
|
|
f091868b7c | ||
|
|
89bedae0d3 | ||
|
|
c8acc48976 | ||
|
|
21fee59b22 | ||
|
|
957a8253f8 | ||
|
|
d5fc3e7bed | ||
|
|
ab438b42da | ||
|
|
3867fece4a | ||
|
|
2b908d4fbe | ||
|
|
8ff062ec8b | ||
|
|
294fc41aec | ||
|
|
684f7df158 | ||
|
|
c3287755e3 | ||
|
|
9f97f4d79e | ||
|
|
34eb421649 | ||
|
|
850b05573e | ||
|
|
6ec8bfdfee | ||
|
|
81638c248e | ||
|
|
2e11b1298e | ||
|
|
20320f3a27 | ||
|
|
4019c12d26 | ||
|
|
cf72184ce4 | ||
|
|
ca8d15bc64 | ||
|
|
a91c897fd3 | ||
|
|
816bdf0320 | ||
|
|
d4a6acbd99 | ||
|
|
e421db4005 | ||
|
|
9067c2a9c1 | ||
|
|
9f7321ca1a | ||
|
|
5fa01132b9 | ||
|
|
e082b6d599 | ||
|
|
d44be2d835 | ||
|
|
7dc8557033 | ||
|
|
72037a1865 | ||
|
|
2d1621c43d | ||
|
|
d1a5db3310 | ||
|
|
ad8fd8fecc | ||
|
|
be74b76079 | ||
|
|
dd64af728f | ||
|
|
e43b46786d | ||
|
|
3f3b37b843 | ||
|
|
2ecf9f6ddf | ||
|
|
48c069fe68 | ||
|
|
9c5c597c85 | ||
|
|
c2eec8545d | ||
|
|
2395d4be26 | ||
|
|
9455476705 | ||
|
|
494e223706 | ||
|
|
348fd18230 | ||
|
|
7233b4de55 | ||
|
|
af6df05685 | ||
|
|
965b65db6e | ||
|
|
4cc01c8aa8 | ||
|
|
41372168b6 | ||
|
|
f4438b0a08 | ||
|
|
897c842637 | ||
|
|
ee86ceb906 | ||
|
|
e298732499 | ||
|
|
4081937e22 | ||
|
|
f9aedb2118 | ||
|
|
74b4719af8 | ||
|
|
2f35cc9188 | ||
|
|
2f966d8c38 | ||
|
|
b0868d9136 | ||
|
|
37440e9416 | ||
|
|
0d7d27ec0b |
@@ -30,6 +30,9 @@ INTERNAL_FILES_URL=http://127.0.0.1:5001
|
||||
# The time in seconds after the signature is rejected
|
||||
FILES_ACCESS_TIMEOUT=300
|
||||
|
||||
# Collaboration mode toggle
|
||||
ENABLE_COLLABORATION_MODE=false
|
||||
|
||||
# Access token expiration time in minutes
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
|
||||
+17
-3
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
@@ -8,10 +9,16 @@ def is_db_command():
|
||||
|
||||
|
||||
# create app
|
||||
celery = None
|
||||
flask_app = None
|
||||
socketio_app = None
|
||||
|
||||
if is_db_command():
|
||||
from app_factory import create_migrations_app
|
||||
|
||||
app = create_migrations_app()
|
||||
socketio_app = app
|
||||
flask_app = app
|
||||
else:
|
||||
# It seems that JetBrains Python debugger does not work well with gevent,
|
||||
# so we need to disable gevent in debug mode.
|
||||
@@ -33,8 +40,15 @@ else:
|
||||
|
||||
from app_factory import create_app
|
||||
|
||||
app = create_app()
|
||||
celery = app.extensions["celery"]
|
||||
socketio_app, flask_app = create_app()
|
||||
app = flask_app
|
||||
celery = flask_app.extensions["celery"]
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5001)
|
||||
from gevent import pywsgi
|
||||
from geventwebsocket.handler import WebSocketHandler
|
||||
|
||||
host = os.environ.get("HOST", "0.0.0.0")
|
||||
port = int(os.environ.get("PORT", 5001))
|
||||
server = pywsgi.WSGIServer((host, port), socketio_app, handler_class=WebSocketHandler)
|
||||
server.serve_forever()
|
||||
|
||||
+10
-2
@@ -31,14 +31,22 @@ def create_flask_app_with_configs() -> DifyApp:
|
||||
return dify_app
|
||||
|
||||
|
||||
def create_app() -> DifyApp:
|
||||
def create_app() -> tuple[any, DifyApp]:
|
||||
start_time = time.perf_counter()
|
||||
app = create_flask_app_with_configs()
|
||||
initialize_extensions(app)
|
||||
|
||||
import socketio
|
||||
|
||||
from extensions.ext_socketio import sio
|
||||
|
||||
sio.app = app
|
||||
socketio_app = socketio.WSGIApp(sio, app)
|
||||
|
||||
end_time = time.perf_counter()
|
||||
if dify_config.DEBUG:
|
||||
logger.info("Finished create_app (%s ms)", round((end_time - start_time) * 1000, 2))
|
||||
return app
|
||||
return socketio_app, app
|
||||
|
||||
|
||||
def initialize_extensions(app: DifyApp):
|
||||
|
||||
@@ -1048,6 +1048,13 @@ class PositionConfig(BaseSettings):
|
||||
return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""}
|
||||
|
||||
|
||||
class CollaborationConfig(BaseSettings):
|
||||
ENABLE_COLLABORATION_MODE: bool = Field(
|
||||
description="Whether to enable collaboration mode features across the workspace",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
class LoginConfig(BaseSettings):
|
||||
ENABLE_EMAIL_CODE_LOGIN: bool = Field(
|
||||
description="whether to enable email code login",
|
||||
@@ -1136,6 +1143,7 @@ class FeatureConfig(
|
||||
WorkflowConfig,
|
||||
WorkflowNodeExecutionConfig,
|
||||
WorkspaceConfig,
|
||||
CollaborationConfig,
|
||||
LoginConfig,
|
||||
AccountConfig,
|
||||
SwaggerUIConfig,
|
||||
|
||||
@@ -58,11 +58,13 @@ from .app import (
|
||||
mcp_server,
|
||||
message,
|
||||
model_config,
|
||||
online_user,
|
||||
ops_trace,
|
||||
site,
|
||||
statistic,
|
||||
workflow,
|
||||
workflow_app_log,
|
||||
workflow_comment,
|
||||
workflow_draft_variable,
|
||||
workflow_run,
|
||||
workflow_statistic,
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from werkzeug.wrappers import Request as WerkzeugRequest
|
||||
|
||||
from extensions.ext_redis import redis_client
|
||||
from extensions.ext_socketio import sio
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_access_token
|
||||
from services.account_service import AccountService
|
||||
|
||||
SESSION_STATE_TTL_SECONDS = 3600
|
||||
WORKFLOW_ONLINE_USERS_PREFIX = "workflow_online_users:"
|
||||
WORKFLOW_LEADER_PREFIX = "workflow_leader:"
|
||||
WS_SID_MAP_PREFIX = "ws_sid_map:"
|
||||
|
||||
|
||||
def _workflow_key(workflow_id: str) -> str:
|
||||
return f"{WORKFLOW_ONLINE_USERS_PREFIX}{workflow_id}"
|
||||
|
||||
|
||||
def _leader_key(workflow_id: str) -> str:
|
||||
return f"{WORKFLOW_LEADER_PREFIX}{workflow_id}"
|
||||
|
||||
|
||||
def _sid_key(sid: str) -> str:
|
||||
return f"{WS_SID_MAP_PREFIX}{sid}"
|
||||
|
||||
|
||||
def _refresh_session_state(workflow_id: str, sid: str) -> None:
|
||||
"""
|
||||
Refresh TTLs for workflow + session keys so healthy sessions do not linger forever after crashes.
|
||||
"""
|
||||
workflow_key = _workflow_key(workflow_id)
|
||||
sid_key = _sid_key(sid)
|
||||
if redis_client.exists(workflow_key):
|
||||
redis_client.expire(workflow_key, SESSION_STATE_TTL_SECONDS)
|
||||
if redis_client.exists(sid_key):
|
||||
redis_client.expire(sid_key, SESSION_STATE_TTL_SECONDS)
|
||||
|
||||
|
||||
@sio.on("connect")
|
||||
def socket_connect(sid, environ, auth):
|
||||
"""
|
||||
WebSocket connect event, do authentication here.
|
||||
"""
|
||||
token = None
|
||||
if auth and isinstance(auth, dict):
|
||||
token = auth.get("token")
|
||||
|
||||
if not token:
|
||||
try:
|
||||
request_environ = WerkzeugRequest(environ)
|
||||
token = extract_access_token(request_environ)
|
||||
except Exception:
|
||||
token = None
|
||||
|
||||
if not token:
|
||||
return False
|
||||
|
||||
try:
|
||||
decoded = PassportService().verify(token)
|
||||
user_id = decoded.get("user_id")
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
with sio.app.app_context():
|
||||
user = AccountService.load_logged_in_account(account_id=user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
sio.save_session(sid, {"user_id": user.id, "username": user.name, "avatar": user.avatar})
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@sio.on("user_connect")
|
||||
def handle_user_connect(sid, data):
|
||||
"""
|
||||
Handle user connect event. Each session (tab) is treated as an independent collaborator.
|
||||
"""
|
||||
|
||||
workflow_id = data.get("workflow_id")
|
||||
if not workflow_id:
|
||||
return {"msg": "workflow_id is required"}, 400
|
||||
|
||||
session = sio.get_session(sid)
|
||||
user_id = session.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
return {"msg": "unauthorized"}, 401
|
||||
|
||||
# Each session is stored independently with sid as key
|
||||
session_info = {
|
||||
"user_id": user_id,
|
||||
"username": session.get("username", "Unknown"),
|
||||
"avatar": session.get("avatar", None),
|
||||
"sid": sid,
|
||||
"connected_at": int(time.time()), # Add timestamp to differentiate tabs
|
||||
}
|
||||
|
||||
workflow_key = _workflow_key(workflow_id)
|
||||
# Store session info with sid as key
|
||||
redis_client.hset(workflow_key, sid, json.dumps(session_info))
|
||||
redis_client.set(
|
||||
_sid_key(sid),
|
||||
json.dumps({"workflow_id": workflow_id, "user_id": user_id}),
|
||||
ex=SESSION_STATE_TTL_SECONDS,
|
||||
)
|
||||
_refresh_session_state(workflow_id, sid)
|
||||
|
||||
# Leader election: first session becomes the leader
|
||||
leader_sid = get_or_set_leader(workflow_id, sid)
|
||||
is_leader = leader_sid == sid
|
||||
|
||||
sio.enter_room(sid, workflow_id)
|
||||
broadcast_online_users(workflow_id)
|
||||
|
||||
# Notify this session of their leader status
|
||||
sio.emit("status", {"isLeader": is_leader}, room=sid)
|
||||
|
||||
return {"msg": "connected", "user_id": user_id, "sid": sid, "isLeader": is_leader}
|
||||
|
||||
|
||||
@sio.on("disconnect")
|
||||
def handle_disconnect(sid):
|
||||
"""
|
||||
Handle session disconnect event. Remove the specific session from online users.
|
||||
"""
|
||||
mapping = redis_client.get(_sid_key(sid))
|
||||
if mapping:
|
||||
data = json.loads(mapping)
|
||||
workflow_id = data["workflow_id"]
|
||||
|
||||
# Remove this specific session
|
||||
redis_client.hdel(_workflow_key(workflow_id), sid)
|
||||
redis_client.delete(_sid_key(sid))
|
||||
|
||||
# Handle leader re-election if the leader session disconnected
|
||||
handle_leader_disconnect(workflow_id, sid)
|
||||
|
||||
broadcast_online_users(workflow_id)
|
||||
|
||||
|
||||
def _clear_session_state(workflow_id: str, sid: str) -> None:
|
||||
redis_client.hdel(_workflow_key(workflow_id), sid)
|
||||
redis_client.delete(_sid_key(sid))
|
||||
|
||||
|
||||
def _is_session_active(workflow_id: str, sid: str) -> bool:
|
||||
if not sid:
|
||||
return False
|
||||
|
||||
try:
|
||||
if not sio.manager.is_connected(sid, "/"):
|
||||
return False
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
if not redis_client.hexists(_workflow_key(workflow_id), sid):
|
||||
return False
|
||||
|
||||
if not redis_client.exists(_sid_key(sid)):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_or_set_leader(workflow_id: str, sid: str) -> str:
|
||||
"""
|
||||
Get current leader session or set this session as leader if no valid leader exists.
|
||||
Returns the leader session id (sid).
|
||||
"""
|
||||
raw_leader = redis_client.get(_leader_key(workflow_id))
|
||||
current_leader = raw_leader.decode("utf-8") if isinstance(raw_leader, bytes) else raw_leader
|
||||
leader_replaced = False
|
||||
|
||||
if current_leader and not _is_session_active(workflow_id, current_leader):
|
||||
_clear_session_state(workflow_id, current_leader)
|
||||
redis_client.delete(_leader_key(workflow_id))
|
||||
current_leader = None
|
||||
leader_replaced = True
|
||||
|
||||
if not current_leader:
|
||||
redis_client.set(_leader_key(workflow_id), sid, ex=SESSION_STATE_TTL_SECONDS) # Expire in 1 hour
|
||||
if leader_replaced:
|
||||
broadcast_leader_change(workflow_id, sid)
|
||||
return sid
|
||||
|
||||
return current_leader
|
||||
|
||||
|
||||
def handle_leader_disconnect(workflow_id, disconnected_sid):
|
||||
"""
|
||||
Handle leader re-election when a session disconnects.
|
||||
If the disconnected session was the leader, elect a new leader from remaining sessions.
|
||||
"""
|
||||
current_leader = redis_client.get(_leader_key(workflow_id))
|
||||
|
||||
if current_leader:
|
||||
current_leader = current_leader.decode("utf-8") if isinstance(current_leader, bytes) else current_leader
|
||||
|
||||
if current_leader == disconnected_sid:
|
||||
# Leader session disconnected, elect a new leader
|
||||
sessions_json = redis_client.hgetall(_workflow_key(workflow_id))
|
||||
|
||||
if sessions_json:
|
||||
# Get the first remaining session as new leader
|
||||
new_leader_sid = list(sessions_json.keys())[0]
|
||||
if isinstance(new_leader_sid, bytes):
|
||||
new_leader_sid = new_leader_sid.decode("utf-8")
|
||||
|
||||
redis_client.set(_leader_key(workflow_id), new_leader_sid, ex=SESSION_STATE_TTL_SECONDS)
|
||||
|
||||
# Notify all sessions about the new leader
|
||||
broadcast_leader_change(workflow_id, new_leader_sid)
|
||||
else:
|
||||
# No sessions left, remove leader
|
||||
redis_client.delete(_leader_key(workflow_id))
|
||||
|
||||
|
||||
def broadcast_leader_change(workflow_id, new_leader_sid):
|
||||
"""
|
||||
Broadcast leader change to all sessions in the workflow.
|
||||
"""
|
||||
sessions_json = redis_client.hgetall(_workflow_key(workflow_id))
|
||||
|
||||
for sid, session_info_json in sessions_json.items():
|
||||
try:
|
||||
sid_str = sid.decode("utf-8") if isinstance(sid, bytes) else sid
|
||||
is_leader = sid_str == new_leader_sid
|
||||
# Emit to each session whether they are the new leader
|
||||
sio.emit("status", {"isLeader": is_leader}, room=sid_str)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def get_current_leader(workflow_id):
|
||||
"""
|
||||
Get the current leader for a workflow.
|
||||
"""
|
||||
leader = redis_client.get(_leader_key(workflow_id))
|
||||
return leader.decode("utf-8") if leader and isinstance(leader, bytes) else leader
|
||||
|
||||
|
||||
def broadcast_online_users(workflow_id):
|
||||
"""
|
||||
Broadcast online users to the workflow room.
|
||||
Each session is shown as a separate user (even if same person has multiple tabs).
|
||||
"""
|
||||
sessions_json = redis_client.hgetall(_workflow_key(workflow_id))
|
||||
users = []
|
||||
|
||||
for sid, session_info_json in sessions_json.items():
|
||||
try:
|
||||
session_info = json.loads(session_info_json)
|
||||
# Each session appears as a separate "user" in the UI
|
||||
users.append(
|
||||
{
|
||||
"user_id": session_info["user_id"],
|
||||
"username": session_info["username"],
|
||||
"avatar": session_info.get("avatar"),
|
||||
"sid": session_info["sid"],
|
||||
"connected_at": session_info.get("connected_at"),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Sort by connection time to maintain consistent order
|
||||
users.sort(key=lambda x: x.get("connected_at") or 0)
|
||||
|
||||
# Get current leader session
|
||||
leader_sid = get_current_leader(workflow_id)
|
||||
|
||||
sio.emit("online_users", {"workflow_id": workflow_id, "users": users, "leader": leader_sid}, room=workflow_id)
|
||||
|
||||
|
||||
@sio.on("collaboration_event")
|
||||
def handle_collaboration_event(sid, data):
|
||||
"""
|
||||
Handle general collaboration events, include:
|
||||
1. mouse_move
|
||||
2. vars_and_features_update
|
||||
3. sync_request (ask leader to update graph)
|
||||
4. app_state_update
|
||||
5. mcp_server_update
|
||||
6. workflow_update
|
||||
7. comments_update
|
||||
8. node_panel_presence
|
||||
|
||||
"""
|
||||
mapping = redis_client.get(_sid_key(sid))
|
||||
|
||||
if not mapping:
|
||||
return {"msg": "unauthorized"}, 401
|
||||
|
||||
mapping_data = json.loads(mapping)
|
||||
workflow_id = mapping_data["workflow_id"]
|
||||
user_id = mapping_data["user_id"]
|
||||
_refresh_session_state(workflow_id, sid)
|
||||
|
||||
event_type = data.get("type")
|
||||
event_data = data.get("data")
|
||||
timestamp = data.get("timestamp", int(time.time()))
|
||||
|
||||
if not event_type:
|
||||
return {"msg": "invalid event type"}, 400
|
||||
|
||||
sio.emit(
|
||||
"collaboration_update",
|
||||
{"type": event_type, "userId": user_id, "data": event_data, "timestamp": timestamp},
|
||||
room=workflow_id,
|
||||
skip_sid=sid,
|
||||
)
|
||||
|
||||
return {"msg": "event_broadcasted"}
|
||||
|
||||
|
||||
@sio.on("graph_event")
|
||||
def handle_graph_event(sid, data):
|
||||
"""
|
||||
Handle graph events - simple broadcast relay.
|
||||
"""
|
||||
mapping = redis_client.get(_sid_key(sid))
|
||||
|
||||
if not mapping:
|
||||
return {"msg": "unauthorized"}, 401
|
||||
|
||||
mapping_data = json.loads(mapping)
|
||||
workflow_id = mapping_data["workflow_id"]
|
||||
_refresh_session_state(workflow_id, sid)
|
||||
|
||||
sio.emit("graph_update", data, room=workflow_id, skip_sid=sid)
|
||||
|
||||
return {"msg": "graph_update_broadcasted"}
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.console import api, console_ns
|
||||
from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
@@ -21,7 +22,9 @@ from core.file.models import File
|
||||
from core.helper.trace_id_helper import get_external_trace_id
|
||||
from core.workflow.graph_engine.manager import GraphEngineManager
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from factories import file_factory, variable_factory
|
||||
from fields.online_user_fields import online_user_list_fields
|
||||
from fields.workflow_fields import workflow_fields, workflow_pagination_fields
|
||||
from fields.workflow_run_fields import workflow_run_node_execution_fields
|
||||
from libs import helper
|
||||
@@ -122,6 +125,7 @@ class DraftWorkflowApi(Resource):
|
||||
.add_argument("hash", type=str, required=False, location="json")
|
||||
.add_argument("environment_variables", type=list, required=True, location="json")
|
||||
.add_argument("conversation_variables", type=list, required=False, location="json")
|
||||
.add_argument("force_upload", type=bool, required=False, default=False, location="json")
|
||||
)
|
||||
args = parser.parse_args()
|
||||
elif "text/plain" in content_type:
|
||||
@@ -139,6 +143,7 @@ class DraftWorkflowApi(Resource):
|
||||
"hash": data.get("hash"),
|
||||
"environment_variables": data.get("environment_variables"),
|
||||
"conversation_variables": data.get("conversation_variables"),
|
||||
"force_upload": data.get("force_upload", False),
|
||||
}
|
||||
except json.JSONDecodeError:
|
||||
return {"message": "Invalid JSON data"}, 400
|
||||
@@ -163,6 +168,7 @@ class DraftWorkflowApi(Resource):
|
||||
account=current_user,
|
||||
environment_variables=environment_variables,
|
||||
conversation_variables=conversation_variables,
|
||||
force_upload=args.get("force_upload", False),
|
||||
)
|
||||
except WorkflowHashNotEqualError:
|
||||
raise DraftWorkflowNotSync()
|
||||
@@ -734,6 +740,46 @@ class ConvertToWorkflowApi(Resource):
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/config")
|
||||
class WorkflowConfigApi(Resource):
|
||||
"""Resource for workflow configuration."""
|
||||
|
||||
@api.doc("get_workflow_config")
|
||||
@api.doc(description="Get workflow configuration")
|
||||
@api.doc(params={"app_id": "Application ID"})
|
||||
@api.response(200, "Workflow configuration retrieved successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def get(self, app_model: App):
|
||||
return {
|
||||
"parallel_depth_limit": dify_config.WORKFLOW_PARALLEL_DEPTH_LIMIT,
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/features")
|
||||
class WorkflowFeaturesApi(Resource):
|
||||
"""Update draft workflow features."""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def post(self, app_model: App):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
parser = reqparse.RequestParser().add_argument("features", type=dict, required=True, location="json")
|
||||
args = parser.parse_args()
|
||||
|
||||
features = args.get("features")
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
workflow_service.update_draft_workflow_features(app_model=app_model, features=features, account=current_user)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows")
|
||||
class PublishedAllWorkflowApi(Resource):
|
||||
@api.doc("get_all_published_workflows")
|
||||
@@ -915,3 +961,30 @@ class DraftWorkflowNodeLastRunApi(Resource):
|
||||
if node_exec is None:
|
||||
raise NotFound("last run not found")
|
||||
return node_exec
|
||||
|
||||
|
||||
@console_ns.route("/apps/workflows/online-users")
|
||||
class WorkflowOnlineUsersApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@marshal_with(online_user_list_fields)
|
||||
def get(self):
|
||||
parser = reqparse.RequestParser().add_argument("workflow_ids", type=str, required=True, location="args")
|
||||
args = parser.parse_args()
|
||||
|
||||
workflow_ids = [workflow_id.strip() for workflow_id in args["workflow_ids"].split(",")]
|
||||
|
||||
results = []
|
||||
for workflow_id in workflow_ids:
|
||||
users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}")
|
||||
|
||||
users = []
|
||||
for _, user_info_json in users_json.items():
|
||||
try:
|
||||
users.append(json.loads(user_info_json))
|
||||
except Exception:
|
||||
continue
|
||||
results.append({"workflow_id": workflow_id, "users": users})
|
||||
|
||||
return {"data": results}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import logging
|
||||
|
||||
from flask_restx import Resource, fields, marshal_with, reqparse
|
||||
|
||||
from controllers.console import api
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from fields.member_fields import account_with_role_fields
|
||||
from fields.workflow_comment_fields import (
|
||||
workflow_comment_basic_fields,
|
||||
workflow_comment_create_fields,
|
||||
workflow_comment_detail_fields,
|
||||
workflow_comment_reply_create_fields,
|
||||
workflow_comment_reply_update_fields,
|
||||
workflow_comment_resolve_fields,
|
||||
workflow_comment_update_fields,
|
||||
)
|
||||
from libs.login import current_user, login_required
|
||||
from models import App
|
||||
from services.account_service import TenantService
|
||||
from services.workflow_comment_service import WorkflowCommentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowCommentListApi(Resource):
|
||||
"""API for listing and creating workflow comments."""
|
||||
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
@marshal_with(workflow_comment_basic_fields, envelope="data")
|
||||
def get(self, app_model: App):
|
||||
"""Get all comments for a workflow."""
|
||||
comments = WorkflowCommentService.get_comments(tenant_id=current_user.current_tenant_id, app_id=app_model.id)
|
||||
|
||||
return comments
|
||||
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
@marshal_with(workflow_comment_create_fields)
|
||||
def post(self, app_model: App):
|
||||
"""Create a new workflow comment."""
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("position_x", type=float, required=True, location="json")
|
||||
parser.add_argument("position_y", type=float, required=True, location="json")
|
||||
parser.add_argument("content", type=str, required=True, location="json")
|
||||
parser.add_argument("mentioned_user_ids", type=list, location="json", default=[])
|
||||
args = parser.parse_args()
|
||||
|
||||
result = WorkflowCommentService.create_comment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
app_id=app_model.id,
|
||||
created_by=current_user.id,
|
||||
content=args.content,
|
||||
position_x=args.position_x,
|
||||
position_y=args.position_y,
|
||||
mentioned_user_ids=args.mentioned_user_ids,
|
||||
)
|
||||
|
||||
return result, 201
|
||||
|
||||
|
||||
class WorkflowCommentDetailApi(Resource):
|
||||
"""API for managing individual workflow comments."""
|
||||
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
@marshal_with(workflow_comment_detail_fields)
|
||||
def get(self, app_model: App, comment_id: str):
|
||||
"""Get a specific workflow comment."""
|
||||
comment = WorkflowCommentService.get_comment(
|
||||
tenant_id=current_user.current_tenant_id, app_id=app_model.id, comment_id=comment_id
|
||||
)
|
||||
|
||||
return comment
|
||||
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
@marshal_with(workflow_comment_update_fields)
|
||||
def put(self, app_model: App, comment_id: str):
|
||||
"""Update a workflow comment."""
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("content", type=str, required=True, location="json")
|
||||
parser.add_argument("position_x", type=float, required=False, location="json")
|
||||
parser.add_argument("position_y", type=float, required=False, location="json")
|
||||
parser.add_argument("mentioned_user_ids", type=list, location="json", default=[])
|
||||
args = parser.parse_args()
|
||||
|
||||
result = WorkflowCommentService.update_comment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
app_id=app_model.id,
|
||||
comment_id=comment_id,
|
||||
user_id=current_user.id,
|
||||
content=args.content,
|
||||
position_x=args.position_x,
|
||||
position_y=args.position_y,
|
||||
mentioned_user_ids=args.mentioned_user_ids,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
def delete(self, app_model: App, comment_id: str):
|
||||
"""Delete a workflow comment."""
|
||||
WorkflowCommentService.delete_comment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
app_id=app_model.id,
|
||||
comment_id=comment_id,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
return {"result": "success"}, 204
|
||||
|
||||
|
||||
class WorkflowCommentResolveApi(Resource):
|
||||
"""API for resolving and reopening workflow comments."""
|
||||
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
@marshal_with(workflow_comment_resolve_fields)
|
||||
def post(self, app_model: App, comment_id: str):
|
||||
"""Resolve a workflow comment."""
|
||||
comment = WorkflowCommentService.resolve_comment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
app_id=app_model.id,
|
||||
comment_id=comment_id,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
return comment
|
||||
|
||||
|
||||
class WorkflowCommentReplyApi(Resource):
|
||||
"""API for managing comment replies."""
|
||||
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
@marshal_with(workflow_comment_reply_create_fields)
|
||||
def post(self, app_model: App, comment_id: str):
|
||||
"""Add a reply to a workflow comment."""
|
||||
# Validate comment access first
|
||||
WorkflowCommentService.validate_comment_access(
|
||||
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
|
||||
)
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("content", type=str, required=True, location="json")
|
||||
parser.add_argument("mentioned_user_ids", type=list, location="json", default=[])
|
||||
args = parser.parse_args()
|
||||
|
||||
result = WorkflowCommentService.create_reply(
|
||||
comment_id=comment_id,
|
||||
content=args.content,
|
||||
created_by=current_user.id,
|
||||
mentioned_user_ids=args.mentioned_user_ids,
|
||||
)
|
||||
|
||||
return result, 201
|
||||
|
||||
|
||||
class WorkflowCommentReplyDetailApi(Resource):
|
||||
"""API for managing individual comment replies."""
|
||||
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
@marshal_with(workflow_comment_reply_update_fields)
|
||||
def put(self, app_model: App, comment_id: str, reply_id: str):
|
||||
"""Update a comment reply."""
|
||||
# Validate comment access first
|
||||
WorkflowCommentService.validate_comment_access(
|
||||
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
|
||||
)
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("content", type=str, required=True, location="json")
|
||||
parser.add_argument("mentioned_user_ids", type=list, location="json", default=[])
|
||||
args = parser.parse_args()
|
||||
|
||||
reply = WorkflowCommentService.update_reply(
|
||||
reply_id=reply_id, user_id=current_user.id, content=args.content, mentioned_user_ids=args.mentioned_user_ids
|
||||
)
|
||||
|
||||
return reply
|
||||
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
def delete(self, app_model: App, comment_id: str, reply_id: str):
|
||||
"""Delete a comment reply."""
|
||||
# Validate comment access first
|
||||
WorkflowCommentService.validate_comment_access(
|
||||
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
|
||||
)
|
||||
|
||||
WorkflowCommentService.delete_reply(reply_id=reply_id, user_id=current_user.id)
|
||||
|
||||
return {"result": "success"}, 204
|
||||
|
||||
|
||||
class WorkflowCommentMentionUsersApi(Resource):
|
||||
"""API for getting mentionable users for workflow comments."""
|
||||
|
||||
@login_required
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
@marshal_with({"users": fields.List(fields.Nested(account_with_role_fields))})
|
||||
def get(self, app_model: App):
|
||||
"""Get all users in current tenant for mentions."""
|
||||
members = TenantService.get_tenant_members(current_user.current_tenant)
|
||||
return {"users": members}
|
||||
|
||||
|
||||
# Register API routes
|
||||
api.add_resource(WorkflowCommentListApi, "/apps/<uuid:app_id>/workflow/comments")
|
||||
api.add_resource(WorkflowCommentDetailApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>")
|
||||
api.add_resource(WorkflowCommentResolveApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/resolve")
|
||||
api.add_resource(WorkflowCommentReplyApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies")
|
||||
api.add_resource(
|
||||
WorkflowCommentReplyDetailApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies/<string:reply_id>"
|
||||
)
|
||||
api.add_resource(WorkflowCommentMentionUsersApi, "/apps/<uuid:app_id>/workflow/comments/mention-users")
|
||||
@@ -19,8 +19,8 @@ from core.variables.segments import ArrayFileSegment, FileSegment, Segment
|
||||
from core.variables.types import SegmentType
|
||||
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
||||
from extensions.ext_database import db
|
||||
from factories import variable_factory
|
||||
from factories.file_factory import build_from_mapping, build_from_mappings
|
||||
from factories.variable_factory import build_segment_with_type
|
||||
from libs.login import current_user, login_required
|
||||
from models import Account, App, AppMode
|
||||
from models.workflow import WorkflowDraftVariable
|
||||
@@ -355,7 +355,7 @@ class VariableApi(Resource):
|
||||
if len(raw_value) > 0 and not isinstance(raw_value[0], dict):
|
||||
raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}")
|
||||
raw_value = build_from_mappings(mappings=raw_value, tenant_id=app_model.tenant_id)
|
||||
new_value = build_segment_with_type(variable.value_type, raw_value)
|
||||
new_value = variable_factory.build_segment_with_type(variable.value_type, raw_value)
|
||||
draft_var_srv.update_variable(variable, name=new_name, value=new_value)
|
||||
db.session.commit()
|
||||
return variable
|
||||
@@ -448,8 +448,35 @@ class ConversationVariableCollectionApi(Resource):
|
||||
db.session.commit()
|
||||
return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID)
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=AppMode.ADVANCED_CHAT)
|
||||
def post(self, app_model: App):
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
if not current_user.is_editor:
|
||||
raise Forbidden()
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("conversation_variables", type=list, required=True, location="json")
|
||||
args = parser.parse_args()
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
conversation_variables_list = args.get("conversation_variables") or []
|
||||
conversation_variables = [
|
||||
variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list
|
||||
]
|
||||
|
||||
workflow_service.update_draft_workflow_conversation_variables(
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
conversation_variables=conversation_variables,
|
||||
)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/system-variables")
|
||||
class SystemVariableCollectionApi(Resource):
|
||||
@api.doc("get_system_variables")
|
||||
@api.doc(description="Get system variables for workflow")
|
||||
@@ -499,3 +526,44 @@ class EnvironmentVariableCollectionApi(Resource):
|
||||
)
|
||||
|
||||
return {"items": env_vars_list}
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def post(self, app_model: App):
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
if not current_user.is_editor:
|
||||
raise Forbidden()
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("environment_variables", type=list, required=True, location="json")
|
||||
args = parser.parse_args()
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
environment_variables_list = args.get("environment_variables") or []
|
||||
environment_variables = [
|
||||
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
|
||||
]
|
||||
|
||||
workflow_service.update_draft_workflow_environment_variables(
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
environment_variables=environment_variables,
|
||||
)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
api.add_resource(
|
||||
WorkflowVariableCollectionApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/variables",
|
||||
)
|
||||
api.add_resource(NodeVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/variables")
|
||||
api.add_resource(VariableApi, "/apps/<uuid:app_id>/workflows/draft/variables/<uuid:variable_id>")
|
||||
api.add_resource(VariableResetApi, "/apps/<uuid:app_id>/workflows/draft/variables/<uuid:variable_id>/reset")
|
||||
|
||||
api.add_resource(ConversationVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/conversation-variables")
|
||||
api.add_resource(SystemVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/system-variables")
|
||||
api.add_resource(EnvironmentVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/environment-variables")
|
||||
|
||||
@@ -32,6 +32,7 @@ from controllers.console.wraps import (
|
||||
only_edition_cloud,
|
||||
setup_required,
|
||||
)
|
||||
from core.file import helpers as file_helpers
|
||||
from extensions.ext_database import db
|
||||
from fields.member_fields import account_fields
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
@@ -128,6 +129,17 @@ class AccountNameApi(Resource):
|
||||
|
||||
@console_ns.route("/account/avatar")
|
||||
class AccountAvatarApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("avatar", type=str, required=True, location="args")
|
||||
args = parser.parse_args()
|
||||
|
||||
avatar_url = file_helpers.get_signed_file_url(args["avatar"])
|
||||
return {"avatar_url": avatar_url}
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
|
||||
@@ -289,7 +289,8 @@ class OracleVector(BaseVector):
|
||||
words = pseg.cut(query)
|
||||
current_entity = ""
|
||||
for word, pos in words:
|
||||
if pos in {"nr", "Ng", "eng", "nz", "n", "ORG", "v"}: # nr: 人名,ns: 地名,nt: 机构名
|
||||
# nr: person name, ns: place name, nt: organization name
|
||||
if pos in {"nr", "Ng", "eng", "nz", "n", "ORG", "v"}:
|
||||
current_entity += word
|
||||
else:
|
||||
if current_entity:
|
||||
|
||||
@@ -213,7 +213,7 @@ class VastbaseVector(BaseVector):
|
||||
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(SQL_CREATE_TABLE.format(table_name=self.table_name, dimension=dimension))
|
||||
# Vastbase 支持的向量维度取值范围为 [1,16000]
|
||||
# Vastbase supports vector dimensions in range [1, 16000]
|
||||
if dimension <= 16000:
|
||||
cur.execute(SQL_CREATE_INDEX.format(table_name=self.table_name))
|
||||
redis_client.set(collection_exist_cache_key, 1, ex=3600)
|
||||
|
||||
@@ -747,7 +747,7 @@ class ParameterExtractorNode(Node):
|
||||
if model_mode == ModelMode.CHAT:
|
||||
system_prompt_messages = ChatModelMessage(
|
||||
role=PromptMessageRole.SYSTEM,
|
||||
text=CHAT_GENERATE_JSON_PROMPT.format(histories=memory_str).replace("{{instructions}}", instruction),
|
||||
text=CHAT_GENERATE_JSON_PROMPT.format(histories=memory_str, instructions=instruction),
|
||||
)
|
||||
user_prompt_message = ChatModelMessage(role=PromptMessageRole.USER, text=input_text)
|
||||
return [system_prompt_messages, user_prompt_message]
|
||||
|
||||
@@ -135,7 +135,7 @@ Here are the chat histories between human and assistant, inside <histories></his
|
||||
### Instructions:
|
||||
Some extra information are provided below, you should always follow the instructions as possible as you can.
|
||||
<instructions>
|
||||
{{instructions}}
|
||||
{instructions}
|
||||
</instructions>
|
||||
"""
|
||||
|
||||
|
||||
@@ -38,14 +38,16 @@ elif [[ "${MODE}" == "beat" ]]; then
|
||||
exec celery -A app.celery beat --loglevel ${LOG_LEVEL:-INFO}
|
||||
else
|
||||
if [[ "${DEBUG}" == "true" ]]; then
|
||||
exec flask run --host=${DIFY_BIND_ADDRESS:-0.0.0.0} --port=${DIFY_PORT:-5001} --debug
|
||||
export HOST=${DIFY_BIND_ADDRESS:-0.0.0.0}
|
||||
export PORT=${DIFY_PORT:-5001}
|
||||
exec python -m app
|
||||
else
|
||||
exec gunicorn \
|
||||
--bind "${DIFY_BIND_ADDRESS:-0.0.0.0}:${DIFY_PORT:-5001}" \
|
||||
--workers ${SERVER_WORKER_AMOUNT:-1} \
|
||||
--worker-class ${SERVER_WORKER_CLASS:-gevent} \
|
||||
--worker-class ${SERVER_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} \
|
||||
--worker-connections ${SERVER_WORKER_CONNECTIONS:-10} \
|
||||
--timeout ${GUNICORN_TIMEOUT:-200} \
|
||||
app:app
|
||||
app:socketio_app
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
from configs import dify_config
|
||||
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN
|
||||
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN, HEADER_NAME_PASSPORT
|
||||
from dify_app import DifyApp
|
||||
|
||||
BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT)
|
||||
SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization")
|
||||
AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN)
|
||||
FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN)
|
||||
|
||||
|
||||
def init_app(app: DifyApp):
|
||||
# register blueprint routers
|
||||
@@ -17,7 +22,7 @@ def init_app(app: DifyApp):
|
||||
|
||||
CORS(
|
||||
service_api_bp,
|
||||
allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE],
|
||||
allow_headers=list(SERVICE_API_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
)
|
||||
app.register_blueprint(service_api_bp)
|
||||
@@ -26,7 +31,7 @@ def init_app(app: DifyApp):
|
||||
web_bp,
|
||||
resources={r"/*": {"origins": dify_config.WEB_API_CORS_ALLOW_ORIGINS}},
|
||||
supports_credentials=True,
|
||||
allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN],
|
||||
allow_headers=list(AUTHENTICATED_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=["X-Version", "X-Env"],
|
||||
)
|
||||
@@ -36,7 +41,7 @@ def init_app(app: DifyApp):
|
||||
console_app_bp,
|
||||
resources={r"/*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}},
|
||||
supports_credentials=True,
|
||||
allow_headers=["Content-Type", "Authorization", HEADER_NAME_CSRF_TOKEN],
|
||||
allow_headers=list(AUTHENTICATED_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=["X-Version", "X-Env"],
|
||||
)
|
||||
@@ -44,7 +49,7 @@ def init_app(app: DifyApp):
|
||||
|
||||
CORS(
|
||||
files_bp,
|
||||
allow_headers=["Content-Type", HEADER_NAME_CSRF_TOKEN],
|
||||
allow_headers=list(FILES_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
)
|
||||
app.register_blueprint(files_bp)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import socketio
|
||||
|
||||
sio = socketio.Server(async_mode="gevent", cors_allowed_origins="*")
|
||||
@@ -0,0 +1,17 @@
|
||||
from flask_restx import fields
|
||||
|
||||
online_user_partial_fields = {
|
||||
"user_id": fields.String,
|
||||
"username": fields.String,
|
||||
"avatar": fields.String,
|
||||
"sid": fields.String,
|
||||
}
|
||||
|
||||
workflow_online_users_fields = {
|
||||
"workflow_id": fields.String,
|
||||
"users": fields.List(fields.Nested(online_user_partial_fields)),
|
||||
}
|
||||
|
||||
online_user_list_fields = {
|
||||
"data": fields.List(fields.Nested(workflow_online_users_fields)),
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
from flask_restx import fields
|
||||
|
||||
from libs.helper import AvatarUrlField, TimestampField
|
||||
|
||||
# basic account fields for comments
|
||||
account_fields = {
|
||||
"id": fields.String,
|
||||
"name": fields.String,
|
||||
"email": fields.String,
|
||||
"avatar_url": AvatarUrlField,
|
||||
}
|
||||
|
||||
# Comment mention fields
|
||||
workflow_comment_mention_fields = {
|
||||
"mentioned_user_id": fields.String,
|
||||
"mentioned_user_account": fields.Nested(account_fields, allow_null=True),
|
||||
"reply_id": fields.String,
|
||||
}
|
||||
|
||||
# Comment reply fields
|
||||
workflow_comment_reply_fields = {
|
||||
"id": fields.String,
|
||||
"content": fields.String,
|
||||
"created_by": fields.String,
|
||||
"created_by_account": fields.Nested(account_fields, allow_null=True),
|
||||
"created_at": TimestampField,
|
||||
}
|
||||
|
||||
# Basic comment fields (for list views)
|
||||
workflow_comment_basic_fields = {
|
||||
"id": fields.String,
|
||||
"position_x": fields.Float,
|
||||
"position_y": fields.Float,
|
||||
"content": fields.String,
|
||||
"created_by": fields.String,
|
||||
"created_by_account": fields.Nested(account_fields, allow_null=True),
|
||||
"created_at": TimestampField,
|
||||
"updated_at": TimestampField,
|
||||
"resolved": fields.Boolean,
|
||||
"resolved_at": TimestampField,
|
||||
"resolved_by": fields.String,
|
||||
"resolved_by_account": fields.Nested(account_fields, allow_null=True),
|
||||
"reply_count": fields.Integer,
|
||||
"mention_count": fields.Integer,
|
||||
"participants": fields.List(fields.Nested(account_fields)),
|
||||
}
|
||||
|
||||
# Detailed comment fields (for single comment view)
|
||||
workflow_comment_detail_fields = {
|
||||
"id": fields.String,
|
||||
"position_x": fields.Float,
|
||||
"position_y": fields.Float,
|
||||
"content": fields.String,
|
||||
"created_by": fields.String,
|
||||
"created_by_account": fields.Nested(account_fields, allow_null=True),
|
||||
"created_at": TimestampField,
|
||||
"updated_at": TimestampField,
|
||||
"resolved": fields.Boolean,
|
||||
"resolved_at": TimestampField,
|
||||
"resolved_by": fields.String,
|
||||
"resolved_by_account": fields.Nested(account_fields, allow_null=True),
|
||||
"replies": fields.List(fields.Nested(workflow_comment_reply_fields)),
|
||||
"mentions": fields.List(fields.Nested(workflow_comment_mention_fields)),
|
||||
}
|
||||
|
||||
# Comment creation response fields (simplified)
|
||||
workflow_comment_create_fields = {
|
||||
"id": fields.String,
|
||||
"created_at": TimestampField,
|
||||
}
|
||||
|
||||
# Comment update response fields (simplified)
|
||||
workflow_comment_update_fields = {
|
||||
"id": fields.String,
|
||||
"updated_at": TimestampField,
|
||||
}
|
||||
|
||||
# Comment resolve response fields
|
||||
workflow_comment_resolve_fields = {
|
||||
"id": fields.String,
|
||||
"resolved": fields.Boolean,
|
||||
"resolved_at": TimestampField,
|
||||
"resolved_by": fields.String,
|
||||
}
|
||||
|
||||
# Reply creation response fields (simplified)
|
||||
workflow_comment_reply_create_fields = {
|
||||
"id": fields.String,
|
||||
"created_at": TimestampField,
|
||||
}
|
||||
|
||||
# Reply update response fields
|
||||
workflow_comment_reply_update_fields = {
|
||||
"id": fields.String,
|
||||
"updated_at": TimestampField,
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Add workflow comments table
|
||||
|
||||
Revision ID: 227822d22895
|
||||
Revises: 68519ad5cd18
|
||||
Create Date: 2025-08-22 17:26:15.255980
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '227822d22895'
|
||||
down_revision = '68519ad5cd18'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('workflow_comments',
|
||||
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
|
||||
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('app_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('position_x', sa.Float(), nullable=False),
|
||||
sa.Column('position_y', sa.Float(), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('created_by', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('resolved', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('resolved_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('resolved_by', models.types.StringUUID(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name='workflow_comments_pkey')
|
||||
)
|
||||
with op.batch_alter_table('workflow_comments', schema=None) as batch_op:
|
||||
batch_op.create_index('workflow_comments_app_idx', ['tenant_id', 'app_id'], unique=False)
|
||||
batch_op.create_index('workflow_comments_created_at_idx', ['created_at'], unique=False)
|
||||
|
||||
op.create_table('workflow_comment_replies',
|
||||
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
|
||||
sa.Column('comment_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('created_by', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name=op.f('workflow_comment_replies_comment_id_fkey'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='workflow_comment_replies_pkey')
|
||||
)
|
||||
with op.batch_alter_table('workflow_comment_replies', schema=None) as batch_op:
|
||||
batch_op.create_index('comment_replies_comment_idx', ['comment_id'], unique=False)
|
||||
batch_op.create_index('comment_replies_created_at_idx', ['created_at'], unique=False)
|
||||
|
||||
op.create_table('workflow_comment_mentions',
|
||||
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
|
||||
sa.Column('comment_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('reply_id', models.types.StringUUID(), nullable=True),
|
||||
sa.Column('mentioned_user_id', models.types.StringUUID(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name=op.f('workflow_comment_mentions_comment_id_fkey'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['reply_id'], ['workflow_comment_replies.id'], name=op.f('workflow_comment_mentions_reply_id_fkey'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='workflow_comment_mentions_pkey')
|
||||
)
|
||||
with op.batch_alter_table('workflow_comment_mentions', schema=None) as batch_op:
|
||||
batch_op.create_index('comment_mentions_comment_idx', ['comment_id'], unique=False)
|
||||
batch_op.create_index('comment_mentions_reply_idx', ['reply_id'], unique=False)
|
||||
batch_op.create_index('comment_mentions_user_idx', ['mentioned_user_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('workflow_comment_mentions', schema=None) as batch_op:
|
||||
batch_op.drop_index('comment_mentions_user_idx')
|
||||
batch_op.drop_index('comment_mentions_reply_idx')
|
||||
batch_op.drop_index('comment_mentions_comment_idx')
|
||||
|
||||
op.drop_table('workflow_comment_mentions')
|
||||
with op.batch_alter_table('workflow_comment_replies', schema=None) as batch_op:
|
||||
batch_op.drop_index('comment_replies_created_at_idx')
|
||||
batch_op.drop_index('comment_replies_comment_idx')
|
||||
|
||||
op.drop_table('workflow_comment_replies')
|
||||
with op.batch_alter_table('workflow_comments', schema=None) as batch_op:
|
||||
batch_op.drop_index('workflow_comments_created_at_idx')
|
||||
batch_op.drop_index('workflow_comments_app_idx')
|
||||
|
||||
op.drop_table('workflow_comments')
|
||||
# ### end Alembic commands ###
|
||||
@@ -9,6 +9,11 @@ from .account import (
|
||||
TenantStatus,
|
||||
)
|
||||
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
|
||||
from .comment import (
|
||||
WorkflowComment,
|
||||
WorkflowCommentMention,
|
||||
WorkflowCommentReply,
|
||||
)
|
||||
from .dataset import (
|
||||
AppDatasetJoin,
|
||||
Dataset,
|
||||
@@ -174,6 +179,9 @@ __all__ = [
|
||||
"Workflow",
|
||||
"WorkflowAppLog",
|
||||
"WorkflowAppLogCreatedFrom",
|
||||
"WorkflowComment",
|
||||
"WorkflowCommentMention",
|
||||
"WorkflowCommentReply",
|
||||
"WorkflowNodeExecutionModel",
|
||||
"WorkflowNodeExecutionOffload",
|
||||
"WorkflowNodeExecutionTriggeredFrom",
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Workflow comment models."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import Index, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .account import Account
|
||||
from .base import Base
|
||||
from .engine import db
|
||||
from .types import StringUUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowComment(Base):
|
||||
"""Workflow comment model for canvas commenting functionality.
|
||||
|
||||
Comments are associated with apps rather than specific workflow versions,
|
||||
since an app has only one draft workflow at a time and comments should persist
|
||||
across workflow version changes.
|
||||
|
||||
Attributes:
|
||||
id: Comment ID
|
||||
tenant_id: Workspace ID
|
||||
app_id: App ID (primary association, comments belong to apps)
|
||||
position_x: X coordinate on canvas
|
||||
position_y: Y coordinate on canvas
|
||||
content: Comment content
|
||||
created_by: Creator account ID
|
||||
created_at: Creation time
|
||||
updated_at: Last update time
|
||||
resolved: Whether comment is resolved
|
||||
resolved_at: Resolution time
|
||||
resolved_by: Resolver account ID
|
||||
"""
|
||||
|
||||
__tablename__ = "workflow_comments"
|
||||
__table_args__ = (
|
||||
db.PrimaryKeyConstraint("id", name="workflow_comments_pkey"),
|
||||
Index("workflow_comments_app_idx", "tenant_id", "app_id"),
|
||||
Index("workflow_comments_created_at_idx", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
position_x: Mapped[float] = mapped_column(db.Float)
|
||||
position_y: Mapped[float] = mapped_column(db.Float)
|
||||
content: Mapped[str] = mapped_column(db.Text, nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
db.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
|
||||
)
|
||||
resolved: Mapped[bool] = mapped_column(db.Boolean, nullable=False, server_default=db.text("false"))
|
||||
resolved_at: Mapped[Optional[datetime]] = mapped_column(db.DateTime)
|
||||
resolved_by: Mapped[Optional[str]] = mapped_column(StringUUID)
|
||||
|
||||
# Relationships
|
||||
replies: Mapped[list["WorkflowCommentReply"]] = relationship(
|
||||
"WorkflowCommentReply", back_populates="comment", cascade="all, delete-orphan"
|
||||
)
|
||||
mentions: Mapped[list["WorkflowCommentMention"]] = relationship(
|
||||
"WorkflowCommentMention", back_populates="comment", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def created_by_account(self):
|
||||
"""Get creator account."""
|
||||
return db.session.get(Account, self.created_by)
|
||||
|
||||
@property
|
||||
def resolved_by_account(self):
|
||||
"""Get resolver account."""
|
||||
if self.resolved_by:
|
||||
return db.session.get(Account, self.resolved_by)
|
||||
return None
|
||||
|
||||
@property
|
||||
def reply_count(self):
|
||||
"""Get reply count."""
|
||||
return len(self.replies)
|
||||
|
||||
@property
|
||||
def mention_count(self):
|
||||
"""Get mention count."""
|
||||
return len(self.mentions)
|
||||
|
||||
@property
|
||||
def participants(self):
|
||||
"""Get all participants (creator + repliers + mentioned users)."""
|
||||
participant_ids = set()
|
||||
|
||||
# Add comment creator
|
||||
participant_ids.add(self.created_by)
|
||||
|
||||
# Add reply creators
|
||||
participant_ids.update(reply.created_by for reply in self.replies)
|
||||
|
||||
# Add mentioned users
|
||||
participant_ids.update(mention.mentioned_user_id for mention in self.mentions)
|
||||
|
||||
# Get account objects
|
||||
participants = []
|
||||
for user_id in participant_ids:
|
||||
account = db.session.get(Account, user_id)
|
||||
if account:
|
||||
participants.append(account)
|
||||
|
||||
return participants
|
||||
|
||||
|
||||
class WorkflowCommentReply(Base):
|
||||
"""Workflow comment reply model.
|
||||
|
||||
Attributes:
|
||||
id: Reply ID
|
||||
comment_id: Parent comment ID
|
||||
content: Reply content
|
||||
created_by: Creator account ID
|
||||
created_at: Creation time
|
||||
"""
|
||||
|
||||
__tablename__ = "workflow_comment_replies"
|
||||
__table_args__ = (
|
||||
db.PrimaryKeyConstraint("id", name="workflow_comment_replies_pkey"),
|
||||
Index("comment_replies_comment_idx", "comment_id"),
|
||||
Index("comment_replies_created_at_idx", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
|
||||
comment_id: Mapped[str] = mapped_column(
|
||||
StringUUID, db.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
content: Mapped[str] = mapped_column(db.Text, nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
db.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
|
||||
)
|
||||
# Relationships
|
||||
comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="replies")
|
||||
|
||||
@property
|
||||
def created_by_account(self):
|
||||
"""Get creator account."""
|
||||
return db.session.get(Account, self.created_by)
|
||||
|
||||
|
||||
class WorkflowCommentMention(Base):
|
||||
"""Workflow comment mention model.
|
||||
|
||||
Mentions are only for internal accounts since end users
|
||||
cannot access workflow canvas and commenting features.
|
||||
|
||||
Attributes:
|
||||
id: Mention ID
|
||||
comment_id: Parent comment ID
|
||||
mentioned_user_id: Mentioned account ID
|
||||
"""
|
||||
|
||||
__tablename__ = "workflow_comment_mentions"
|
||||
__table_args__ = (
|
||||
db.PrimaryKeyConstraint("id", name="workflow_comment_mentions_pkey"),
|
||||
Index("comment_mentions_comment_idx", "comment_id"),
|
||||
Index("comment_mentions_reply_idx", "reply_id"),
|
||||
Index("comment_mentions_user_idx", "mentioned_user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
|
||||
comment_id: Mapped[str] = mapped_column(
|
||||
StringUUID, db.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
reply_id: Mapped[Optional[str]] = mapped_column(
|
||||
StringUUID, db.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
|
||||
# Relationships
|
||||
comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="mentions")
|
||||
reply: Mapped[Optional["WorkflowCommentReply"]] = relationship("WorkflowCommentReply")
|
||||
|
||||
@property
|
||||
def mentioned_user_account(self):
|
||||
"""Get mentioned account."""
|
||||
return db.session.get(Account, self.mentioned_user_id)
|
||||
@@ -335,7 +335,7 @@ class Workflow(Base):
|
||||
|
||||
:return: hash
|
||||
"""
|
||||
entity = {"graph": self.graph_dict, "features": self.features_dict}
|
||||
entity = {"graph": self.graph_dict}
|
||||
|
||||
return helper.generate_text_hash(json.dumps(entity, sort_keys=True))
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ dependencies = [
|
||||
"flask-orjson~=2.0.0",
|
||||
"flask-sqlalchemy~=3.1.1",
|
||||
"gevent~=25.9.1",
|
||||
"gevent-websocket~=0.10.1",
|
||||
"gmpy2~=2.2.1",
|
||||
"google-api-core==2.18.0",
|
||||
"google-api-python-client==2.90.0",
|
||||
@@ -68,6 +69,7 @@ dependencies = [
|
||||
"pypdfium2==4.30.0",
|
||||
"python-docx~=1.1.0",
|
||||
"python-dotenv==1.0.1",
|
||||
"python-socketio~=5.13.0",
|
||||
"pyyaml~=6.0.1",
|
||||
"readabilipy~=0.3.0",
|
||||
"redis[hiredis]~=6.1.0",
|
||||
|
||||
@@ -151,6 +151,7 @@ class SystemFeatureModel(BaseModel):
|
||||
enable_email_code_login: bool = False
|
||||
enable_email_password_login: bool = True
|
||||
enable_social_oauth_login: bool = False
|
||||
enable_collaboration_mode: bool = False
|
||||
is_allow_register: bool = False
|
||||
is_allow_create_workspace: bool = False
|
||||
is_email_setup: bool = False
|
||||
@@ -211,6 +212,7 @@ class FeatureService:
|
||||
system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN
|
||||
system_features.enable_email_password_login = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN
|
||||
system_features.enable_social_oauth_login = dify_config.ENABLE_SOCIAL_OAUTH_LOGIN
|
||||
system_features.enable_collaboration_mode = dify_config.ENABLE_COLLABORATION_MODE
|
||||
system_features.is_allow_register = dify_config.ALLOW_REGISTER
|
||||
system_features.is_allow_create_workspace = dify_config.ALLOW_CREATE_WORKSPACE
|
||||
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import uuid_value
|
||||
from models import WorkflowComment, WorkflowCommentMention, WorkflowCommentReply
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowCommentService:
|
||||
"""Service for managing workflow comments."""
|
||||
|
||||
@staticmethod
|
||||
def _validate_content(content: str) -> None:
|
||||
if len(content.strip()) == 0:
|
||||
raise ValueError("Comment content cannot be empty")
|
||||
|
||||
if len(content) > 1000:
|
||||
raise ValueError("Comment content cannot exceed 1000 characters")
|
||||
|
||||
@staticmethod
|
||||
def get_comments(tenant_id: str, app_id: str) -> list[WorkflowComment]:
|
||||
"""Get all comments for a workflow."""
|
||||
with Session(db.engine) as session:
|
||||
# Get all comments with eager loading
|
||||
stmt = (
|
||||
select(WorkflowComment)
|
||||
.options(selectinload(WorkflowComment.replies), selectinload(WorkflowComment.mentions))
|
||||
.where(WorkflowComment.tenant_id == tenant_id, WorkflowComment.app_id == app_id)
|
||||
.order_by(desc(WorkflowComment.created_at))
|
||||
)
|
||||
|
||||
comments = session.scalars(stmt).all()
|
||||
return comments
|
||||
|
||||
@staticmethod
|
||||
def get_comment(tenant_id: str, app_id: str, comment_id: str, session: Session = None) -> WorkflowComment:
|
||||
"""Get a specific comment."""
|
||||
|
||||
def _get_comment(session: Session) -> WorkflowComment:
|
||||
stmt = (
|
||||
select(WorkflowComment)
|
||||
.options(selectinload(WorkflowComment.replies), selectinload(WorkflowComment.mentions))
|
||||
.where(
|
||||
WorkflowComment.id == comment_id,
|
||||
WorkflowComment.tenant_id == tenant_id,
|
||||
WorkflowComment.app_id == app_id,
|
||||
)
|
||||
)
|
||||
comment = session.scalar(stmt)
|
||||
|
||||
if not comment:
|
||||
raise NotFound("Comment not found")
|
||||
|
||||
return comment
|
||||
|
||||
if session is not None:
|
||||
return _get_comment(session)
|
||||
else:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
return _get_comment(session)
|
||||
|
||||
@staticmethod
|
||||
def create_comment(
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
created_by: str,
|
||||
content: str,
|
||||
position_x: float,
|
||||
position_y: float,
|
||||
mentioned_user_ids: Optional[list[str]] = None,
|
||||
) -> WorkflowComment:
|
||||
"""Create a new workflow comment."""
|
||||
WorkflowCommentService._validate_content(content)
|
||||
|
||||
with Session(db.engine) as session:
|
||||
comment = WorkflowComment(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
position_x=position_x,
|
||||
position_y=position_y,
|
||||
content=content,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
session.add(comment)
|
||||
session.flush() # Get the comment ID for mentions
|
||||
|
||||
# Create mentions if specified
|
||||
mentioned_user_ids = mentioned_user_ids or []
|
||||
for user_id in mentioned_user_ids:
|
||||
if isinstance(user_id, str) and uuid_value(user_id):
|
||||
mention = WorkflowCommentMention(
|
||||
comment_id=comment.id,
|
||||
reply_id=None, # This is a comment mention, not reply mention
|
||||
mentioned_user_id=user_id,
|
||||
)
|
||||
session.add(mention)
|
||||
|
||||
session.commit()
|
||||
|
||||
# Return only what we need - id and created_at
|
||||
return {"id": comment.id, "created_at": comment.created_at}
|
||||
|
||||
@staticmethod
|
||||
def update_comment(
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
comment_id: str,
|
||||
user_id: str,
|
||||
content: str,
|
||||
position_x: Optional[float] = None,
|
||||
position_y: Optional[float] = None,
|
||||
mentioned_user_ids: Optional[list[str]] = None,
|
||||
) -> dict:
|
||||
"""Update a workflow comment."""
|
||||
WorkflowCommentService._validate_content(content)
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
# Get comment with validation
|
||||
stmt = select(WorkflowComment).where(
|
||||
WorkflowComment.id == comment_id,
|
||||
WorkflowComment.tenant_id == tenant_id,
|
||||
WorkflowComment.app_id == app_id,
|
||||
)
|
||||
comment = session.scalar(stmt)
|
||||
|
||||
if not comment:
|
||||
raise NotFound("Comment not found")
|
||||
|
||||
# Only the creator can update the comment
|
||||
if comment.created_by != user_id:
|
||||
raise Forbidden("Only the comment creator can update it")
|
||||
|
||||
# Update comment fields
|
||||
comment.content = content
|
||||
if position_x is not None:
|
||||
comment.position_x = position_x
|
||||
if position_y is not None:
|
||||
comment.position_y = position_y
|
||||
|
||||
# Update mentions - first remove existing mentions for this comment only (not replies)
|
||||
existing_mentions = session.scalars(
|
||||
select(WorkflowCommentMention).where(
|
||||
WorkflowCommentMention.comment_id == comment.id,
|
||||
WorkflowCommentMention.reply_id.is_(None), # Only comment mentions, not reply mentions
|
||||
)
|
||||
).all()
|
||||
for mention in existing_mentions:
|
||||
session.delete(mention)
|
||||
|
||||
# Add new mentions
|
||||
mentioned_user_ids = mentioned_user_ids or []
|
||||
for user_id_str in mentioned_user_ids:
|
||||
if isinstance(user_id_str, str) and uuid_value(user_id_str):
|
||||
mention = WorkflowCommentMention(
|
||||
comment_id=comment.id,
|
||||
reply_id=None, # This is a comment mention
|
||||
mentioned_user_id=user_id_str,
|
||||
)
|
||||
session.add(mention)
|
||||
|
||||
session.commit()
|
||||
|
||||
return {"id": comment.id, "updated_at": comment.updated_at}
|
||||
|
||||
@staticmethod
|
||||
def delete_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> None:
|
||||
"""Delete a workflow comment."""
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id, session)
|
||||
|
||||
# Only the creator can delete the comment
|
||||
if comment.created_by != user_id:
|
||||
raise Forbidden("Only the comment creator can delete it")
|
||||
|
||||
# Delete associated mentions (both comment and reply mentions)
|
||||
mentions = session.scalars(
|
||||
select(WorkflowCommentMention).where(WorkflowCommentMention.comment_id == comment_id)
|
||||
).all()
|
||||
for mention in mentions:
|
||||
session.delete(mention)
|
||||
|
||||
# Delete associated replies
|
||||
replies = session.scalars(
|
||||
select(WorkflowCommentReply).where(WorkflowCommentReply.comment_id == comment_id)
|
||||
).all()
|
||||
for reply in replies:
|
||||
session.delete(reply)
|
||||
|
||||
session.delete(comment)
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def resolve_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> WorkflowComment:
|
||||
"""Resolve a workflow comment."""
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id, session)
|
||||
if comment.resolved:
|
||||
return comment
|
||||
|
||||
comment.resolved = True
|
||||
comment.resolved_at = naive_utc_now()
|
||||
comment.resolved_by = user_id
|
||||
session.commit()
|
||||
|
||||
return comment
|
||||
|
||||
@staticmethod
|
||||
def create_reply(
|
||||
comment_id: str, content: str, created_by: str, mentioned_user_ids: Optional[list[str]] = None
|
||||
) -> dict:
|
||||
"""Add a reply to a workflow comment."""
|
||||
WorkflowCommentService._validate_content(content)
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
# Check if comment exists
|
||||
comment = session.get(WorkflowComment, comment_id)
|
||||
if not comment:
|
||||
raise NotFound("Comment not found")
|
||||
|
||||
reply = WorkflowCommentReply(comment_id=comment_id, content=content, created_by=created_by)
|
||||
|
||||
session.add(reply)
|
||||
session.flush() # Get the reply ID for mentions
|
||||
|
||||
# Create mentions if specified
|
||||
mentioned_user_ids = mentioned_user_ids or []
|
||||
for user_id in mentioned_user_ids:
|
||||
if isinstance(user_id, str) and uuid_value(user_id):
|
||||
# Create mention linking to specific reply
|
||||
mention = WorkflowCommentMention(
|
||||
comment_id=comment_id, reply_id=reply.id, mentioned_user_id=user_id
|
||||
)
|
||||
session.add(mention)
|
||||
|
||||
session.commit()
|
||||
|
||||
return {"id": reply.id, "created_at": reply.created_at}
|
||||
|
||||
@staticmethod
|
||||
def update_reply(
|
||||
reply_id: str, user_id: str, content: str, mentioned_user_ids: Optional[list[str]] = None
|
||||
) -> WorkflowCommentReply:
|
||||
"""Update a comment reply."""
|
||||
WorkflowCommentService._validate_content(content)
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
reply = session.get(WorkflowCommentReply, reply_id)
|
||||
if not reply:
|
||||
raise NotFound("Reply not found")
|
||||
|
||||
# Only the creator can update the reply
|
||||
if reply.created_by != user_id:
|
||||
raise Forbidden("Only the reply creator can update it")
|
||||
|
||||
reply.content = content
|
||||
|
||||
# Update mentions - first remove existing mentions for this reply
|
||||
existing_mentions = session.scalars(
|
||||
select(WorkflowCommentMention).where(WorkflowCommentMention.reply_id == reply.id)
|
||||
).all()
|
||||
for mention in existing_mentions:
|
||||
session.delete(mention)
|
||||
|
||||
# Add mentions
|
||||
mentioned_user_ids = mentioned_user_ids or []
|
||||
for user_id_str in mentioned_user_ids:
|
||||
if isinstance(user_id_str, str) and uuid_value(user_id_str):
|
||||
mention = WorkflowCommentMention(
|
||||
comment_id=reply.comment_id, reply_id=reply.id, mentioned_user_id=user_id_str
|
||||
)
|
||||
session.add(mention)
|
||||
|
||||
session.commit()
|
||||
session.refresh(reply) # Refresh to get updated timestamp
|
||||
|
||||
return {"id": reply.id, "updated_at": reply.updated_at}
|
||||
|
||||
@staticmethod
|
||||
def delete_reply(reply_id: str, user_id: str) -> None:
|
||||
"""Delete a comment reply."""
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
reply = session.get(WorkflowCommentReply, reply_id)
|
||||
if not reply:
|
||||
raise NotFound("Reply not found")
|
||||
|
||||
# Only the creator can delete the reply
|
||||
if reply.created_by != user_id:
|
||||
raise Forbidden("Only the reply creator can delete it")
|
||||
|
||||
# Delete associated mentions first
|
||||
mentions = session.scalars(
|
||||
select(WorkflowCommentMention).where(WorkflowCommentMention.reply_id == reply_id)
|
||||
).all()
|
||||
for mention in mentions:
|
||||
session.delete(mention)
|
||||
|
||||
session.delete(reply)
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def validate_comment_access(comment_id: str, tenant_id: str, app_id: str) -> WorkflowComment:
|
||||
"""Validate that a comment belongs to the specified tenant and app."""
|
||||
return WorkflowCommentService.get_comment(tenant_id, app_id, comment_id)
|
||||
@@ -197,15 +197,17 @@ class WorkflowService:
|
||||
account: Account,
|
||||
environment_variables: Sequence[Variable],
|
||||
conversation_variables: Sequence[Variable],
|
||||
force_upload: bool = False,
|
||||
) -> Workflow:
|
||||
"""
|
||||
Sync draft workflow
|
||||
:param force_upload: Skip hash validation when True (for restore operations)
|
||||
:raises WorkflowHashNotEqualError
|
||||
"""
|
||||
# fetch draft workflow by app_model
|
||||
workflow = self.get_draft_workflow(app_model=app_model)
|
||||
|
||||
if workflow and workflow.unique_hash != unique_hash:
|
||||
if workflow and workflow.unique_hash != unique_hash and not force_upload:
|
||||
raise WorkflowHashNotEqualError()
|
||||
|
||||
# validate features structure
|
||||
@@ -243,6 +245,78 @@ class WorkflowService:
|
||||
# return draft workflow
|
||||
return workflow
|
||||
|
||||
def update_draft_workflow_environment_variables(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
environment_variables: Sequence[Variable],
|
||||
account: Account,
|
||||
):
|
||||
"""
|
||||
Update draft workflow environment variables
|
||||
"""
|
||||
# fetch draft workflow by app_model
|
||||
workflow = self.get_draft_workflow(app_model=app_model)
|
||||
|
||||
if not workflow:
|
||||
raise ValueError("No draft workflow found.")
|
||||
|
||||
workflow.environment_variables = environment_variables
|
||||
workflow.updated_by = account.id
|
||||
workflow.updated_at = naive_utc_now()
|
||||
|
||||
# commit db session changes
|
||||
db.session.commit()
|
||||
|
||||
def update_draft_workflow_conversation_variables(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
conversation_variables: Sequence[Variable],
|
||||
account: Account,
|
||||
):
|
||||
"""
|
||||
Update draft workflow conversation variables
|
||||
"""
|
||||
# fetch draft workflow by app_model
|
||||
workflow = self.get_draft_workflow(app_model=app_model)
|
||||
|
||||
if not workflow:
|
||||
raise ValueError("No draft workflow found.")
|
||||
|
||||
workflow.conversation_variables = conversation_variables
|
||||
workflow.updated_by = account.id
|
||||
workflow.updated_at = naive_utc_now()
|
||||
|
||||
# commit db session changes
|
||||
db.session.commit()
|
||||
|
||||
def update_draft_workflow_features(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
features: dict,
|
||||
account: Account,
|
||||
):
|
||||
"""
|
||||
Update draft workflow features
|
||||
"""
|
||||
# fetch draft workflow by app_model
|
||||
workflow = self.get_draft_workflow(app_model=app_model)
|
||||
|
||||
if not workflow:
|
||||
raise ValueError("No draft workflow found.")
|
||||
|
||||
# validate features structure
|
||||
self.validate_features_structure(app_model=app_model, features=features)
|
||||
|
||||
workflow.features = json.dumps(features)
|
||||
workflow.updated_by = account.id
|
||||
workflow.updated_at = naive_utc_now()
|
||||
|
||||
# commit db session changes
|
||||
db.session.commit()
|
||||
|
||||
def publish_workflow(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -267,6 +267,7 @@ class TestFeatureService:
|
||||
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
|
||||
mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True
|
||||
mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = False
|
||||
mock_config.ENABLE_COLLABORATION_MODE = True
|
||||
mock_config.ALLOW_REGISTER = False
|
||||
mock_config.ALLOW_CREATE_WORKSPACE = False
|
||||
mock_config.MAIL_TYPE = "smtp"
|
||||
@@ -291,6 +292,7 @@ class TestFeatureService:
|
||||
# Verify authentication settings
|
||||
assert result.enable_email_code_login is True
|
||||
assert result.enable_email_password_login is False
|
||||
assert result.enable_collaboration_mode is True
|
||||
assert result.is_allow_register is False
|
||||
assert result.is_allow_create_workspace is False
|
||||
|
||||
@@ -340,6 +342,7 @@ class TestFeatureService:
|
||||
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
|
||||
mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True
|
||||
mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = False
|
||||
mock_config.ENABLE_COLLABORATION_MODE = False
|
||||
mock_config.ALLOW_REGISTER = True
|
||||
mock_config.ALLOW_CREATE_WORKSPACE = True
|
||||
mock_config.MAIL_TYPE = "smtp"
|
||||
@@ -361,6 +364,7 @@ class TestFeatureService:
|
||||
assert result.enable_email_code_login is True
|
||||
assert result.enable_email_password_login is True
|
||||
assert result.enable_social_oauth_login is False
|
||||
assert result.enable_collaboration_mode is False
|
||||
assert result.is_allow_register is True
|
||||
assert result.is_allow_create_workspace is True
|
||||
assert result.is_email_setup is True
|
||||
|
||||
Generated
+74
@@ -553,6 +553,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/f4/a69c20ee4f660081a7dedb1ac57f29be9378e04edfcb90c526b923d4bebc/beautifulsoup4-4.12.2-py3-none-any.whl", hash = "sha256:bd2520ca0d9d7d12694a53d44ac482d181b4ec1888909b035a3dbf40d0f57d4a", size = 142979, upload-time = "2023-04-07T15:02:50.77Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bidict"
|
||||
version = "0.23.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "billiard"
|
||||
version = "4.2.2"
|
||||
@@ -1312,6 +1321,7 @@ dependencies = [
|
||||
{ name = "flask-restx" },
|
||||
{ name = "flask-sqlalchemy" },
|
||||
{ name = "gevent" },
|
||||
{ name = "gevent-websocket" },
|
||||
{ name = "gmpy2" },
|
||||
{ name = "google-api-core" },
|
||||
{ name = "google-api-python-client" },
|
||||
@@ -1359,6 +1369,7 @@ dependencies = [
|
||||
{ name = "pypdfium2" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-socketio" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "readabilipy" },
|
||||
{ name = "redis", extra = ["hiredis"] },
|
||||
@@ -1503,6 +1514,7 @@ requires-dist = [
|
||||
{ name = "flask-restx", specifier = "~=1.3.0" },
|
||||
{ name = "flask-sqlalchemy", specifier = "~=3.1.1" },
|
||||
{ name = "gevent", specifier = "~=25.9.1" },
|
||||
{ name = "gevent-websocket", specifier = "~=0.10.1" },
|
||||
{ name = "gmpy2", specifier = "~=2.2.1" },
|
||||
{ name = "google-api-core", specifier = "==2.18.0" },
|
||||
{ name = "google-api-python-client", specifier = "==2.90.0" },
|
||||
@@ -1550,6 +1562,7 @@ requires-dist = [
|
||||
{ name = "pypdfium2", specifier = "==4.30.0" },
|
||||
{ name = "python-docx", specifier = "~=1.1.0" },
|
||||
{ name = "python-dotenv", specifier = "==1.0.1" },
|
||||
{ name = "python-socketio", specifier = "~=5.13.0" },
|
||||
{ name = "pyyaml", specifier = "~=6.0.1" },
|
||||
{ name = "readabilipy", specifier = "~=0.3.0" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = "~=6.1.0" },
|
||||
@@ -2104,6 +2117,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/98/caf06d5d22a7c129c1fb2fc1477306902a2c8ddfd399cd26bbbd4caf2141/gevent-25.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acd6bcd5feabf22c7c5174bd3b9535ee9f088d2bbce789f740ad8d6554b18f3", size = 1682837, upload-time = "2025-09-17T19:48:47.318Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gevent-websocket"
|
||||
version = "0.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "gevent" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/d2/6fa19239ff1ab072af40ebf339acd91fb97f34617c2ee625b8e34bf42393/gevent-websocket-0.10.1.tar.gz", hash = "sha256:7eaef32968290c9121f7c35b973e2cc302ffb076d018c9068d2f5ca8b2d85fb0", size = 18366, upload-time = "2017-03-12T22:46:05.68Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/84/2dc373eb6493e00c884cc11e6c059ec97abae2678d42f06bf780570b0193/gevent_websocket-0.10.1-py3-none-any.whl", hash = "sha256:17b67d91282f8f4c973eba0551183fc84f56f1c90c8f6b6b30256f31f66f5242", size = 22987, upload-time = "2017-03-12T22:46:03.611Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gitdb"
|
||||
version = "4.0.12"
|
||||
@@ -5073,6 +5098,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-engineio"
|
||||
version = "4.12.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "simple-websocket" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/d8/63e5535ab21dc4998ba1cfe13690ccf122883a38f025dca24d6e56c05eba/python_engineio-4.12.3.tar.gz", hash = "sha256:35633e55ec30915e7fc8f7e34ca8d73ee0c080cec8a8cd04faf2d7396f0a7a7a", size = 91910, upload-time = "2025-09-28T06:31:36.765Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/f0/c5aa0a69fd9326f013110653543f36ece4913c17921f3e1dbd78e1b423ee/python_engineio-4.12.3-py3-none-any.whl", hash = "sha256:7c099abb2a27ea7ab429c04da86ab2d82698cdd6c52406cb73766fe454feb7e1", size = 59637, upload-time = "2025-09-28T06:31:35.354Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-http-client"
|
||||
version = "3.3.7"
|
||||
@@ -5129,6 +5166,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-socketio"
|
||||
version = "5.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bidict" },
|
||||
{ name = "python-engineio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/1a/396d50ccf06ee539fa758ce5623b59a9cb27637fc4b2dc07ed08bf495e77/python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029", size = 121125, upload-time = "2025-04-12T15:46:59.933Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800, upload-time = "2025-04-12T15:46:58.412Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2025.2"
|
||||
@@ -5634,6 +5684,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simple-websocket"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
@@ -7032,6 +7094,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wsproto"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425, upload-time = "2022-08-23T19:58:21.447Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226, upload-time = "2022-08-23T19:58:19.96Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xinference-client"
|
||||
version = "1.2.2"
|
||||
|
||||
@@ -122,6 +122,10 @@ MIGRATION_ENABLED=true
|
||||
# The default value is 300 seconds.
|
||||
FILES_ACCESS_TIMEOUT=300
|
||||
|
||||
# Collaboration mode toggle
|
||||
# To open collaboration features, you also need to set SERVER_WORKER_CLASS=geventwebsocket.gunicorn.workers.GeventWebSocketWorker
|
||||
ENABLE_COLLABORATION_MODE=false
|
||||
|
||||
# Access token expiration time in minutes
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
@@ -149,6 +153,7 @@ DIFY_PORT=5001
|
||||
SERVER_WORKER_AMOUNT=1
|
||||
|
||||
# Defaults to gevent. If using windows, it can be switched to sync or solo.
|
||||
# If enable collaboration mode, it must be set to geventwebsocket.gunicorn.workers.GeventWebSocketWorker
|
||||
SERVER_WORKER_CLASS=gevent
|
||||
|
||||
# Default number of worker connections, the default is 10.
|
||||
|
||||
@@ -14,6 +14,14 @@ server {
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /socket.io/ {
|
||||
proxy_pass http://api:5001;
|
||||
include proxy.conf;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /v1 {
|
||||
proxy_pass http://api:5001;
|
||||
include proxy.conf;
|
||||
|
||||
@@ -5,7 +5,7 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
# proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout ${NGINX_PROXY_READ_TIMEOUT};
|
||||
proxy_send_timeout ${NGINX_PROXY_SEND_TIMEOUT};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Mock for context-block plugin to avoid circular dependency in Storybook
|
||||
export const ContextBlockNode = null
|
||||
export const ContextBlockReplacementBlock = null
|
||||
export default null
|
||||
@@ -0,0 +1,4 @@
|
||||
// Mock for history-block plugin to avoid circular dependency in Storybook
|
||||
export const HistoryBlockNode = null
|
||||
export const HistoryBlockReplacementBlock = null
|
||||
export default null
|
||||
@@ -0,0 +1,4 @@
|
||||
// Mock for query-block plugin to avoid circular dependency in Storybook
|
||||
export const QueryBlockNode = null
|
||||
export const QueryBlockReplacementBlock = null
|
||||
export default null
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { StorybookConfig } from '@storybook/nextjs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
@@ -25,5 +30,17 @@ const config: StorybookConfig = {
|
||||
docs: {
|
||||
defaultName: 'Documentation',
|
||||
},
|
||||
webpackFinal: async (config) => {
|
||||
// Add alias to mock problematic modules with circular dependencies
|
||||
config.resolve = config.resolve || {}
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
// Mock the plugin index files to avoid circular dependencies
|
||||
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/context-block/index.tsx')]: path.resolve(__dirname, '__mocks__/context-block.tsx'),
|
||||
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/history-block/index.tsx')]: path.resolve(__dirname, '__mocks__/history-block.tsx'),
|
||||
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/query-block/index.tsx')]: path.resolve(__dirname, '__mocks__/query-block.tsx'),
|
||||
}
|
||||
return config
|
||||
},
|
||||
}
|
||||
export default config
|
||||
|
||||
@@ -160,8 +160,7 @@ describe('Navigation Utilities', () => {
|
||||
page: 1,
|
||||
limit: '',
|
||||
keyword: 'test',
|
||||
empty: null,
|
||||
undefined,
|
||||
filter: '',
|
||||
})
|
||||
|
||||
expect(path).toBe('/datasets/123/documents?page=1&keyword=test')
|
||||
|
||||
@@ -39,28 +39,38 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa
|
||||
const isDarkQuery = DARK_MODE_MEDIA_QUERY.test(query)
|
||||
const matches = isDarkQuery ? systemPrefersDark : false
|
||||
|
||||
const handleAddListener = (listener: (event: MediaQueryListEvent) => void) => {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
const handleRemoveListener = (listener: (event: MediaQueryListEvent) => void) => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
|
||||
const handleAddEventListener = (_event: string, listener: EventListener) => {
|
||||
if (typeof listener === 'function')
|
||||
listeners.add(listener as (event: MediaQueryListEvent) => void)
|
||||
}
|
||||
|
||||
const handleRemoveEventListener = (_event: string, listener: EventListener) => {
|
||||
if (typeof listener === 'function')
|
||||
listeners.delete(listener as (event: MediaQueryListEvent) => void)
|
||||
}
|
||||
|
||||
const handleDispatchEvent = (event: Event) => {
|
||||
listeners.forEach(listener => listener(event as MediaQueryListEvent))
|
||||
return true
|
||||
}
|
||||
|
||||
const mediaQueryList: MediaQueryList = {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: (listener: MediaQueryListListener) => {
|
||||
listeners.add(listener)
|
||||
},
|
||||
removeListener: (listener: MediaQueryListListener) => {
|
||||
listeners.delete(listener)
|
||||
},
|
||||
addEventListener: (_event, listener: EventListener) => {
|
||||
if (typeof listener === 'function')
|
||||
listeners.add(listener as MediaQueryListListener)
|
||||
},
|
||||
removeEventListener: (_event, listener: EventListener) => {
|
||||
if (typeof listener === 'function')
|
||||
listeners.delete(listener as MediaQueryListListener)
|
||||
},
|
||||
dispatchEvent: (event: Event) => {
|
||||
listeners.forEach(listener => listener(event as MediaQueryListEvent))
|
||||
return true
|
||||
},
|
||||
addListener: handleAddListener,
|
||||
removeListener: handleRemoveListener,
|
||||
addEventListener: handleAddEventListener,
|
||||
removeEventListener: handleRemoveEventListener,
|
||||
dispatchEvent: handleDispatchEvent,
|
||||
}
|
||||
|
||||
return mediaQueryList
|
||||
@@ -69,6 +79,121 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa
|
||||
jest.spyOn(window, 'matchMedia').mockImplementation(mockMatchMedia)
|
||||
}
|
||||
|
||||
// Helper function to create timing page component
|
||||
const createTimingPageComponent = (
|
||||
timingData: Array<{ phase: string; timestamp: number; styles: { backgroundColor: string; color: string } }>,
|
||||
) => {
|
||||
const recordTiming = (phase: string, styles: { backgroundColor: string; color: string }) => {
|
||||
timingData.push({
|
||||
phase,
|
||||
timestamp: performance.now(),
|
||||
styles,
|
||||
})
|
||||
}
|
||||
|
||||
const TimingPageComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
const currentStyles = {
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
color: isDark ? '#ffffff' : '#000000',
|
||||
}
|
||||
|
||||
recordTiming(mounted ? 'CSR' : 'Initial', currentStyles)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="timing-page"
|
||||
style={currentStyles}
|
||||
>
|
||||
<div data-testid="timing-status">
|
||||
Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return TimingPageComponent
|
||||
}
|
||||
|
||||
// Helper function to create CSS test component
|
||||
const createCSSTestComponent = (
|
||||
cssStates: Array<{ className: string; timestamp: number }>,
|
||||
) => {
|
||||
const recordCSSState = (className: string) => {
|
||||
cssStates.push({
|
||||
className,
|
||||
timestamp: performance.now(),
|
||||
})
|
||||
}
|
||||
|
||||
const CSSTestComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}`
|
||||
|
||||
recordCSSState(className)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="css-component"
|
||||
className={className}
|
||||
>
|
||||
<div data-testid="css-classes">Classes: {className}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return CSSTestComponent
|
||||
}
|
||||
|
||||
// Helper function to create performance test component
|
||||
const createPerformanceTestComponent = (
|
||||
performanceMarks: Array<{ event: string; timestamp: number }>,
|
||||
) => {
|
||||
const recordPerformanceMark = (event: string) => {
|
||||
performanceMarks.push({ event, timestamp: performance.now() })
|
||||
}
|
||||
|
||||
const PerformanceTestComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
recordPerformanceMark('component-render')
|
||||
|
||||
useEffect(() => {
|
||||
recordPerformanceMark('mount-start')
|
||||
setMounted(true)
|
||||
recordPerformanceMark('mount-complete')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (theme)
|
||||
recordPerformanceMark('theme-available')
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<div data-testid="performance-test">
|
||||
Mounted: {mounted.toString()} | Theme: {theme || 'loading'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return PerformanceTestComponent
|
||||
}
|
||||
|
||||
// Simulate real page component based on Dify's actual theme usage
|
||||
const PageComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
@@ -227,39 +352,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
const timingData: Array<{ phase: string; timestamp: number; styles: any }> = []
|
||||
|
||||
const TimingPageComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
// Record timing and styles for each render phase
|
||||
const currentStyles = {
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
color: isDark ? '#ffffff' : '#000000',
|
||||
}
|
||||
|
||||
timingData.push({
|
||||
phase: mounted ? 'CSR' : 'Initial',
|
||||
timestamp: performance.now(),
|
||||
styles: currentStyles,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="timing-page"
|
||||
style={currentStyles}
|
||||
>
|
||||
<div data-testid="timing-status">
|
||||
Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const TimingPageComponent = createTimingPageComponent(timingData)
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
@@ -295,33 +388,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
const cssStates: Array<{ className: string; timestamp: number }> = []
|
||||
|
||||
const CSSTestComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
// Simulate Tailwind CSS class application
|
||||
const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}`
|
||||
|
||||
cssStates.push({
|
||||
className,
|
||||
timestamp: performance.now(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="css-component"
|
||||
className={className}
|
||||
>
|
||||
<div data-testid="css-classes">Classes: {className}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const CSSTestComponent = createCSSTestComponent(cssStates)
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
@@ -413,34 +480,12 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
|
||||
test('verifies ThemeProvider position fix reduces initialization delay', async () => {
|
||||
const performanceMarks: Array<{ event: string; timestamp: number }> = []
|
||||
|
||||
const PerformanceTestComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
performanceMarks.push({ event: 'component-render', timestamp: performance.now() })
|
||||
|
||||
useEffect(() => {
|
||||
performanceMarks.push({ event: 'mount-start', timestamp: performance.now() })
|
||||
setMounted(true)
|
||||
performanceMarks.push({ event: 'mount-complete', timestamp: performance.now() })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (theme)
|
||||
performanceMarks.push({ event: 'theme-available', timestamp: performance.now() })
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<div data-testid="performance-test">
|
||||
Mounted: {mounted.toString()} | Theme: {theme || 'loading'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
expect(window.localStorage.getItem('theme')).toBe('dark')
|
||||
|
||||
const PerformanceTestComponent = createPerformanceTestComponent(performanceMarks)
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PerformanceTestComponent />
|
||||
|
||||
@@ -70,14 +70,18 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
|
||||
})
|
||||
|
||||
describe('Fallback Logic (from layout-main.tsx)', () => {
|
||||
type Tag = { id: string; name: string }
|
||||
type AppDetail = { tags: Tag[] }
|
||||
type FallbackResult = { tags?: Tag[] } | null
|
||||
// no-op
|
||||
it('should trigger fallback when tags are missing or empty', () => {
|
||||
const appDetailWithoutTags = { tags: [] }
|
||||
const appDetailWithTags = { tags: [{ id: 'tag1' }] }
|
||||
const appDetailWithUndefinedTags = { tags: undefined as any }
|
||||
const appDetailWithoutTags: AppDetail = { tags: [] }
|
||||
const appDetailWithTags: AppDetail = { tags: [{ id: 'tag1', name: 't' }] }
|
||||
const appDetailWithUndefinedTags: { tags: Tag[] | undefined } = { tags: undefined }
|
||||
|
||||
// This simulates the condition in layout-main.tsx
|
||||
const shouldFallback1 = !appDetailWithoutTags.tags || appDetailWithoutTags.tags.length === 0
|
||||
const shouldFallback2 = !appDetailWithTags.tags || appDetailWithTags.tags.length === 0
|
||||
const shouldFallback1 = appDetailWithoutTags.tags.length === 0
|
||||
const shouldFallback2 = appDetailWithTags.tags.length === 0
|
||||
const shouldFallback3 = !appDetailWithUndefinedTags.tags || appDetailWithUndefinedTags.tags.length === 0
|
||||
|
||||
expect(shouldFallback1).toBe(true) // Empty array should trigger fallback
|
||||
@@ -86,24 +90,26 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
|
||||
})
|
||||
|
||||
it('should preserve tags when fallback succeeds', () => {
|
||||
const originalAppDetail = { tags: [] as any[] }
|
||||
const fallbackResult = { tags: [{ id: 'tag1', name: 'fallback-tag' }] }
|
||||
const originalAppDetail: AppDetail = { tags: [] }
|
||||
const fallbackResult: { tags?: Tag[] } = { tags: [{ id: 'tag1', name: 'fallback-tag' }] }
|
||||
|
||||
// This simulates the successful fallback in layout-main.tsx
|
||||
if (fallbackResult?.tags)
|
||||
originalAppDetail.tags = fallbackResult.tags
|
||||
const tags = fallbackResult.tags
|
||||
if (tags)
|
||||
originalAppDetail.tags = tags
|
||||
|
||||
expect(originalAppDetail.tags).toEqual(fallbackResult.tags)
|
||||
expect(originalAppDetail.tags.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should continue with empty tags when fallback fails', () => {
|
||||
const originalAppDetail: { tags: any[] } = { tags: [] }
|
||||
const fallbackResult: { tags?: any[] } | null = null
|
||||
const originalAppDetail: AppDetail = { tags: [] }
|
||||
const fallbackResult = null as FallbackResult
|
||||
|
||||
// This simulates fallback failure in layout-main.tsx
|
||||
if (fallbackResult?.tags)
|
||||
originalAppDetail.tags = fallbackResult.tags
|
||||
const tags: Tag[] | undefined = fallbackResult && 'tags' in fallbackResult ? fallbackResult.tags : undefined
|
||||
if (tags)
|
||||
originalAppDetail.tags = tags
|
||||
|
||||
expect(originalAppDetail.tags).toEqual([])
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import AppCard from '@/app/components/app/overview/app-card'
|
||||
@@ -19,6 +19,8 @@ import { asyncRunSafe } from '@/utils'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import type { IAppCardProps } from '@/app/components/app/overview/app-card'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
|
||||
export type ICardViewProps = {
|
||||
appId: string
|
||||
@@ -47,15 +49,44 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
|
||||
|
||||
message ||= (type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully')
|
||||
|
||||
if (type === 'success')
|
||||
if (type === 'success') {
|
||||
updateAppDetail()
|
||||
|
||||
// Emit collaboration event to notify other clients of app state changes
|
||||
const socket = webSocketClient.getSocket(appId)
|
||||
if (socket) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'app_state_update',
|
||||
data: { timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
notify({
|
||||
type,
|
||||
message: t(`common.actionMsg.${message}`),
|
||||
})
|
||||
}
|
||||
|
||||
// Listen for collaborative app state updates from other clients
|
||||
useEffect(() => {
|
||||
if (!appId) return
|
||||
|
||||
const unsubscribe = collaborationManager.onAppStateUpdate(async (update: any) => {
|
||||
try {
|
||||
console.log('Received app state update from collaboration:', update)
|
||||
// Update app detail when other clients modify app state
|
||||
await updateAppDetail()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('app state update failed:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appId])
|
||||
|
||||
const onChangeSiteStatus = async (value: boolean) => {
|
||||
const [err] = await asyncRunSafe<App>(
|
||||
updateAppSiteStatus({
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ const ConfigPopup: FC<PopupProps> = ({
|
||||
}
|
||||
}, [onChooseProvider])
|
||||
|
||||
const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => {
|
||||
const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | TencentConfig) => {
|
||||
onConfigUpdated(currentProvider!, payload)
|
||||
hideConfigModal()
|
||||
}, [currentProvider, hideConfigModal, onConfigUpdated])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
@@ -16,7 +16,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import { copyApp, deleteApp, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps'
|
||||
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
@@ -31,6 +31,8 @@ import type { Operation } from './app-operations'
|
||||
import AppOperations from './app-operations'
|
||||
import dynamic from 'next/dynamic'
|
||||
import cn from '@/utils/classnames'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
|
||||
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
|
||||
ssr: false,
|
||||
@@ -74,6 +76,19 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
|
||||
const [showExportWarning, setShowExportWarning] = useState(false)
|
||||
|
||||
const emitAppMetaUpdate = useCallback(() => {
|
||||
if (!appDetail?.id)
|
||||
return
|
||||
const socket = webSocketClient.getSocket(appDetail.id)
|
||||
if (socket) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'app_meta_update',
|
||||
data: { timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}, [appDetail?.id])
|
||||
|
||||
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
icon_type,
|
||||
@@ -102,11 +117,12 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
message: t('app.editDone'),
|
||||
})
|
||||
setAppDetail(app)
|
||||
emitAppMetaUpdate()
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.editFailed') })
|
||||
}
|
||||
}, [appDetail, notify, setAppDetail, t])
|
||||
}, [appDetail, notify, setAppDetail, t, emitAppMetaUpdate])
|
||||
|
||||
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
|
||||
if (!appDetail)
|
||||
@@ -203,6 +219,23 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
setShowConfirmDelete(false)
|
||||
}, [appDetail, notify, onPlanInfoChanged, replace, setAppDetail, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!appDetail?.id)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onAppMetaUpdate(async () => {
|
||||
try {
|
||||
const res = await fetchAppDetail({ url: '/apps', id: appDetail.id })
|
||||
setAppDetail({ ...res })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('failed to refresh app detail from collaboration update:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appDetail?.id, setAppDetail])
|
||||
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
|
||||
if (!appDetail)
|
||||
|
||||
@@ -47,6 +47,9 @@ import { AccessMode } from '@/models/access-control'
|
||||
import { fetchAppDetail } from '@/service/apps'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
|
||||
|
||||
export type AppPublisherProps = {
|
||||
disabled?: boolean
|
||||
@@ -96,6 +99,7 @@ const AppPublisher = ({
|
||||
const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '')
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
const invalidateAppWorkflow = useInvalidateAppWorkflow()
|
||||
|
||||
useEffect(() => {
|
||||
if (systemFeatures.webapp_auth.enabled && open && appDetail)
|
||||
@@ -120,11 +124,27 @@ const AppPublisher = ({
|
||||
try {
|
||||
await onPublish?.(params)
|
||||
setPublished(true)
|
||||
|
||||
const appId = appDetail?.id
|
||||
const socket = appId ? webSocketClient.getSocket(appId) : null
|
||||
if (appId)
|
||||
invalidateAppWorkflow(appId)
|
||||
if (socket) {
|
||||
const timestamp = Date.now()
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'app_publish_update',
|
||||
data: {
|
||||
action: 'published',
|
||||
timestamp,
|
||||
},
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
catch {
|
||||
setPublished(false)
|
||||
}
|
||||
}, [onPublish])
|
||||
}, [appDetail?.id, onPublish, invalidateAppWorkflow])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
try {
|
||||
@@ -178,6 +198,18 @@ const AppPublisher = ({
|
||||
handlePublish()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useEffect(() => {
|
||||
const appId = appDetail?.id
|
||||
if (!appId) return
|
||||
|
||||
const unsubscribe = collaborationManager.onAppPublishUpdate((update: any) => {
|
||||
if (update?.data?.action === 'published')
|
||||
invalidateAppWorkflow(appId)
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appDetail?.id, invalidateAppWorkflow])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
|
||||
@@ -32,6 +32,8 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { formatTime } from '@/utils/time'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import type { WorkflowOnlineUser } from '@/models/app'
|
||||
|
||||
const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
|
||||
ssr: false,
|
||||
@@ -55,9 +57,10 @@ const AccessControl = dynamic(() => import('@/app/components/app/app-access-cont
|
||||
export type AppCardProps = {
|
||||
app: App
|
||||
onRefresh?: () => void
|
||||
onlineUsers?: WorkflowOnlineUser[]
|
||||
}
|
||||
|
||||
const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
@@ -333,6 +336,19 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
return `${t('datasetDocuments.segment.editedAt')} ${timeText}`
|
||||
}, [app.updated_at, app.created_at])
|
||||
|
||||
const onlineUserAvatars = useMemo(() => {
|
||||
if (!onlineUsers.length)
|
||||
return []
|
||||
|
||||
return onlineUsers
|
||||
.map(user => ({
|
||||
id: user.user_id || user.sid || '',
|
||||
name: user.username || 'User',
|
||||
avatar_url: user.avatar || undefined,
|
||||
}))
|
||||
.filter(user => !!user.id)
|
||||
}, [onlineUsers])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -377,6 +393,11 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
<RiVerifiedBadgeLine className='h-4 w-4 text-text-quaternary' />
|
||||
</Tooltip>}
|
||||
</div>
|
||||
<div>
|
||||
{onlineUserAvatars.length > 0 && (
|
||||
<UserAvatarList users={onlineUserAvatars} maxVisible={3} size={20} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'>
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
useRouter,
|
||||
} from 'next/navigation'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
import useSWR from 'swr'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import {
|
||||
@@ -19,8 +20,8 @@ import AppCard from './app-card'
|
||||
import NewAppCard from './new-app-card'
|
||||
import useAppsQueryState from './hooks/use-apps-query-state'
|
||||
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import { fetchAppList } from '@/service/apps'
|
||||
import type { AppListResponse, WorkflowOnlineUser } from '@/models/app'
|
||||
import { fetchAppList, fetchWorkflowOnlineUsers } from '@/service/apps'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
@@ -112,6 +113,36 @@ const List = () => {
|
||||
},
|
||||
)
|
||||
|
||||
const apps = useMemo(() => data?.flatMap(page => page.data) ?? [], [data])
|
||||
|
||||
const workflowIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
apps.forEach((appItem) => {
|
||||
const workflowId = appItem.id
|
||||
if (!workflowId)
|
||||
return
|
||||
|
||||
if (appItem.mode === 'workflow' || appItem.mode === 'advanced-chat')
|
||||
ids.add(workflowId)
|
||||
})
|
||||
return Array.from(ids)
|
||||
}, [apps])
|
||||
|
||||
const { data: onlineUsersByWorkflow, mutate: refreshOnlineUsers } = useSWR<Record<string, WorkflowOnlineUser[]>>(
|
||||
workflowIds.length ? { workflowIds } : null,
|
||||
fetchWorkflowOnlineUsers,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
mutate()
|
||||
if (workflowIds.length)
|
||||
refreshOnlineUsers()
|
||||
}, 10000)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [workflowIds.join(','), mutate, refreshOnlineUsers])
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const options = [
|
||||
{ value: 'all', text: t('app.types.all'), icon: <RiApps2Line className='mr-1 h-[14px] w-[14px]' /> },
|
||||
@@ -213,7 +244,12 @@ const List = () => {
|
||||
{isCurrentWorkspaceEditor
|
||||
&& <NewAppCard ref={newAppCardRef} onSuccess={mutate} selectedAppType={activeTab} />}
|
||||
{data.map(({ data: apps }) => apps.map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={mutate} />
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
onRefresh={mutate}
|
||||
onlineUsers={onlineUsersByWorkflow?.[app.id] ?? []}
|
||||
/>
|
||||
)))}
|
||||
</div>
|
||||
: <div className='relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShareLine } from '@remixicon/react'
|
||||
import ActionButton, { ActionButtonState } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/ActionButton',
|
||||
component: ActionButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Action button component with multiple sizes and states. Commonly used for toolbar actions and inline operations.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'm', 'l', 'xl'],
|
||||
description: 'Button size',
|
||||
},
|
||||
state: {
|
||||
control: 'select',
|
||||
options: [
|
||||
ActionButtonState.Default,
|
||||
ActionButtonState.Active,
|
||||
ActionButtonState.Disabled,
|
||||
ActionButtonState.Destructive,
|
||||
ActionButtonState.Hover,
|
||||
],
|
||||
description: 'Button state',
|
||||
},
|
||||
children: {
|
||||
control: 'text',
|
||||
description: 'Button content',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Native disabled state',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ActionButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
// With text
|
||||
export const WithText: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
children: 'Edit',
|
||||
},
|
||||
}
|
||||
|
||||
// Icon with text
|
||||
export const IconWithText: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
children: (
|
||||
<>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
Add Item
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
// Size variations
|
||||
export const ExtraSmall: Story = {
|
||||
args: {
|
||||
size: 'xs',
|
||||
children: <RiEditLine className="h-3 w-3" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: 'xs',
|
||||
children: <RiEditLine className="h-3.5 w-3.5" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const Medium: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
size: 'l',
|
||||
children: <RiEditLine className="h-5 w-5" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const ExtraLarge: Story = {
|
||||
args: {
|
||||
size: 'xl',
|
||||
children: <RiEditLine className="h-6 w-6" />,
|
||||
},
|
||||
}
|
||||
|
||||
// State variations
|
||||
export const ActiveState: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Active,
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledState: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Disabled,
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const DestructiveState: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Destructive,
|
||||
children: <RiDeleteBinLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const HoverState: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Hover,
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world examples
|
||||
export const ToolbarActions: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-1 rounded-lg bg-background-section-burn p-2">
|
||||
<ActionButton size="m">
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<ActionButton size="m">
|
||||
<RiShareLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<ActionButton size="m">
|
||||
<RiSaveLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<div className="mx-1 h-4 w-px bg-divider-regular" />
|
||||
<ActionButton size="m" state={ActionButtonState.Destructive}>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const InlineActions: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-secondary">Item name</span>
|
||||
<ActionButton size="xs">
|
||||
<RiEditLine className="h-3.5 w-3.5" />
|
||||
</ActionButton>
|
||||
<ActionButton size="xs">
|
||||
<RiMore2Fill className="h-3.5 w-3.5" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="xs">
|
||||
<RiEditLine className="h-3 w-3" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">XS</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="xs">
|
||||
<RiEditLine className="h-3.5 w-3.5" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">S</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m">
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">M</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="l">
|
||||
<RiEditLine className="h-5 w-5" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">L</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="xl">
|
||||
<RiEditLine className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">XL</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const StateComparison: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Default}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Default</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Active}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Active</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Hover}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Hover</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Disabled}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Disabled</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Destructive}>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Destructive</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Default,
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import AutoHeightTextarea from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/AutoHeightTextarea',
|
||||
component: AutoHeightTextarea,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Auto-resizing textarea component that expands and contracts based on content, with configurable min/max height constraints.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Textarea value',
|
||||
},
|
||||
minHeight: {
|
||||
control: 'number',
|
||||
description: 'Minimum height in pixels',
|
||||
},
|
||||
maxHeight: {
|
||||
control: 'number',
|
||||
description: 'Maximum height in pixels',
|
||||
},
|
||||
autoFocus: {
|
||||
control: 'boolean',
|
||||
description: 'Auto focus on mount',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes',
|
||||
},
|
||||
wrapperClassName: {
|
||||
control: 'text',
|
||||
description: 'Wrapper CSS classes',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AutoHeightTextarea>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const AutoHeightTextareaDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }}>
|
||||
<AutoHeightTextarea
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
console.log('Text changed:', e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: '',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// With initial value
|
||||
export const WithInitialValue: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: 'This is a pre-filled textarea with some initial content.',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// With multiline content
|
||||
export const MultilineContent: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: 'Line 1\nLine 2\nLine 3\nLine 4\nThis textarea automatically expands to fit the content.',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Custom min height
|
||||
export const CustomMinHeight: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Taller minimum height...',
|
||||
value: '',
|
||||
minHeight: 100,
|
||||
maxHeight: 200,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Small max height (scrollable)
|
||||
export const SmallMaxHeight: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type multiple lines...',
|
||||
value: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nThis will become scrollable when it exceeds max height.',
|
||||
minHeight: 36,
|
||||
maxHeight: 80,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Auto focus enabled
|
||||
export const AutoFocus: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'This textarea auto-focuses on mount',
|
||||
value: '',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
autoFocus: true,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// With custom styling
|
||||
export const CustomStyling: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Custom styled textarea...',
|
||||
value: '',
|
||||
minHeight: 50,
|
||||
maxHeight: 150,
|
||||
className: 'w-full p-3 bg-gray-50 border-2 border-blue-400 rounded-xl text-lg focus:outline-none focus:bg-white focus:border-blue-600',
|
||||
wrapperClassName: 'shadow-lg',
|
||||
},
|
||||
}
|
||||
|
||||
// Long content example
|
||||
export const LongContent: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
|
||||
minHeight: 36,
|
||||
maxHeight: 200,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Chat input
|
||||
export const ChatInput: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type your message...',
|
||||
value: '',
|
||||
minHeight: 40,
|
||||
maxHeight: 120,
|
||||
className: 'w-full px-4 py-2 bg-gray-100 border border-gray-300 rounded-2xl text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Comment box
|
||||
export const CommentBox: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Write a comment...',
|
||||
value: '',
|
||||
minHeight: 60,
|
||||
maxHeight: 200,
|
||||
className: 'w-full p-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: '',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
autoFocus: false,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
wrapperClassName: '',
|
||||
},
|
||||
}
|
||||
@@ -31,7 +31,7 @@ const AutoHeightTextarea = (
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
}: IProps & {
|
||||
ref: React.RefObject<unknown>;
|
||||
ref?: React.RefObject<HTMLTextAreaElement>;
|
||||
},
|
||||
) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
|
||||
@@ -9,6 +9,7 @@ export type AvatarProps = {
|
||||
className?: string
|
||||
textClassName?: string
|
||||
onError?: (x: boolean) => void
|
||||
backgroundColor?: string
|
||||
}
|
||||
const Avatar = ({
|
||||
name,
|
||||
@@ -17,9 +18,18 @@ const Avatar = ({
|
||||
className,
|
||||
textClassName,
|
||||
onError,
|
||||
backgroundColor,
|
||||
}: AvatarProps) => {
|
||||
const avatarClassName = 'shrink-0 flex items-center rounded-full bg-primary-600'
|
||||
const style = { width: `${size}px`, height: `${size}px`, fontSize: `${size}px`, lineHeight: `${size}px` }
|
||||
const avatarClassName = backgroundColor
|
||||
? 'shrink-0 flex items-center rounded-full'
|
||||
: 'shrink-0 flex items-center rounded-full bg-primary-600'
|
||||
const style = {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
fontSize: `${size}px`,
|
||||
lineHeight: `${size}px`,
|
||||
...(backgroundColor && !avatar ? { backgroundColor } : {}),
|
||||
}
|
||||
const [imgError, setImgError] = useState(false)
|
||||
|
||||
const handleError = () => {
|
||||
@@ -35,14 +45,18 @@ const Avatar = ({
|
||||
|
||||
if (avatar && !imgError) {
|
||||
return (
|
||||
<img
|
||||
<span
|
||||
className={cn(avatarClassName, className)}
|
||||
style={style}
|
||||
alt={name}
|
||||
src={avatar}
|
||||
onError={handleError}
|
||||
onLoad={() => onError?.(false)}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
className='h-full w-full rounded-full object-cover'
|
||||
alt={name}
|
||||
src={avatar}
|
||||
onError={handleError}
|
||||
onLoad={() => onError?.(false)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import BlockInput from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/BlockInput',
|
||||
component: BlockInput,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Block input component with variable highlighting. Supports {{variable}} syntax with validation and visual highlighting of variable names.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Input value (supports {{variable}} syntax)',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Wrapper CSS classes',
|
||||
},
|
||||
highLightClassName: {
|
||||
control: 'text',
|
||||
description: 'CSS class for highlighted variables (default: text-blue-500)',
|
||||
},
|
||||
readonly: {
|
||||
control: 'boolean',
|
||||
description: 'Read-only mode',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof BlockInput>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const BlockInputDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
const [keys, setKeys] = useState<string[]>([])
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }}>
|
||||
<BlockInput
|
||||
{...args}
|
||||
value={value}
|
||||
onConfirm={(newValue, extractedKeys) => {
|
||||
setValue(newValue)
|
||||
setKeys(extractedKeys)
|
||||
console.log('Value confirmed:', newValue)
|
||||
console.log('Extracted keys:', extractedKeys)
|
||||
}}
|
||||
/>
|
||||
{keys.length > 0 && (
|
||||
<div className="mt-4 rounded-lg bg-blue-50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-gray-700">Detected Variables:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keys.map(key => (
|
||||
<span key={key} className="rounded bg-blue-500 px-2 py-1 text-xs text-white">
|
||||
{key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: '',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With single variable
|
||||
export const SingleVariable: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Hello {{name}}, welcome to the application!',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With multiple variables
|
||||
export const MultipleVariables: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Dear {{user_name}},\n\nYour order {{order_id}} has been shipped to {{address}}.\n\nThank you for shopping with us!',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Complex template
|
||||
export const ComplexTemplate: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Hi {{customer_name}},\n\nYour {{product_type}} subscription will renew on {{renewal_date}} for {{amount}}.\n\nYour payment method ending in {{card_last_4}} will be charged.\n\nQuestions? Contact us at {{support_email}}.',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Read-only mode
|
||||
export const ReadOnlyMode: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'This is a read-only template with {{variable1}} and {{variable2}}.\n\nYou cannot edit this content.',
|
||||
readonly: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Empty state
|
||||
export const EmptyState: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: '',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Long content
|
||||
export const LongContent: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Dear {{recipient_name}},\n\nWe are writing to inform you about the upcoming changes to your {{service_name}} account.\n\nEffective {{effective_date}}, your plan will include:\n\n1. Access to {{feature_1}}\n2. {{feature_2}} with unlimited usage\n3. Priority support via {{support_channel}}\n4. Monthly reports sent to {{email_address}}\n\nYour new monthly rate will be {{new_price}}, compared to your current rate of {{old_price}}.\n\nIf you have any questions, please contact our team at {{contact_info}}.\n\nBest regards,\n{{company_name}} Team',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Variables with underscores
|
||||
export const VariablesWithUnderscores: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'User {{user_id}} from {{user_country}} has {{total_orders}} orders with status {{order_status}}.',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Adjacent variables
|
||||
export const AdjacentVariables: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'File: {{file_name}}.{{file_extension}} ({{file_size}}{{size_unit}})',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Email template
|
||||
export const EmailTemplate: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Subject: Your {{service_name}} account has been created\n\nHi {{first_name}},\n\nWelcome to {{company_name}}! Your account is now active.\n\nUsername: {{username}}\nEmail: {{email}}\n\nGet started at {{app_url}}\n\nThanks,\nThe {{company_name}} Team',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Notification template
|
||||
export const NotificationTemplate: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: '🔔 {{user_name}} mentioned you in {{channel_name}}\n\n"{{message_preview}}"\n\nReply now: {{message_url}}',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Custom styling
|
||||
export const CustomStyling: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'This template uses {{custom_variable}} with custom styling.',
|
||||
readonly: false,
|
||||
className: 'bg-gray-50 border-2 border-blue-200',
|
||||
},
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Try editing this text and adding variables like {{example}}',
|
||||
readonly: false,
|
||||
className: '',
|
||||
highLightClassName: '',
|
||||
},
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
|
||||
import type { ChatItem } from '../../types'
|
||||
import { mockedWorkflowProcess } from './__mocks__/workflowProcess'
|
||||
import { markdownContent } from './__mocks__/markdownContent'
|
||||
import { markdownContentSVG } from './__mocks__/markdownContentSVG'
|
||||
import Answer from '.'
|
||||
@@ -34,6 +32,11 @@ const mockedBaseChatItem = {
|
||||
content: 'Hello, how can I assist you today?',
|
||||
} satisfies ChatItem
|
||||
|
||||
const mockedWorkflowProcess = {
|
||||
status: 'succeeded',
|
||||
tracing: [],
|
||||
}
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
item: mockedBaseChatItem,
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Checkbox from '.'
|
||||
|
||||
// Helper function for toggling items in an array
|
||||
const createToggleItem = <T extends { id: string; checked: boolean }>(
|
||||
items: T[],
|
||||
setItems: (items: T[]) => void,
|
||||
) => (id: string) => {
|
||||
setItems(items.map(item =>
|
||||
item.id === id ? { ...item, checked: !item.checked } as T : item,
|
||||
))
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Checkbox',
|
||||
component: Checkbox,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Checkbox component with support for checked, unchecked, indeterminate, and disabled states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
checked: {
|
||||
control: 'boolean',
|
||||
description: 'Checked state',
|
||||
},
|
||||
indeterminate: {
|
||||
control: 'boolean',
|
||||
description: 'Indeterminate state (partially checked)',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes',
|
||||
},
|
||||
id: {
|
||||
control: 'text',
|
||||
description: 'HTML id attribute',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Checkbox>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const CheckboxDemo = (args: any) => {
|
||||
const [checked, setChecked] = useState(args.checked || false)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
{...args}
|
||||
checked={checked}
|
||||
onCheck={() => {
|
||||
if (!args.disabled) {
|
||||
setChecked(!checked)
|
||||
console.log('Checkbox toggled:', !checked)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{checked ? 'Checked' : 'Unchecked'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default unchecked
|
||||
export const Default: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
indeterminate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Checked state
|
||||
export const Checked: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: false,
|
||||
indeterminate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Indeterminate state
|
||||
export const Indeterminate: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
indeterminate: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled unchecked
|
||||
export const DisabledUnchecked: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: true,
|
||||
indeterminate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled checked
|
||||
export const DisabledChecked: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: true,
|
||||
indeterminate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled indeterminate
|
||||
export const DisabledIndeterminate: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: true,
|
||||
indeterminate: true,
|
||||
},
|
||||
}
|
||||
|
||||
// State comparison
|
||||
export const StateComparison: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={false} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Unchecked</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Checked</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={false} indeterminate={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Indeterminate</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={false} disabled={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Disabled</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={true} disabled={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Disabled Checked</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={false} indeterminate={true} disabled={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Disabled Indeterminate</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// With labels
|
||||
const WithLabelsDemo = () => {
|
||||
const [items, setItems] = useState([
|
||||
{ id: '1', label: 'Enable notifications', checked: true },
|
||||
{ id: '2', label: 'Enable email updates', checked: false },
|
||||
{ id: '3', label: 'Enable SMS alerts', checked: false },
|
||||
])
|
||||
|
||||
const toggleItem = createToggleItem(items, setItems)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={item.id}
|
||||
checked={item.checked}
|
||||
onCheck={() => toggleItem(item.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className="cursor-pointer text-sm text-gray-700"
|
||||
onClick={() => toggleItem(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithLabels: Story = {
|
||||
render: () => <WithLabelsDemo />,
|
||||
}
|
||||
|
||||
// Select all example
|
||||
const SelectAllExampleDemo = () => {
|
||||
const [items, setItems] = useState([
|
||||
{ id: '1', label: 'Item 1', checked: false },
|
||||
{ id: '2', label: 'Item 2', checked: false },
|
||||
{ id: '3', label: 'Item 3', checked: false },
|
||||
])
|
||||
|
||||
const allChecked = items.every(item => item.checked)
|
||||
const someChecked = items.some(item => item.checked)
|
||||
const indeterminate = someChecked && !allChecked
|
||||
|
||||
const toggleAll = () => {
|
||||
const newChecked = !allChecked
|
||||
setItems(items.map(item => ({ ...item, checked: newChecked })))
|
||||
}
|
||||
|
||||
const toggleItem = createToggleItem(items, setItems)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg bg-gray-50 p-4">
|
||||
<div className="flex items-center gap-3 border-b border-gray-200 pb-3">
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={indeterminate}
|
||||
onCheck={toggleAll}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Select All</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pl-7">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={item.id}
|
||||
checked={item.checked}
|
||||
onCheck={() => toggleItem(item.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className="cursor-pointer text-sm text-gray-600"
|
||||
onClick={() => toggleItem(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SelectAllExample: Story = {
|
||||
render: () => <SelectAllExampleDemo />,
|
||||
}
|
||||
|
||||
// Form example
|
||||
const FormExampleDemo = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
terms: false,
|
||||
newsletter: false,
|
||||
privacy: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-96 rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Account Settings</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={formData.terms}
|
||||
onCheck={() => setFormData({ ...formData, terms: !formData.terms })}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="terms" className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
I agree to the terms and conditions
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Required to continue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="newsletter"
|
||||
checked={formData.newsletter}
|
||||
onCheck={() => setFormData({ ...formData, newsletter: !formData.newsletter })}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="newsletter" className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
Subscribe to newsletter
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Get updates about new features
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="privacy"
|
||||
checked={formData.privacy}
|
||||
onCheck={() => setFormData({ ...formData, privacy: !formData.privacy })}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="privacy" className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
I have read the privacy policy
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Required to continue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormExample: Story = {
|
||||
render: () => <FormExampleDemo />,
|
||||
}
|
||||
|
||||
// Task list example
|
||||
const TaskListExampleDemo = () => {
|
||||
const [tasks, setTasks] = useState([
|
||||
{ id: '1', title: 'Review pull request', completed: true },
|
||||
{ id: '2', title: 'Update documentation', completed: true },
|
||||
{ id: '3', title: 'Fix navigation bug', completed: false },
|
||||
{ id: '4', title: 'Deploy to staging', completed: false },
|
||||
])
|
||||
|
||||
const toggleTask = (id: string) => {
|
||||
setTasks(tasks.map(task =>
|
||||
task.id === id ? { ...task, completed: !task.completed } : task,
|
||||
))
|
||||
}
|
||||
|
||||
const completedCount = tasks.filter(t => t.completed).length
|
||||
|
||||
return (
|
||||
<div className="w-96 rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Today's Tasks</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{completedCount} of {tasks.length} completed
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{tasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 rounded p-2 hover:bg-gray-50"
|
||||
>
|
||||
<Checkbox
|
||||
id={task.id}
|
||||
checked={task.completed}
|
||||
onCheck={() => toggleTask(task.id)}
|
||||
/>
|
||||
<span
|
||||
className={`cursor-pointer text-sm ${
|
||||
task.completed ? 'text-gray-400 line-through' : 'text-gray-700'
|
||||
}`}
|
||||
onClick={() => toggleTask(task.id)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TaskListExample: Story = {
|
||||
render: () => <TaskListExampleDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
indeterminate: false,
|
||||
disabled: false,
|
||||
id: 'playground-checkbox',
|
||||
},
|
||||
}
|
||||
@@ -15,11 +15,12 @@ const ContentDialog = ({
|
||||
onClose,
|
||||
children,
|
||||
}: ContentDialogProps) => {
|
||||
// z-[70]: Ensures dialog appears above workflow operators (z-[60]) and other UI elements
|
||||
return (
|
||||
<Transition
|
||||
show={show}
|
||||
as='div'
|
||||
className='absolute left-0 top-0 z-30 box-border h-full w-full p-2'
|
||||
className='absolute left-0 top-0 z-[70] box-border h-full w-full p-2'
|
||||
>
|
||||
<TransitionChild>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 4C0 1.79086 1.79086 0 4 0H12C14.2091 0 16 1.79086 16 4V12C16 14.2091 14.2091 16 12 16H4C1.79086 16 0 14.2091 0 12V4Z" fill="white" fill-opacity="0.12"/>
|
||||
<path d="M3.42756 8.7358V7.62784H10.8764C11.2003 7.62784 11.4957 7.5483 11.7628 7.3892C12.0298 7.23011 12.2415 7.01705 12.3977 6.75C12.5568 6.48295 12.6364 6.1875 12.6364 5.86364C12.6364 5.53977 12.5568 5.24574 12.3977 4.98153C12.2386 4.71449 12.0256 4.50142 11.7585 4.34233C11.4943 4.18324 11.2003 4.10369 10.8764 4.10369H10.3991V3H10.8764C11.4048 3 11.8849 3.12926 12.3168 3.38778C12.7486 3.64631 13.0938 3.99148 13.3523 4.4233C13.6108 4.85511 13.7401 5.33523 13.7401 5.86364C13.7401 6.25852 13.6648 6.62926 13.5142 6.97585C13.3665 7.32244 13.1619 7.62784 12.9006 7.89205C12.6392 8.15625 12.3352 8.36364 11.9886 8.5142C11.642 8.66193 11.2713 8.7358 10.8764 8.7358H3.42756ZM6.16761 12.0554L2.29403 8.18182L6.16761 4.30824L6.9304 5.07102L3.81534 8.18182L6.9304 11.2926L6.16761 12.0554Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" viewBox="0 0 14 12" fill="none">
|
||||
<path d="M12.3334 4C12.3334 2.52725 11.1395 1.33333 9.66671 1.33333H4.33337C2.86062 1.33333 1.66671 2.52724 1.66671 4V10.6667H9.66671C11.1395 10.6667 12.3334 9.47274 12.3334 8V4ZM7.66671 6.66667V8H4.33337V6.66667H7.66671ZM9.66671 4V5.33333H4.33337V4H9.66671ZM13.6667 8C13.6667 10.2091 11.8758 12 9.66671 12H0.333374V4C0.333374 1.79086 2.12424 0 4.33337 0H9.66671C11.8758 0 13.6667 1.79086 13.6667 4V8Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 527 B |
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "16",
|
||||
"viewBox": "0 0 16 16",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M0 4C0 1.79086 1.79086 0 4 0H12C14.2091 0 16 1.79086 16 4V12C16 14.2091 14.2091 16 12 16H4C1.79086 16 0 14.2091 0 12V4Z",
|
||||
"fill": "white",
|
||||
"fill-opacity": "0.12"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M3.42756 8.7358V7.62784H10.8764C11.2003 7.62784 11.4957 7.5483 11.7628 7.3892C12.0298 7.23011 12.2415 7.01705 12.3977 6.75C12.5568 6.48295 12.6364 6.1875 12.6364 5.86364C12.6364 5.53977 12.5568 5.24574 12.3977 4.98153C12.2386 4.71449 12.0256 4.50142 11.7585 4.34233C11.4943 4.18324 11.2003 4.10369 10.8764 4.10369H10.3991V3H10.8764C11.4048 3 11.8849 3.12926 12.3168 3.38778C12.7486 3.64631 13.0938 3.99148 13.3523 4.4233C13.6108 4.85511 13.7401 5.33523 13.7401 5.86364C13.7401 6.25852 13.6648 6.62926 13.5142 6.97585C13.3665 7.32244 13.1619 7.62784 12.9006 7.89205C12.6392 8.15625 12.3352 8.36364 11.9886 8.5142C11.642 8.66193 11.2713 8.7358 10.8764 8.7358H3.42756ZM6.16761 12.0554L2.29403 8.18182L6.16761 4.30824L6.9304 5.07102L3.81534 8.18182L6.9304 11.2926L6.16761 12.0554Z",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "EnterKey"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './EnterKey.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'EnterKey'
|
||||
|
||||
export default Icon
|
||||
@@ -1,6 +1,7 @@
|
||||
export { default as D } from './D'
|
||||
export { default as DiagonalDividingLine } from './DiagonalDividingLine'
|
||||
export { default as Dify } from './Dify'
|
||||
export { default as EnterKey } from './EnterKey'
|
||||
export { default as Gdpr } from './Gdpr'
|
||||
export { default as Github } from './Github'
|
||||
export { default as Highlight } from './Highlight'
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"xmlns": "http://www.w3.org/2000/svg",
|
||||
"width": "14",
|
||||
"height": "12",
|
||||
"viewBox": "0 0 14 12",
|
||||
"fill": "none"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M12.3334 4C12.3334 2.52725 11.1395 1.33333 9.66671 1.33333H4.33337C2.86062 1.33333 1.66671 2.52724 1.66671 4V10.6667H9.66671C11.1395 10.6667 12.3334 9.47274 12.3334 8V4ZM7.66671 6.66667V8H4.33337V6.66667H7.66671ZM9.66671 4V5.33333H4.33337V4H9.66671ZM13.6667 8C13.6667 10.2091 11.8758 12 9.66671 12H0.333374V4C0.333374 1.79086 2.12424 0 4.33337 0H9.66671C11.8758 0 13.6667 1.79086 13.6667 4V8Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Comment"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './Comment.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Comment'
|
||||
|
||||
export default Icon
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as Icon3Dots } from './Icon3Dots'
|
||||
export { default as Comment } from './Comment'
|
||||
export { default as DefaultToolIcon } from './DefaultToolIcon'
|
||||
export { default as Message3Fill } from './Message3Fill'
|
||||
export { default as RowStruct } from './RowStruct'
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import { InputNumber } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/InputNumber',
|
||||
component: InputNumber,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Number input component with increment/decrement buttons. Supports min/max constraints, custom step amounts, and units display.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'number',
|
||||
description: 'Current value',
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['regular', 'large'],
|
||||
description: 'Input size',
|
||||
},
|
||||
min: {
|
||||
control: 'number',
|
||||
description: 'Minimum value',
|
||||
},
|
||||
max: {
|
||||
control: 'number',
|
||||
description: 'Maximum value',
|
||||
},
|
||||
amount: {
|
||||
control: 'number',
|
||||
description: 'Step amount for increment/decrement',
|
||||
},
|
||||
unit: {
|
||||
control: 'text',
|
||||
description: 'Unit text displayed (e.g., "px", "ms")',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
defaultValue: {
|
||||
control: 'number',
|
||||
description: 'Default value when undefined',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof InputNumber>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const InputNumberDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value ?? 0)
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<InputNumber
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue)
|
||||
console.log('Value changed:', newValue)
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Current value: <span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 0,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Large size
|
||||
export const LargeSize: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 10,
|
||||
size: 'large',
|
||||
},
|
||||
}
|
||||
|
||||
// With min/max constraints
|
||||
export const WithMinMax: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 5,
|
||||
min: 0,
|
||||
max: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// With custom step amount
|
||||
export const CustomStepAmount: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
amount: 5,
|
||||
min: 0,
|
||||
max: 100,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// With unit
|
||||
export const WithUnit: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 100,
|
||||
unit: 'px',
|
||||
min: 0,
|
||||
max: 1000,
|
||||
amount: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 42,
|
||||
disabled: true,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Decimal values
|
||||
export const DecimalValues: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 2.5,
|
||||
amount: 0.5,
|
||||
min: 0,
|
||||
max: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Negative values allowed
|
||||
export const NegativeValues: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
amount: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Size comparison
|
||||
const SizeComparisonDemo = () => {
|
||||
const [regularValue, setRegularValue] = useState(10)
|
||||
const [largeValue, setLargeValue] = useState(20)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" style={{ width: '300px' }}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Regular Size</label>
|
||||
<InputNumber
|
||||
size="regular"
|
||||
value={regularValue}
|
||||
onChange={setRegularValue}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Large Size</label>
|
||||
<InputNumber
|
||||
size="large"
|
||||
value={largeValue}
|
||||
onChange={setLargeValue}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => <SizeComparisonDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Font size picker
|
||||
const FontSizePickerDemo = () => {
|
||||
const [fontSize, setFontSize] = useState(16)
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Font Size</label>
|
||||
<InputNumber
|
||||
value={fontSize}
|
||||
onChange={setFontSize}
|
||||
min={8}
|
||||
max={72}
|
||||
amount={2}
|
||||
unit="px"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<p style={{ fontSize: `${fontSize}px` }} className="text-gray-900">
|
||||
Preview Text
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FontSizePicker: Story = {
|
||||
render: () => <FontSizePickerDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Quantity selector
|
||||
const QuantitySelectorDemo = () => {
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const pricePerItem = 29.99
|
||||
const total = (quantity * pricePerItem).toFixed(2)
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Product Name</h3>
|
||||
<p className="text-sm text-gray-500">${pricePerItem} each</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Quantity</label>
|
||||
<InputNumber
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
min={1}
|
||||
max={99}
|
||||
amount={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Total</span>
|
||||
<span className="text-lg font-semibold text-gray-900">${total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const QuantitySelector: Story = {
|
||||
render: () => <QuantitySelectorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Timer settings
|
||||
const TimerSettingsDemo = () => {
|
||||
const [hours, setHours] = useState(0)
|
||||
const [minutes, setMinutes] = useState(15)
|
||||
const [seconds, setSeconds] = useState(30)
|
||||
|
||||
const totalSeconds = hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Timer Configuration</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Hours</label>
|
||||
<InputNumber
|
||||
value={hours}
|
||||
onChange={setHours}
|
||||
min={0}
|
||||
max={23}
|
||||
unit="h"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Minutes</label>
|
||||
<InputNumber
|
||||
value={minutes}
|
||||
onChange={setMinutes}
|
||||
min={0}
|
||||
max={59}
|
||||
unit="m"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Seconds</label>
|
||||
<InputNumber
|
||||
value={seconds}
|
||||
onChange={setSeconds}
|
||||
min={0}
|
||||
max={59}
|
||||
unit="s"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-blue-50 p-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total duration: <span className="font-semibold">{totalSeconds} seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TimerSettings: Story = {
|
||||
render: () => <TimerSettingsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Animation settings
|
||||
const AnimationSettingsDemo = () => {
|
||||
const [duration, setDuration] = useState(300)
|
||||
const [delay, setDelay] = useState(0)
|
||||
const [iterations, setIterations] = useState(1)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Animation Properties</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Duration</label>
|
||||
<InputNumber
|
||||
value={duration}
|
||||
onChange={setDuration}
|
||||
min={0}
|
||||
max={5000}
|
||||
amount={50}
|
||||
unit="ms"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Delay</label>
|
||||
<InputNumber
|
||||
value={delay}
|
||||
onChange={setDelay}
|
||||
min={0}
|
||||
max={2000}
|
||||
amount={50}
|
||||
unit="ms"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Iterations</label>
|
||||
<InputNumber
|
||||
value={iterations}
|
||||
onChange={setIterations}
|
||||
min={1}
|
||||
max={10}
|
||||
amount={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-gray-50 p-4">
|
||||
<div className="font-mono text-xs text-gray-700">
|
||||
animation: {duration}ms {delay}ms {iterations}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AnimationSettings: Story = {
|
||||
render: () => <AnimationSettingsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Temperature control
|
||||
const TemperatureControlDemo = () => {
|
||||
const [temperature, setTemperature] = useState(20)
|
||||
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Temperature Control</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Set Temperature</label>
|
||||
<InputNumber
|
||||
size="large"
|
||||
value={temperature}
|
||||
onChange={setTemperature}
|
||||
min={16}
|
||||
max={30}
|
||||
amount={0.5}
|
||||
unit="°C"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-lg bg-gray-50 p-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Celsius</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">{temperature}°C</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Fahrenheit</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">{fahrenheit}°F</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TemperatureControl: Story = {
|
||||
render: () => <TemperatureControlDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 10,
|
||||
size: 'regular',
|
||||
min: 0,
|
||||
max: 100,
|
||||
amount: 1,
|
||||
unit: '',
|
||||
disabled: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Input from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input',
|
||||
component: Input,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Input component with support for icons, clear button, validation states, and units. Includes automatic leading zero removal for number inputs.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['regular', 'large'],
|
||||
description: 'Input size',
|
||||
},
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['text', 'number', 'email', 'password', 'url', 'tel'],
|
||||
description: 'Input type',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
destructive: {
|
||||
control: 'boolean',
|
||||
description: 'Error/destructive state',
|
||||
},
|
||||
showLeftIcon: {
|
||||
control: 'boolean',
|
||||
description: 'Show search icon on left',
|
||||
},
|
||||
showClearIcon: {
|
||||
control: 'boolean',
|
||||
description: 'Show clear button when input has value',
|
||||
},
|
||||
unit: {
|
||||
control: 'text',
|
||||
description: 'Unit text displayed on right (e.g., "px", "ms")',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Input>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const InputDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Input
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
console.log('Input changed:', e.target.value)
|
||||
}}
|
||||
onClear={() => {
|
||||
setValue('')
|
||||
console.log('Input cleared')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
placeholder: 'Enter text...',
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
// Large size
|
||||
export const LargeSize: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'large',
|
||||
placeholder: 'Enter text...',
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
// With search icon
|
||||
export const WithSearchIcon: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
showLeftIcon: true,
|
||||
placeholder: 'Search...',
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
// With clear button
|
||||
export const WithClearButton: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
showClearIcon: true,
|
||||
value: 'Some text to clear',
|
||||
placeholder: 'Type something...',
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
// Search input (icon + clear)
|
||||
export const SearchInput: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
showLeftIcon: true,
|
||||
showClearIcon: true,
|
||||
value: '',
|
||||
placeholder: 'Search...',
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'Disabled input',
|
||||
disabled: true,
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
// Destructive/error state
|
||||
export const DestructiveState: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'invalid@email',
|
||||
destructive: true,
|
||||
placeholder: 'Enter email...',
|
||||
type: 'email',
|
||||
},
|
||||
}
|
||||
|
||||
// Number input
|
||||
export const NumberInput: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
type: 'number',
|
||||
placeholder: 'Enter a number...',
|
||||
value: '0',
|
||||
},
|
||||
}
|
||||
|
||||
// With unit
|
||||
export const WithUnit: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
type: 'number',
|
||||
value: '100',
|
||||
unit: 'px',
|
||||
placeholder: 'Enter value...',
|
||||
},
|
||||
}
|
||||
|
||||
// Email input
|
||||
export const EmailInput: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
type: 'email',
|
||||
placeholder: 'Enter your email...',
|
||||
showClearIcon: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Password input
|
||||
export const PasswordInput: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
type: 'password',
|
||||
placeholder: 'Enter password...',
|
||||
value: 'secret123',
|
||||
},
|
||||
}
|
||||
|
||||
// Size comparison
|
||||
const SizeComparisonDemo = () => {
|
||||
const [regularValue, setRegularValue] = useState('')
|
||||
const [largeValue, setLargeValue] = useState('')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" style={{ width: '400px' }}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Regular Size</label>
|
||||
<Input
|
||||
size="regular"
|
||||
value={regularValue}
|
||||
onChange={e => setRegularValue(e.target.value)}
|
||||
placeholder="Regular input..."
|
||||
showClearIcon
|
||||
onClear={() => setRegularValue('')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Large Size</label>
|
||||
<Input
|
||||
size="large"
|
||||
value={largeValue}
|
||||
onChange={e => setLargeValue(e.target.value)}
|
||||
placeholder="Large input..."
|
||||
showClearIcon
|
||||
onClear={() => setLargeValue('')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => <SizeComparisonDemo />,
|
||||
}
|
||||
|
||||
// State comparison
|
||||
const StateComparisonDemo = () => {
|
||||
const [normalValue, setNormalValue] = useState('Normal state')
|
||||
const [errorValue, setErrorValue] = useState('Error state')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" style={{ width: '400px' }}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Normal</label>
|
||||
<Input
|
||||
value={normalValue}
|
||||
onChange={e => setNormalValue(e.target.value)}
|
||||
showClearIcon
|
||||
onClear={() => setNormalValue('')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Destructive</label>
|
||||
<Input
|
||||
value={errorValue}
|
||||
onChange={e => setErrorValue(e.target.value)}
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Disabled</label>
|
||||
<Input
|
||||
value="Disabled input"
|
||||
onChange={() => undefined}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StateComparison: Story = {
|
||||
render: () => <StateComparisonDemo />,
|
||||
}
|
||||
|
||||
// Form example
|
||||
const FormExampleDemo = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
age: '',
|
||||
website: '',
|
||||
})
|
||||
const [errors, setErrors] = useState({
|
||||
email: false,
|
||||
age: false,
|
||||
})
|
||||
|
||||
const validateEmail = (email: string) => {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">User Profile</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Name</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Enter your name..."
|
||||
showClearIcon
|
||||
onClear={() => setFormData({ ...formData, name: '' })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
setErrors({ ...errors, email: e.target.value ? !validateEmail(e.target.value) : false })
|
||||
}}
|
||||
placeholder="Enter your email..."
|
||||
destructive={errors.email}
|
||||
showClearIcon
|
||||
onClear={() => {
|
||||
setFormData({ ...formData, email: '' })
|
||||
setErrors({ ...errors, email: false })
|
||||
}}
|
||||
/>
|
||||
{errors.email && (
|
||||
<span className="text-xs text-red-600">Please enter a valid email address</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Age</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.age}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, age: e.target.value })
|
||||
setErrors({ ...errors, age: e.target.value ? Number(e.target.value) < 18 : false })
|
||||
}}
|
||||
placeholder="Enter your age..."
|
||||
destructive={errors.age}
|
||||
unit="years"
|
||||
/>
|
||||
{errors.age && (
|
||||
<span className="text-xs text-red-600">Must be 18 or older</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Website</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={e => setFormData({ ...formData, website: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
showClearIcon
|
||||
onClear={() => setFormData({ ...formData, website: '' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormExample: Story = {
|
||||
render: () => <FormExampleDemo />,
|
||||
}
|
||||
|
||||
// Search example
|
||||
const SearchExampleDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const items = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape']
|
||||
const filteredItems = items.filter(item =>
|
||||
item.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="flex flex-col gap-4">
|
||||
<Input
|
||||
size="large"
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onClear={() => setSearchQuery('')}
|
||||
placeholder="Search fruits..."
|
||||
/>
|
||||
{searchQuery && (
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-xs text-gray-500">
|
||||
{filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{filteredItems.map(item => (
|
||||
<div key={item} className="text-sm text-gray-700">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SearchExample: Story = {
|
||||
render: () => <SearchExampleDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
type: 'text',
|
||||
placeholder: 'Type something...',
|
||||
disabled: false,
|
||||
destructive: false,
|
||||
showLeftIcon: false,
|
||||
showClearIcon: true,
|
||||
unit: '',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
|
||||
// Mock component to avoid complex initialization issues
|
||||
const PromptEditorMock = ({ value, onChange, placeholder, editable, compact, className, wrapperClassName }: any) => {
|
||||
const [content, setContent] = useState(value || '')
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
onChange?.(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<textarea
|
||||
className={`w-full resize-none outline-none ${compact ? 'text-[13px] leading-5' : 'text-sm leading-6'} ${className}`}
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
disabled={!editable}
|
||||
style={{ minHeight: '120px' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/PromptEditor',
|
||||
component: PromptEditorMock,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Rich text prompt editor built on Lexical. Supports variable blocks, context blocks, and slash commands for inserting dynamic content. Use `/` or `{` to trigger component picker.\n\n**Note:** This is a simplified version for Storybook. The actual component uses Lexical editor with advanced features.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Editor content',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
editable: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the editor is editable',
|
||||
},
|
||||
compact: {
|
||||
control: 'boolean',
|
||||
description: 'Compact mode with smaller text',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'CSS class for editor content',
|
||||
},
|
||||
wrapperClassName: {
|
||||
control: 'text',
|
||||
description: 'CSS class for editor wrapper',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof PromptEditorMock>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const PromptEditorDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }}>
|
||||
<div className="min-h-[120px] rounded-lg border border-gray-300 p-4">
|
||||
<PromptEditorMock
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(text: string) => {
|
||||
setValue(text)
|
||||
console.log('Content changed:', text)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{value && (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">Current Value:</div>
|
||||
<div className="whitespace-pre-wrap font-mono text-sm text-gray-800">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type / for commands...',
|
||||
editable: true,
|
||||
compact: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With initial value
|
||||
export const WithInitialValue: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
value: 'Write a summary about the following topic:\n\nPlease include key points and examples.',
|
||||
placeholder: 'Type / for commands...',
|
||||
editable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Compact mode
|
||||
export const CompactMode: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
value: 'This is a compact editor with smaller text size.',
|
||||
placeholder: 'Type / for commands...',
|
||||
editable: true,
|
||||
compact: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Read-only mode
|
||||
export const ReadOnlyMode: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
value: 'This content is read-only and cannot be edited.\n\nYou can select and copy text, but not modify it.',
|
||||
editable: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With variables example
|
||||
export const WithVariablesExample: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
value: 'Hello, please analyze the following data and provide insights.',
|
||||
placeholder: 'Type / to insert variables...',
|
||||
editable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Long content example
|
||||
export const LongContent: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
value: `You are a helpful AI assistant. Your task is to provide accurate, helpful, and friendly responses.
|
||||
|
||||
Guidelines:
|
||||
1. Be clear and concise
|
||||
2. Provide examples when helpful
|
||||
3. Ask clarifying questions if needed
|
||||
4. Maintain a professional yet friendly tone
|
||||
|
||||
Please analyze the user's request and provide a comprehensive response.`,
|
||||
placeholder: 'Enter your prompt...',
|
||||
editable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Custom placeholder
|
||||
export const CustomPlaceholder: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Describe the task you want the AI to perform... (Press / for variables)',
|
||||
editable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Multiple editors
|
||||
const MultipleEditorsDemo = () => {
|
||||
const [systemPrompt, setSystemPrompt] = useState('You are a helpful assistant.')
|
||||
const [userPrompt, setUserPrompt] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">System Prompt</label>
|
||||
<div className="min-h-[100px] rounded-lg border border-gray-300 bg-blue-50 p-4">
|
||||
<PromptEditorMock
|
||||
value={systemPrompt}
|
||||
onChange={setSystemPrompt}
|
||||
placeholder="Enter system instructions..."
|
||||
editable={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">User Prompt</label>
|
||||
<div className="min-h-[100px] rounded-lg border border-gray-300 p-4">
|
||||
<PromptEditorMock
|
||||
value={userPrompt}
|
||||
onChange={setUserPrompt}
|
||||
placeholder="Enter user message template..."
|
||||
editable={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(systemPrompt || userPrompt) && (
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">Combined Output:</div>
|
||||
<div className="whitespace-pre-wrap text-sm text-gray-800">
|
||||
{systemPrompt && (
|
||||
<>
|
||||
<strong>System:</strong> {systemPrompt}
|
||||
{userPrompt && '\n\n'}
|
||||
</>
|
||||
)}
|
||||
{userPrompt && (
|
||||
<>
|
||||
<strong>User:</strong> {userPrompt}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MultipleEditors: Story = {
|
||||
render: () => <MultipleEditorsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Email template
|
||||
const EmailTemplateDemo = () => {
|
||||
const [subject, setSubject] = useState('Welcome to our platform!')
|
||||
const [body, setBody] = useState(`Hi,
|
||||
|
||||
Thank you for signing up! We're excited to have you on board.
|
||||
|
||||
To get started, please verify your email address by clicking the button below.
|
||||
|
||||
Best regards,
|
||||
The Team`)
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Email Template Editor</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Subject Line</label>
|
||||
<div className="rounded-lg border border-gray-300 p-3">
|
||||
<PromptEditorMock
|
||||
value={subject}
|
||||
onChange={setSubject}
|
||||
placeholder="Enter email subject..."
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Email Body</label>
|
||||
<div className="min-h-[200px] rounded-lg border border-gray-300 p-4">
|
||||
<PromptEditorMock
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="Type your email content... Use / to insert variables"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const EmailTemplate: Story = {
|
||||
render: () => <EmailTemplateDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Chat prompt builder
|
||||
const ChatPromptBuilderDemo = () => {
|
||||
const [prompt, setPrompt] = useState(`Analyze the following conversation and provide insights:
|
||||
|
||||
1. Identify the main topics discussed
|
||||
2. Detect the sentiment and tone
|
||||
3. Summarize key points
|
||||
4. Suggest follow-up questions`)
|
||||
|
||||
const [characterCount, setCharacterCount] = useState(prompt.length)
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
setPrompt(text)
|
||||
setCharacterCount(text.length)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Chat Prompt Builder</h3>
|
||||
<span className="text-xs text-gray-500">{characterCount} characters</span>
|
||||
</div>
|
||||
<div className="min-h-[200px] rounded-lg border border-gray-300 bg-gray-50 p-4">
|
||||
<PromptEditorMock
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
placeholder="Design your chat prompt... Use / for templates"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-blue-50 p-3 text-sm text-blue-800">
|
||||
💡 <strong>Tip:</strong> Type <code className="rounded bg-blue-100 px-1 py-0.5">/</code> to insert variables or templates
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ChatPromptBuilder: Story = {
|
||||
render: () => <ChatPromptBuilderDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - API instruction editor
|
||||
const APIInstructionEditorDemo = () => {
|
||||
const [instructions, setInstructions] = useState(`Process the incoming API request and:
|
||||
|
||||
1. Validate all required fields are present
|
||||
2. Transform the data according to the schema
|
||||
3. Apply business logic rules
|
||||
4. Return the formatted response`)
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">API Processing Instructions</h3>
|
||||
<div className="min-h-[180px] rounded-lg border-2 border-indigo-300 bg-indigo-50 p-4">
|
||||
<PromptEditorMock
|
||||
value={instructions}
|
||||
onChange={setInstructions}
|
||||
placeholder="Enter processing instructions..."
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">
|
||||
Save Instructions
|
||||
</button>
|
||||
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const APIInstructionEditor: Story = {
|
||||
render: () => <APIInstructionEditorDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
value: '',
|
||||
placeholder: 'Type / for commands...',
|
||||
editable: true,
|
||||
compact: false,
|
||||
},
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import type {
|
||||
EditorState,
|
||||
} from 'lexical'
|
||||
@@ -80,6 +81,29 @@ import {
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const ValueSyncPlugin: FC<{ value?: string }> = ({ value }) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (value === undefined)
|
||||
return
|
||||
|
||||
const incomingValue = value ?? ''
|
||||
const shouldUpdate = editor.getEditorState().read(() => {
|
||||
const currentText = $getRoot().getChildren().map(node => node.getTextContent()).join('\n')
|
||||
return currentText !== incomingValue
|
||||
})
|
||||
|
||||
if (!shouldUpdate)
|
||||
return
|
||||
|
||||
const editorState = editor.parseEditorState(textToEditorState(incomingValue))
|
||||
editor.setEditorState(editorState)
|
||||
}, [editor, value])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export type PromptEditorProps = {
|
||||
instanceId?: string
|
||||
compact?: boolean
|
||||
@@ -293,6 +317,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
<VariableValueBlock />
|
||||
)
|
||||
}
|
||||
<ValueSyncPlugin value={value} />
|
||||
<OnChangePlugin onChange={handleEditorChange} />
|
||||
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
|
||||
<UpdateBlock instanceId={instanceId} />
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import { RiCloudLine, RiCpuLine, RiDatabase2Line, RiLightbulbLine, RiRocketLine, RiShieldLine } from '@remixicon/react'
|
||||
import RadioCard from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/RadioCard',
|
||||
component: RadioCard,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Radio card component for selecting options with rich content. Features icon, title, description, and optional configuration panel when selected.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
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>
|
||||
|
||||
// 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="h-5 w-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="h-5 w-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="h-5 w-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="h-5 w-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 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 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 />,
|
||||
}
|
||||
|
||||
// Multiple cards selection
|
||||
const MultipleCardsDemo = () => {
|
||||
const [selected, setSelected] = useState('standard')
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'standard',
|
||||
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
|
||||
iconBg: 'bg-purple-100',
|
||||
title: 'Standard',
|
||||
description: 'Perfect for most use cases',
|
||||
},
|
||||
{
|
||||
value: 'advanced',
|
||||
icon: <RiCpuLine className="h-5 w-5 text-blue-600" />,
|
||||
iconBg: 'bg-blue-100',
|
||||
title: 'Advanced',
|
||||
description: 'More features and customization',
|
||||
},
|
||||
{
|
||||
value: 'enterprise',
|
||||
icon: <RiShieldLine className="h-5 w-5 text-green-600" />,
|
||||
iconBg: 'bg-green-100',
|
||||
title: 'Enterprise',
|
||||
description: 'Full features with premium support',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="space-y-3">
|
||||
{options.map(option => (
|
||||
<RadioCard
|
||||
key={option.value}
|
||||
icon={option.icon}
|
||||
iconBgClassName={option.iconBg}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
isChosen={selected === option.value}
|
||||
onChosen={() => setSelected(option.value)}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MultipleCards: Story = {
|
||||
render: () => <MultipleCardsDemo />,
|
||||
}
|
||||
|
||||
// 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="h-5 w-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="h-5 w-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="h-5 w-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 CloudProviderSelection: Story = {
|
||||
render: () => <CloudProviderSelectionDemo />,
|
||||
}
|
||||
|
||||
// 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="h-5 w-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="h-5 w-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="h-5 w-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 />,
|
||||
}
|
||||
|
||||
// Real-world example - Storage options
|
||||
const StorageOptionsDemo = () => {
|
||||
const [storage, setStorage] = useState('ssd')
|
||||
|
||||
const storageOptions = [
|
||||
{
|
||||
value: 'ssd',
|
||||
icon: <RiDatabase2Line className="h-5 w-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="h-5 w-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="h-5 w-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 />,
|
||||
}
|
||||
|
||||
// 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="h-5 w-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="h-5 w-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="h-5 w-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 />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
const PlaygroundDemo = () => {
|
||||
const [selected, setSelected] = useState('option1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="space-y-3">
|
||||
<RadioCard
|
||||
icon={<RiRocketLine className="h-5 w-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="h-5 w-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="rounded bg-blue-50 p-2 text-xs text-gray-600">
|
||||
Additional configuration appears when selected
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="h-5 w-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 />,
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Radio from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/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 />,
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import SearchInput from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/SearchInput',
|
||||
component: SearchInput,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Search input component with search icon, clear button, and IME composition support for Asian languages.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Search input value',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
white: {
|
||||
control: 'boolean',
|
||||
description: 'White background variant',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof SearchInput>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const SearchInputDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<SearchInput
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v)
|
||||
console.log('Search value changed:', v)
|
||||
}}
|
||||
/>
|
||||
{value && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Searching for: <span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search...',
|
||||
white: false,
|
||||
},
|
||||
}
|
||||
|
||||
// White variant
|
||||
export const WhiteBackground: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search...',
|
||||
white: true,
|
||||
},
|
||||
}
|
||||
|
||||
// With initial value
|
||||
export const WithInitialValue: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Initial search query',
|
||||
placeholder: 'Search...',
|
||||
white: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Custom placeholder
|
||||
export const CustomPlaceholder: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search documents, files, and more...',
|
||||
white: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - User list search
|
||||
const UserListSearchDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const users = [
|
||||
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
|
||||
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'User' },
|
||||
{ id: 3, name: 'Charlie Brown', email: 'charlie@example.com', role: 'User' },
|
||||
{ id: 4, name: 'Diana Prince', email: 'diana@example.com', role: 'Editor' },
|
||||
{ id: 5, name: 'Eve Davis', email: 'eve@example.com', role: 'User' },
|
||||
]
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|| user.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|| user.role.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Team Members</h3>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search by name, email, or role..."
|
||||
/>
|
||||
<div className="mt-4 space-y-2">
|
||||
{filteredUsers.length > 0 ? (
|
||||
filteredUsers.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{user.name}</div>
|
||||
<div className="text-xs text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700">
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
No users found matching "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-gray-500">
|
||||
Showing {filteredUsers.length} of {users.length} members
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const UserListSearch: Story = {
|
||||
render: () => <UserListSearchDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Product search
|
||||
const ProductSearchDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const products = [
|
||||
{ id: 1, name: 'Laptop Pro 15"', category: 'Electronics', price: 1299 },
|
||||
{ id: 2, name: 'Wireless Mouse', category: 'Accessories', price: 29 },
|
||||
{ id: 3, name: 'Mechanical Keyboard', category: 'Accessories', price: 89 },
|
||||
{ id: 4, name: 'Monitor 27" 4K', category: 'Electronics', price: 499 },
|
||||
{ id: 5, name: 'USB-C Hub', category: 'Accessories', price: 49 },
|
||||
{ id: 6, name: 'Laptop Stand', category: 'Accessories', price: 39 },
|
||||
]
|
||||
|
||||
const filteredProducts = products.filter(product =>
|
||||
product.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|| product.category.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Product Catalog</h3>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search products..."
|
||||
white
|
||||
/>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
{filteredProducts.length > 0 ? (
|
||||
filteredProducts.map(product => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="rounded-lg border border-gray-200 p-4 transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="mb-1 text-sm font-medium">{product.name}</div>
|
||||
<div className="mb-2 text-xs text-gray-500">{product.category}</div>
|
||||
<div className="text-lg font-semibold text-blue-600">${product.price}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-2 py-8 text-center text-sm text-gray-500">
|
||||
No products found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductSearch: Story = {
|
||||
render: () => <ProductSearchDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Documentation search
|
||||
const DocumentationSearchDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const docs = [
|
||||
{ id: 1, title: 'Getting Started', category: 'Introduction', excerpt: 'Learn the basics of our platform' },
|
||||
{ id: 2, title: 'API Reference', category: 'Developers', excerpt: 'Complete API documentation and examples' },
|
||||
{ id: 3, title: 'Authentication Guide', category: 'Security', excerpt: 'Set up OAuth and API key authentication' },
|
||||
{ id: 4, title: 'Best Practices', category: 'Guides', excerpt: 'Tips for optimal performance and security' },
|
||||
{ id: 5, title: 'Troubleshooting', category: 'Support', excerpt: 'Common issues and their solutions' },
|
||||
]
|
||||
|
||||
const filteredDocs = docs.filter(doc =>
|
||||
doc.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|| doc.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|| doc.excerpt.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg bg-gray-50 p-6">
|
||||
<h3 className="mb-2 text-xl font-bold">Documentation</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Search our comprehensive guides and API references</p>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search documentation..."
|
||||
white
|
||||
className="!h-10"
|
||||
/>
|
||||
<div className="mt-4 space-y-3">
|
||||
{filteredDocs.length > 0 ? (
|
||||
filteredDocs.map(doc => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="cursor-pointer rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-300"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<h4 className="text-base font-semibold">{doc.title}</h4>
|
||||
<span className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600">
|
||||
{doc.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{doc.excerpt}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mb-2 text-4xl">🔍</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
No documentation found for "{searchQuery}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DocumentationSearch: Story = {
|
||||
render: () => <DocumentationSearchDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Command palette
|
||||
const CommandPaletteDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const commands = [
|
||||
{ id: 1, name: 'Create new document', icon: '📄', shortcut: '⌘N' },
|
||||
{ id: 2, name: 'Open settings', icon: '⚙️', shortcut: '⌘,' },
|
||||
{ id: 3, name: 'Search everywhere', icon: '🔍', shortcut: '⌘K' },
|
||||
{ id: 4, name: 'Toggle sidebar', icon: '📁', shortcut: '⌘B' },
|
||||
{ id: 5, name: 'Save changes', icon: '💾', shortcut: '⌘S' },
|
||||
{ id: 6, name: 'Undo last action', icon: '↩️', shortcut: '⌘Z' },
|
||||
{ id: 7, name: 'Redo last action', icon: '↪️', shortcut: '⌘⇧Z' },
|
||||
]
|
||||
|
||||
const filteredCommands = commands.filter(cmd =>
|
||||
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="overflow-hidden rounded-lg border border-gray-300 bg-white shadow-lg">
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Type a command or search..."
|
||||
white
|
||||
className="!h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{filteredCommands.length > 0 ? (
|
||||
filteredCommands.map(cmd => (
|
||||
<div
|
||||
key={cmd.id}
|
||||
className="flex cursor-pointer items-center justify-between border-b border-gray-100 px-4 py-3 last:border-b-0 hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{cmd.icon}</span>
|
||||
<span className="text-sm">{cmd.name}</span>
|
||||
</div>
|
||||
<kbd className="rounded bg-gray-200 px-2 py-1 font-mono text-xs">
|
||||
{cmd.shortcut}
|
||||
</kbd>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
No commands found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CommandPalette: Story = {
|
||||
render: () => <CommandPaletteDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Live search with results count
|
||||
const LiveSearchWithCountDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const items = [
|
||||
'React Documentation',
|
||||
'React Hooks',
|
||||
'React Router',
|
||||
'Redux Toolkit',
|
||||
'TypeScript Guide',
|
||||
'JavaScript Basics',
|
||||
'CSS Grid Layout',
|
||||
'Flexbox Tutorial',
|
||||
'Node.js Express',
|
||||
'MongoDB Guide',
|
||||
]
|
||||
|
||||
const filteredItems = items.filter(item =>
|
||||
item.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Learning Resources</h3>
|
||||
{searchQuery && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search resources..."
|
||||
/>
|
||||
<div className="mt-4 space-y-2">
|
||||
{filteredItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="cursor-pointer rounded-lg border border-gray-200 p-3 transition-colors hover:border-blue-300 hover:bg-blue-50"
|
||||
>
|
||||
<div className="text-sm font-medium">{item}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const LiveSearchWithCount: Story = {
|
||||
render: () => <LiveSearchWithCountDemo />,
|
||||
}
|
||||
|
||||
// Size variations
|
||||
const SizeVariationsDemo = () => {
|
||||
const [value1, setValue1] = useState('')
|
||||
const [value2, setValue2] = useState('')
|
||||
const [value3, setValue3] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Default Size</label>
|
||||
<SearchInput value={value1} onChange={setValue1} placeholder="Search..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Medium Size</label>
|
||||
<SearchInput
|
||||
value={value2}
|
||||
onChange={setValue2}
|
||||
placeholder="Search..."
|
||||
className="!h-10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Large Size</label>
|
||||
<SearchInput
|
||||
value={value3}
|
||||
onChange={setValue3}
|
||||
placeholder="Search..."
|
||||
className="!h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeVariations: Story = {
|
||||
render: () => <SizeVariationsDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
value: '',
|
||||
placeholder: 'Search...',
|
||||
white: false,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Select, { PortalSelect, SimpleSelect } from '.'
|
||||
import type { Item } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Select',
|
||||
component: SimpleSelect,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Select component with three variants: Select (with search), SimpleSelect (basic dropdown), and PortalSelect (portal-based positioning). Built on Headless UI.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
notClearable: {
|
||||
control: 'boolean',
|
||||
description: 'Hide clear button',
|
||||
},
|
||||
hideChecked: {
|
||||
control: 'boolean',
|
||||
description: 'Hide check icon on selected item',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof SimpleSelect>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const fruits: Item[] = [
|
||||
{ value: 'apple', name: 'Apple' },
|
||||
{ value: 'banana', name: 'Banana' },
|
||||
{ value: 'cherry', name: 'Cherry' },
|
||||
{ value: 'date', name: 'Date' },
|
||||
{ value: 'elderberry', name: 'Elderberry' },
|
||||
]
|
||||
|
||||
const countries: Item[] = [
|
||||
{ value: 'us', name: 'United States' },
|
||||
{ value: 'uk', name: 'United Kingdom' },
|
||||
{ value: 'ca', name: 'Canada' },
|
||||
{ value: 'au', name: 'Australia' },
|
||||
{ value: 'de', name: 'Germany' },
|
||||
{ value: 'fr', name: 'France' },
|
||||
{ value: 'jp', name: 'Japan' },
|
||||
{ value: 'cn', name: 'China' },
|
||||
]
|
||||
|
||||
// SimpleSelect Demo
|
||||
const SimpleSelectDemo = (args: any) => {
|
||||
const [selected, setSelected] = useState(args.defaultValue || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
{...args}
|
||||
items={fruits}
|
||||
defaultValue={selected}
|
||||
onSelect={(item) => {
|
||||
setSelected(item.value)
|
||||
console.log('Selected:', item)
|
||||
}}
|
||||
/>
|
||||
{selected && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default SimpleSelect
|
||||
export const Default: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'apple',
|
||||
},
|
||||
}
|
||||
|
||||
// With placeholder (no selection)
|
||||
export const WithPlaceholder: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Choose an option...',
|
||||
defaultValue: '',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'banana',
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Not clearable
|
||||
export const NotClearable: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'cherry',
|
||||
notClearable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Hide checked icon
|
||||
export const HideChecked: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'apple',
|
||||
hideChecked: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Select with search
|
||||
const WithSearchDemo = () => {
|
||||
const [selected, setSelected] = useState('us')
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<Select
|
||||
items={countries}
|
||||
defaultValue={selected}
|
||||
onSelect={(item) => {
|
||||
setSelected(item.value as string)
|
||||
console.log('Selected:', item)
|
||||
}}
|
||||
allowSearch={true}
|
||||
/>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithSearch: Story = {
|
||||
render: () => <WithSearchDemo />,
|
||||
}
|
||||
|
||||
// PortalSelect
|
||||
const PortalSelectVariantDemo = () => {
|
||||
const [selected, setSelected] = useState('apple')
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<PortalSelect
|
||||
value={selected}
|
||||
items={fruits}
|
||||
onSelect={(item) => {
|
||||
setSelected(item.value as string)
|
||||
console.log('Selected:', item)
|
||||
}}
|
||||
placeholder="Select a fruit..."
|
||||
/>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PortalSelectVariant: Story = {
|
||||
render: () => <PortalSelectVariantDemo />,
|
||||
}
|
||||
|
||||
// Custom render option
|
||||
const CustomRenderOptionDemo = () => {
|
||||
const [selected, setSelected] = useState('us')
|
||||
|
||||
const countriesWithFlags = [
|
||||
{ value: 'us', name: 'United States', flag: '🇺🇸' },
|
||||
{ value: 'uk', name: 'United Kingdom', flag: '🇬🇧' },
|
||||
{ value: 'ca', name: 'Canada', flag: '🇨🇦' },
|
||||
{ value: 'au', name: 'Australia', flag: '🇦🇺' },
|
||||
{ value: 'de', name: 'Germany', flag: '🇩🇪' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
items={countriesWithFlags}
|
||||
defaultValue={selected}
|
||||
onSelect={item => setSelected(item.value as string)}
|
||||
renderOption={({ item, selected }) => (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{item.flag}</span>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{selected && <span className="text-blue-600">✓</span>}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CustomRenderOption: Story = {
|
||||
render: () => <CustomRenderOptionDemo />,
|
||||
}
|
||||
|
||||
// Loading state
|
||||
export const LoadingState: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
items={[]}
|
||||
defaultValue=""
|
||||
onSelect={() => undefined}
|
||||
placeholder="Loading options..."
|
||||
isLoading={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Form field
|
||||
const FormFieldDemo = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
country: 'us',
|
||||
language: 'en',
|
||||
timezone: 'pst',
|
||||
})
|
||||
|
||||
const languages = [
|
||||
{ value: 'en', name: 'English' },
|
||||
{ value: 'es', name: 'Spanish' },
|
||||
{ value: 'fr', name: 'French' },
|
||||
{ value: 'de', name: 'German' },
|
||||
{ value: 'zh', name: 'Chinese' },
|
||||
]
|
||||
|
||||
const timezones = [
|
||||
{ value: 'pst', name: 'Pacific Time (PST)' },
|
||||
{ value: 'mst', name: 'Mountain Time (MST)' },
|
||||
{ value: 'cst', name: 'Central Time (CST)' },
|
||||
{ value: 'est', name: 'Eastern Time (EST)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">User Preferences</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Country</label>
|
||||
<SimpleSelect
|
||||
items={countries}
|
||||
defaultValue={formData.country}
|
||||
onSelect={item => setFormData({ ...formData, country: item.value as string })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Language</label>
|
||||
<SimpleSelect
|
||||
items={languages}
|
||||
defaultValue={formData.language}
|
||||
onSelect={item => setFormData({ ...formData, language: item.value as string })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Timezone</label>
|
||||
<SimpleSelect
|
||||
items={timezones}
|
||||
defaultValue={formData.timezone}
|
||||
onSelect={item => setFormData({ ...formData, timezone: item.value as string })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 rounded-lg bg-gray-50 p-3 text-xs text-gray-700">
|
||||
<div><strong>Country:</strong> {formData.country}</div>
|
||||
<div><strong>Language:</strong> {formData.language}</div>
|
||||
<div><strong>Timezone:</strong> {formData.timezone}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormField: Story = {
|
||||
render: () => <FormFieldDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Filter selector
|
||||
const FilterSelectorDemo = () => {
|
||||
const [status, setStatus] = useState('all')
|
||||
const [priority, setPriority] = useState('all')
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all', name: 'All Status' },
|
||||
{ value: 'active', name: 'Active' },
|
||||
{ value: 'pending', name: 'Pending' },
|
||||
{ value: 'completed', name: 'Completed' },
|
||||
{ value: 'cancelled', name: 'Cancelled' },
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ value: 'all', name: 'All Priorities' },
|
||||
{ value: 'high', name: 'High Priority' },
|
||||
{ value: 'medium', name: 'Medium Priority' },
|
||||
{ value: 'low', name: 'Low Priority' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Task Filters</h3>
|
||||
<div className="mb-6 flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Status</label>
|
||||
<SimpleSelect
|
||||
items={statusOptions}
|
||||
defaultValue={status}
|
||||
onSelect={item => setStatus(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Priority</label>
|
||||
<SimpleSelect
|
||||
items={priorityOptions}
|
||||
defaultValue={priority}
|
||||
onSelect={item => setPriority(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-blue-50 p-4 text-sm">
|
||||
<div className="mb-2 font-medium text-gray-700">Active Filters:</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="rounded bg-blue-200 px-2 py-1 text-xs text-blue-800">
|
||||
Status: {status}
|
||||
</span>
|
||||
<span className="rounded bg-blue-200 px-2 py-1 text-xs text-blue-800">
|
||||
Priority: {priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FilterSelector: Story = {
|
||||
render: () => <FilterSelectorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Version selector with badge
|
||||
const VersionSelectorDemo = () => {
|
||||
const [selectedVersion, setSelectedVersion] = useState('2.1.0')
|
||||
|
||||
const versions = [
|
||||
{ value: '3.0.0', name: 'v3.0.0 (Beta)' },
|
||||
{ value: '2.1.0', name: 'v2.1.0 (Latest)' },
|
||||
{ value: '2.0.5', name: 'v2.0.5' },
|
||||
{ value: '2.0.4', name: 'v2.0.4' },
|
||||
{ value: '1.9.8', name: 'v1.9.8' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Select Version</h3>
|
||||
<PortalSelect
|
||||
value={selectedVersion}
|
||||
items={versions}
|
||||
onSelect={item => setSelectedVersion(item.value as string)}
|
||||
installedValue="2.0.5"
|
||||
placeholder="Choose version..."
|
||||
/>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-3 text-sm text-gray-700">
|
||||
{selectedVersion !== '2.0.5' && (
|
||||
<div className="mb-2 text-yellow-600">
|
||||
⚠️ Version change detected
|
||||
</div>
|
||||
)}
|
||||
<div>Current: <strong>{selectedVersion}</strong></div>
|
||||
<div className="mt-1 text-xs text-gray-500">Installed: 2.0.5</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VersionSelector: Story = {
|
||||
render: () => <VersionSelectorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Settings dropdown
|
||||
const SettingsDropdownDemo = () => {
|
||||
const [theme, setTheme] = useState('light')
|
||||
const [fontSize, setFontSize] = useState('medium')
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light', name: '☀️ Light Mode' },
|
||||
{ value: 'dark', name: '🌙 Dark Mode' },
|
||||
{ value: 'auto', name: '🔄 Auto (System)' },
|
||||
]
|
||||
|
||||
const fontSizeOptions = [
|
||||
{ value: 'small', name: 'Small (12px)' },
|
||||
{ value: 'medium', name: 'Medium (14px)' },
|
||||
{ value: 'large', name: 'Large (16px)' },
|
||||
{ value: 'xlarge', name: 'Extra Large (18px)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Display Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Theme</label>
|
||||
<SimpleSelect
|
||||
items={themeOptions}
|
||||
defaultValue={theme}
|
||||
onSelect={item => setTheme(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Font Size</label>
|
||||
<SimpleSelect
|
||||
items={fontSizeOptions}
|
||||
defaultValue={fontSize}
|
||||
onSelect={item => setFontSize(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsDropdown: Story = {
|
||||
render: () => <SettingsDropdownDemo />,
|
||||
}
|
||||
|
||||
// Comparison of variants
|
||||
const VariantComparisonDemo = () => {
|
||||
const [simple, setSimple] = useState('apple')
|
||||
const [withSearch, setWithSearch] = useState('us')
|
||||
const [portal, setPortal] = useState('banana')
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-6 text-lg font-semibold">Select Variants Comparison</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">SimpleSelect (Basic)</h4>
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
items={fruits}
|
||||
defaultValue={simple}
|
||||
onSelect={item => setSimple(item.value as string)}
|
||||
placeholder="Choose a fruit..."
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Standard dropdown without search</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">Select (With Search)</h4>
|
||||
<div style={{ width: '300px' }}>
|
||||
<Select
|
||||
items={countries}
|
||||
defaultValue={withSearch}
|
||||
onSelect={item => setWithSearch(item.value as string)}
|
||||
allowSearch={true}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Dropdown with search/filter capability</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">PortalSelect (Portal-based)</h4>
|
||||
<div style={{ width: '300px' }}>
|
||||
<PortalSelect
|
||||
value={portal}
|
||||
items={fruits}
|
||||
onSelect={item => setPortal(item.value as string)}
|
||||
placeholder="Choose a fruit..."
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Portal-based positioning for better overflow handling</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VariantComparison: Story = {
|
||||
render: () => <VariantComparisonDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
const PlaygroundDemo = () => {
|
||||
const [selected, setSelected] = useState('apple')
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }}>
|
||||
<SimpleSelect
|
||||
items={fruits}
|
||||
defaultValue={selected}
|
||||
onSelect={item => setSelected(item.value as string)}
|
||||
placeholder="Select an option..."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PlaygroundDemo />,
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Slider from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Slider',
|
||||
component: Slider,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Slider component for selecting a numeric value within a range. Built on react-slider with customizable min/max/step values.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'number',
|
||||
description: 'Current slider value',
|
||||
},
|
||||
min: {
|
||||
control: 'number',
|
||||
description: 'Minimum value (default: 0)',
|
||||
},
|
||||
max: {
|
||||
control: 'number',
|
||||
description: 'Maximum value (default: 100)',
|
||||
},
|
||||
step: {
|
||||
control: 'number',
|
||||
description: 'Step increment (default: 1)',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Slider>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const SliderDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || 50)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Slider
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v)
|
||||
console.log('Slider value:', v)
|
||||
}}
|
||||
/>
|
||||
<div className="mt-4 text-center text-sm text-gray-600">
|
||||
Value: <span className="text-lg font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With custom range
|
||||
export const CustomRange: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 25,
|
||||
min: 0,
|
||||
max: 50,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With step increment
|
||||
export const WithStepIncrement: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 10,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Decimal values
|
||||
export const DecimalValues: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 2.5,
|
||||
min: 0,
|
||||
max: 5,
|
||||
step: 0.5,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 75,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Volume control
|
||||
const VolumeControlDemo = () => {
|
||||
const [volume, setVolume] = useState(70)
|
||||
|
||||
const getVolumeIcon = (vol: number) => {
|
||||
if (vol === 0) return '🔇'
|
||||
if (vol < 33) return '🔈'
|
||||
if (vol < 66) return '🔉'
|
||||
return '🔊'
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Volume Control</h3>
|
||||
<span className="text-2xl">{getVolumeIcon(volume)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={volume}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onChange={setVolume}
|
||||
/>
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
|
||||
<span>Mute</span>
|
||||
<span className="text-lg font-semibold">{volume}%</span>
|
||||
<span>Max</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VolumeControl: Story = {
|
||||
render: () => <VolumeControlDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Brightness control
|
||||
const BrightnessControlDemo = () => {
|
||||
const [brightness, setBrightness] = useState(80)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Screen Brightness</h3>
|
||||
<span className="text-2xl">☀️</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={brightness}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onChange={setBrightness}
|
||||
/>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4" style={{ opacity: brightness / 100 }}>
|
||||
<div className="text-sm text-gray-700">
|
||||
Preview at {brightness}% brightness
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BrightnessControl: Story = {
|
||||
render: () => <BrightnessControlDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Price range filter
|
||||
const PriceRangeFilterDemo = () => {
|
||||
const [maxPrice, setMaxPrice] = useState(500)
|
||||
const minPrice = 0
|
||||
|
||||
const products = [
|
||||
{ name: 'Product A', price: 150 },
|
||||
{ name: 'Product B', price: 350 },
|
||||
{ name: 'Product C', price: 600 },
|
||||
{ name: 'Product D', price: 250 },
|
||||
{ name: 'Product E', price: 450 },
|
||||
]
|
||||
|
||||
const filteredProducts = products.filter(p => p.price >= minPrice && p.price <= maxPrice)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Filter by Price</h3>
|
||||
<div className="mb-2">
|
||||
<div className="mb-2 flex items-center justify-between text-sm text-gray-600">
|
||||
<span>Maximum Price</span>
|
||||
<span className="font-semibold text-gray-900">${maxPrice}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={maxPrice}
|
||||
min={0}
|
||||
max={1000}
|
||||
step={50}
|
||||
onChange={setMaxPrice}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="mb-3 text-sm font-medium text-gray-700">
|
||||
Showing {filteredProducts.length} of {products.length} products
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filteredProducts.map(product => (
|
||||
<div key={product.name} className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<span className="text-sm">{product.name}</span>
|
||||
<span className="font-semibold text-gray-900">${product.price}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PriceRangeFilter: Story = {
|
||||
render: () => <PriceRangeFilterDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Temperature selector
|
||||
const TemperatureSelectorDemo = () => {
|
||||
const [temperature, setTemperature] = useState(22)
|
||||
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Thermostat Control</h3>
|
||||
<div className="mb-6">
|
||||
<Slider
|
||||
value={temperature}
|
||||
min={16}
|
||||
max={30}
|
||||
step={0.5}
|
||||
onChange={setTemperature}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg bg-blue-50 p-4 text-center">
|
||||
<div className="mb-1 text-xs text-gray-600">Celsius</div>
|
||||
<div className="text-3xl font-bold text-blue-600">{temperature}°C</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-orange-50 p-4 text-center">
|
||||
<div className="mb-1 text-xs text-gray-600">Fahrenheit</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{fahrenheit}°F</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-xs text-gray-500">
|
||||
{temperature < 18 && '🥶 Too cold'}
|
||||
{temperature >= 18 && temperature <= 24 && '😊 Comfortable'}
|
||||
{temperature > 24 && '🥵 Too warm'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TemperatureSelector: Story = {
|
||||
render: () => <TemperatureSelectorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Progress/completion slider
|
||||
const ProgressSliderDemo = () => {
|
||||
const [progress, setProgress] = useState(65)
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Project Completion</h3>
|
||||
<Slider
|
||||
value={progress}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onChange={setProgress}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Progress</span>
|
||||
<span className="text-lg font-bold text-blue-600">{progress}%</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={progress >= 25 ? '✅' : '⏳'}>Planning</span>
|
||||
<span className="text-xs text-gray-500">25%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={progress >= 50 ? '✅' : '⏳'}>Development</span>
|
||||
<span className="text-xs text-gray-500">50%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={progress >= 75 ? '✅' : '⏳'}>Testing</span>
|
||||
<span className="text-xs text-gray-500">75%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={progress >= 100 ? '✅' : '⏳'}>Deployment</span>
|
||||
<span className="text-xs text-gray-500">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProgressSlider: Story = {
|
||||
render: () => <ProgressSliderDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Zoom control
|
||||
const ZoomControlDemo = () => {
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Zoom Level</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
|
||||
onClick={() => setZoom(Math.max(50, zoom - 10))}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<Slider
|
||||
value={zoom}
|
||||
min={50}
|
||||
max={200}
|
||||
step={10}
|
||||
onChange={setZoom}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
|
||||
onClick={() => setZoom(Math.min(200, zoom + 10))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
|
||||
<span>50%</span>
|
||||
<span className="text-lg font-semibold">{zoom}%</span>
|
||||
<span>200%</span>
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4 text-center" style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'center' }}>
|
||||
<div className="text-sm">Preview content</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ZoomControl: Story = {
|
||||
render: () => <ZoomControlDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - AI model parameters
|
||||
const AIModelParametersDemo = () => {
|
||||
const [temperature, setTemperature] = useState(0.7)
|
||||
const [maxTokens, setMaxTokens] = useState(2000)
|
||||
const [topP, setTopP] = useState(0.9)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Model Configuration</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">Temperature</label>
|
||||
<span className="text-sm font-semibold">{temperature}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={temperature}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
onChange={setTemperature}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Controls randomness. Lower is more focused, higher is more creative.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">Max Tokens</label>
|
||||
<span className="text-sm font-semibold">{maxTokens}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={maxTokens}
|
||||
min={100}
|
||||
max={4000}
|
||||
step={100}
|
||||
onChange={setMaxTokens}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Maximum length of generated response.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">Top P</label>
|
||||
<span className="text-sm font-semibold">{topP}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={topP}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={setTopP}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Nucleus sampling threshold.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 rounded-lg bg-blue-50 p-4 text-xs text-gray-700">
|
||||
<div><strong>Temperature:</strong> {temperature}</div>
|
||||
<div><strong>Max Tokens:</strong> {maxTokens}</div>
|
||||
<div><strong>Top P:</strong> {topP}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AIModelParameters: Story = {
|
||||
render: () => <AIModelParametersDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Image quality selector
|
||||
const ImageQualitySelectorDemo = () => {
|
||||
const [quality, setQuality] = useState(80)
|
||||
|
||||
const getQualityLabel = (q: number) => {
|
||||
if (q < 50) return 'Low'
|
||||
if (q < 70) return 'Medium'
|
||||
if (q < 90) return 'High'
|
||||
return 'Maximum'
|
||||
}
|
||||
|
||||
const estimatedSize = Math.round((quality / 100) * 5)
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Image Export Quality</h3>
|
||||
<Slider
|
||||
value={quality}
|
||||
min={10}
|
||||
max={100}
|
||||
step={10}
|
||||
onChange={setQuality}
|
||||
/>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-xs text-gray-600">Quality</div>
|
||||
<div className="text-lg font-semibold">{getQualityLabel(quality)}</div>
|
||||
<div className="text-xs text-gray-500">{quality}%</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-xs text-gray-600">File Size</div>
|
||||
<div className="text-lg font-semibold">~{estimatedSize} MB</div>
|
||||
<div className="text-xs text-gray-500">Estimated</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ImageQualitySelector: Story = {
|
||||
render: () => <ImageQualitySelectorDemo />,
|
||||
}
|
||||
|
||||
// Multiple sliders
|
||||
const MultipleSlidersDemo = () => {
|
||||
const [red, setRed] = useState(128)
|
||||
const [green, setGreen] = useState(128)
|
||||
const [blue, setBlue] = useState(128)
|
||||
|
||||
const rgbColor = `rgb(${red}, ${green}, ${blue})`
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">RGB Color Picker</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-red-600">Red</label>
|
||||
<span className="text-sm font-semibold">{red}</span>
|
||||
</div>
|
||||
<Slider value={red} min={0} max={255} step={1} onChange={setRed} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-green-600">Green</label>
|
||||
<span className="text-sm font-semibold">{green}</span>
|
||||
</div>
|
||||
<Slider value={green} min={0} max={255} step={1} onChange={setGreen} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-blue-600">Blue</label>
|
||||
<span className="text-sm font-semibold">{blue}</span>
|
||||
</div>
|
||||
<Slider value={blue} min={0} max={255} step={1} onChange={setBlue} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div
|
||||
className="h-24 w-24 rounded-lg border-2 border-gray-300"
|
||||
style={{ backgroundColor: rgbColor }}
|
||||
/>
|
||||
<div className="text-right">
|
||||
<div className="mb-1 text-xs text-gray-600">Color Value</div>
|
||||
<div className="font-mono text-sm font-semibold">{rgbColor}</div>
|
||||
<div className="mt-1 font-mono text-xs text-gray-500">
|
||||
#{red.toString(16).padStart(2, '0')}
|
||||
{green.toString(16).padStart(2, '0')}
|
||||
{blue.toString(16).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MultipleSliders: Story = {
|
||||
render: () => <MultipleSlidersDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Switch from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Switch',
|
||||
component: Switch,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Toggle switch component with multiple sizes (xs, sm, md, lg, l). Built on Headless UI Switch with smooth animations.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'sm', 'md', 'lg', 'l'],
|
||||
description: 'Switch size',
|
||||
},
|
||||
defaultValue: {
|
||||
control: 'boolean',
|
||||
description: 'Default checked state',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Switch>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const SwitchDemo = (args: any) => {
|
||||
const [enabled, setEnabled] = useState(args.defaultValue || false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
{...args}
|
||||
defaultValue={enabled}
|
||||
onChange={(value) => {
|
||||
setEnabled(value)
|
||||
console.log('Switch toggled:', value)
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{enabled ? 'On' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state (off)
|
||||
export const Default: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: false,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Default on
|
||||
export const DefaultOn: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: true,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled off
|
||||
export const DisabledOff: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: false,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled on
|
||||
export const DisabledOn: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: true,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Size variations
|
||||
const SizeComparisonDemo = () => {
|
||||
const [states, setStates] = useState({
|
||||
xs: false,
|
||||
sm: false,
|
||||
md: true,
|
||||
lg: true,
|
||||
l: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="xs" defaultValue={states.xs} onChange={v => setStates({ ...states, xs: v })} />
|
||||
<span className="text-sm text-gray-700">Extra Small (xs)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="sm" defaultValue={states.sm} onChange={v => setStates({ ...states, sm: v })} />
|
||||
<span className="text-sm text-gray-700">Small (sm)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" defaultValue={states.md} onChange={v => setStates({ ...states, md: v })} />
|
||||
<span className="text-sm text-gray-700">Medium (md)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="l" defaultValue={states.l} onChange={v => setStates({ ...states, l: v })} />
|
||||
<span className="text-sm text-gray-700">Large (l)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" defaultValue={states.lg} onChange={v => setStates({ ...states, lg: v })} />
|
||||
<span className="text-sm text-gray-700">Extra Large (lg)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => <SizeComparisonDemo />,
|
||||
}
|
||||
|
||||
// With labels
|
||||
const WithLabelsDemo = () => {
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Email Notifications</div>
|
||||
<div className="text-xs text-gray-500">Receive email updates about your account</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithLabels: Story = {
|
||||
render: () => <WithLabelsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Settings panel
|
||||
const SettingsPanelDemo = () => {
|
||||
const [settings, setSettings] = useState({
|
||||
notifications: true,
|
||||
autoSave: true,
|
||||
darkMode: false,
|
||||
analytics: false,
|
||||
emailUpdates: true,
|
||||
})
|
||||
|
||||
const updateSetting = (key: string, value: boolean) => {
|
||||
setSettings({ ...settings, [key]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Application Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Push Notifications</div>
|
||||
<div className="text-xs text-gray-500">Receive push notifications on your device</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.notifications}
|
||||
onChange={v => updateSetting('notifications', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Auto-Save</div>
|
||||
<div className="text-xs text-gray-500">Automatically save changes as you work</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.autoSave}
|
||||
onChange={v => updateSetting('autoSave', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Dark Mode</div>
|
||||
<div className="text-xs text-gray-500">Use dark theme for the interface</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.darkMode}
|
||||
onChange={v => updateSetting('darkMode', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Analytics</div>
|
||||
<div className="text-xs text-gray-500">Help us improve by sharing usage data</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.analytics}
|
||||
onChange={v => updateSetting('analytics', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Email Updates</div>
|
||||
<div className="text-xs text-gray-500">Receive product updates via email</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.emailUpdates}
|
||||
onChange={v => updateSetting('emailUpdates', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsPanel: Story = {
|
||||
render: () => <SettingsPanelDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Privacy controls
|
||||
const PrivacyControlsDemo = () => {
|
||||
const [privacy, setPrivacy] = useState({
|
||||
profilePublic: false,
|
||||
showEmail: false,
|
||||
allowMessages: true,
|
||||
shareActivity: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">Privacy Settings</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Control who can see your information</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Public Profile</div>
|
||||
<div className="text-xs text-gray-500">Make your profile visible to everyone</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={privacy.profilePublic}
|
||||
onChange={v => setPrivacy({ ...privacy, profilePublic: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Show Email Address</div>
|
||||
<div className="text-xs text-gray-500">Display your email on your profile</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={privacy.showEmail}
|
||||
onChange={v => setPrivacy({ ...privacy, showEmail: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Allow Direct Messages</div>
|
||||
<div className="text-xs text-gray-500">Let others send you private messages</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={privacy.allowMessages}
|
||||
onChange={v => setPrivacy({ ...privacy, allowMessages: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Share Activity</div>
|
||||
<div className="text-xs text-gray-500">Show your recent activity to connections</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={privacy.shareActivity}
|
||||
onChange={v => setPrivacy({ ...privacy, shareActivity: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PrivacyControls: Story = {
|
||||
render: () => <PrivacyControlsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Feature toggles
|
||||
const FeatureTogglesDemo = () => {
|
||||
const [features, setFeatures] = useState({
|
||||
betaFeatures: false,
|
||||
experimentalUI: false,
|
||||
advancedMode: true,
|
||||
developerTools: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Feature Flags</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🧪</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Beta Features</div>
|
||||
<div className="text-xs text-gray-500">Access experimental functionality</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={features.betaFeatures}
|
||||
onChange={v => setFeatures({ ...features, betaFeatures: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🎨</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Experimental UI</div>
|
||||
<div className="text-xs text-gray-500">Try the new interface design</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={features.experimentalUI}
|
||||
onChange={v => setFeatures({ ...features, experimentalUI: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">⚡</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Advanced Mode</div>
|
||||
<div className="text-xs text-gray-500">Show advanced configuration options</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={features.advancedMode}
|
||||
onChange={v => setFeatures({ ...features, advancedMode: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🔧</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Developer Tools</div>
|
||||
<div className="text-xs text-gray-500">Enable debugging and inspection tools</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={features.developerTools}
|
||||
onChange={v => setFeatures({ ...features, developerTools: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FeatureToggles: Story = {
|
||||
render: () => <FeatureTogglesDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Notification preferences
|
||||
const NotificationPreferencesDemo = () => {
|
||||
const [notifications, setNotifications] = useState({
|
||||
email: true,
|
||||
push: true,
|
||||
sms: false,
|
||||
desktop: true,
|
||||
})
|
||||
|
||||
const allEnabled = Object.values(notifications).every(v => v)
|
||||
const someEnabled = Object.values(notifications).some(v => v)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Notification Channels</h3>
|
||||
<div className="text-xs text-gray-500">
|
||||
{allEnabled ? 'All enabled' : someEnabled ? 'Some enabled' : 'All disabled'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">📧</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Email</div>
|
||||
<div className="text-xs text-gray-500">Receive notifications via email</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={notifications.email}
|
||||
onChange={v => setNotifications({ ...notifications, email: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🔔</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Push Notifications</div>
|
||||
<div className="text-xs text-gray-500">Mobile and browser push notifications</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={notifications.push}
|
||||
onChange={v => setNotifications({ ...notifications, push: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">💬</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">SMS Messages</div>
|
||||
<div className="text-xs text-gray-500">Receive text message notifications</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={notifications.sms}
|
||||
onChange={v => setNotifications({ ...notifications, sms: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">💻</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Desktop Alerts</div>
|
||||
<div className="text-xs text-gray-500">Show desktop notification popups</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={notifications.desktop}
|
||||
onChange={v => setNotifications({ ...notifications, desktop: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const NotificationPreferences: Story = {
|
||||
render: () => <NotificationPreferencesDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - API access control
|
||||
const APIAccessControlDemo = () => {
|
||||
const [access, setAccess] = useState({
|
||||
readAccess: true,
|
||||
writeAccess: true,
|
||||
deleteAccess: false,
|
||||
adminAccess: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">API Permissions</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Configure access levels for API key</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg bg-green-50 p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<span className="text-green-600">✓</span> Read Access
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">View resources and data</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={access.readAccess}
|
||||
onChange={v => setAccess({ ...access, readAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-blue-50 p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<span className="text-blue-600">✎</span> Write Access
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Create and update resources</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={access.writeAccess}
|
||||
onChange={v => setAccess({ ...access, writeAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-red-50 p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<span className="text-red-600">🗑</span> Delete Access
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Remove resources permanently</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={access.deleteAccess}
|
||||
onChange={v => setAccess({ ...access, deleteAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-purple-50 p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<span className="text-purple-600">⚡</span> Admin Access
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Full administrative privileges</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={access.adminAccess}
|
||||
onChange={v => setAccess({ ...access, adminAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const APIAccessControl: Story = {
|
||||
render: () => <APIAccessControlDemo />,
|
||||
}
|
||||
|
||||
// Compact list with switches
|
||||
const CompactListDemo = () => {
|
||||
const [items, setItems] = useState([
|
||||
{ id: 1, name: 'Feature A', enabled: true },
|
||||
{ id: 2, name: 'Feature B', enabled: false },
|
||||
{ id: 3, name: 'Feature C', enabled: true },
|
||||
{ id: 4, name: 'Feature D', enabled: false },
|
||||
{ id: 5, name: 'Feature E', enabled: true },
|
||||
])
|
||||
|
||||
const toggleItem = (id: number) => {
|
||||
setItems(items.map(item =>
|
||||
item.id === id ? { ...item, enabled: !item.enabled } : item,
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Quick Toggles</h3>
|
||||
<div className="space-y-2">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-gray-700">{item.name}</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
defaultValue={item.enabled}
|
||||
onChange={() => toggleItem(item.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CompactList: Story = {
|
||||
render: () => <CompactListDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: false,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user