Compare commits

..
Author SHA1 Message Date
yunlu.wen f241a6d83b fix catch statement 2025-10-20 14:52:29 +08:00
yunlu.wen 96d7127d9c early stop for missing token 2025-10-20 14:49:09 +08:00
yunlu.wen 63eba34af7 consistent login status check 2025-10-20 14:45:55 +08:00
250 changed files with 1227 additions and 18329 deletions
-3
View File
@@ -30,9 +30,6 @@ 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
+3 -17
View File
@@ -1,4 +1,3 @@
import os
import sys
@@ -9,16 +8,10 @@ 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.
@@ -40,15 +33,8 @@ else:
from app_factory import create_app
socketio_app, flask_app = create_app()
app = flask_app
celery = flask_app.extensions["celery"]
app = create_app()
celery = app.extensions["celery"]
if __name__ == "__main__":
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()
app.run(host="0.0.0.0", port=5001)
+2 -10
View File
@@ -31,22 +31,14 @@ def create_flask_app_with_configs() -> DifyApp:
return dify_app
def create_app() -> tuple[any, DifyApp]:
def create_app() -> 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 socketio_app, app
return app
def initialize_extensions(app: DifyApp):
-8
View File
@@ -1048,13 +1048,6 @@ 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",
@@ -1143,7 +1136,6 @@ class FeatureConfig(
WorkflowConfig,
WorkflowNodeExecutionConfig,
WorkspaceConfig,
CollaborationConfig,
LoginConfig,
AccountConfig,
SwaggerUIConfig,
-2
View File
@@ -58,13 +58,11 @@ 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,
-339
View File
@@ -1,339 +0,0 @@
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"}
-73
View File
@@ -9,7 +9,6 @@ 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
@@ -22,9 +21,7 @@ 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
@@ -125,7 +122,6 @@ 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:
@@ -143,7 +139,6 @@ 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
@@ -168,7 +163,6 @@ 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()
@@ -740,46 +734,6 @@ 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")
@@ -961,30 +915,3 @@ 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}
@@ -1,240 +0,0 @@
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 = variable_factory.build_segment_with_type(variable.value_type, raw_value)
new_value = 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,35 +448,8 @@ 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")
@@ -526,44 +499,3 @@ 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")
+12 -1
View File
@@ -1,6 +1,7 @@
import flask_login
from flask import make_response, request
from flask_restx import Resource, reqparse
from werkzeug.exceptions import Unauthorized
import services
from configs import dify_config
@@ -25,7 +26,9 @@ from controllers.console.wraps import email_password_login_enabled, setup_requir
from events.tenant_event import tenant_was_created
from libs.helper import email, extract_remote_ip
from libs.login import current_account_with_tenant
from libs.passport import PassportService
from libs.token import (
check_csrf_token,
clear_access_token_from_cookie,
clear_csrf_token_from_cookie,
clear_refresh_token_from_cookie,
@@ -295,4 +298,12 @@ class LoginStatus(Resource):
def get(self):
token = extract_access_token(request)
csrf_token = extract_csrf_token(request)
return {"logged_in": bool(token) and bool(csrf_token)}
if not token or not csrf_token:
return {"logged_in": False}
res = True
try:
validated = PassportService().verify(token=token)
check_csrf_token(request=request, user_id=validated.get("user_id", ""))
except Unauthorized:
res = False
return {"logged_in": res}
@@ -32,7 +32,6 @@ 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
@@ -129,17 +128,6 @@ 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,8 +289,7 @@ class OracleVector(BaseVector):
words = pseg.cut(query)
current_entity = ""
for word, pos in words:
# nr: person name, ns: place name, nt: organization name
if pos in {"nr", "Ng", "eng", "nz", "n", "ORG", "v"}:
if pos in {"nr", "Ng", "eng", "nz", "n", "ORG", "v"}: # nr: 人名,ns: 地名,nt: 机构名
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 supports vector dimensions in range [1, 16000]
# Vastbase 支持的向量维度取值范围为 [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, instructions=instruction),
text=CHAT_GENERATE_JSON_PROMPT.format(histories=memory_str).replace("{{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>
"""
+3 -5
View File
@@ -38,16 +38,14 @@ elif [[ "${MODE}" == "beat" ]]; then
exec celery -A app.celery beat --loglevel ${LOG_LEVEL:-INFO}
else
if [[ "${DEBUG}" == "true" ]]; then
export HOST=${DIFY_BIND_ADDRESS:-0.0.0.0}
export PORT=${DIFY_PORT:-5001}
exec python -m app
exec flask run --host=${DIFY_BIND_ADDRESS:-0.0.0.0} --port=${DIFY_PORT:-5001} --debug
else
exec gunicorn \
--bind "${DIFY_BIND_ADDRESS:-0.0.0.0}:${DIFY_PORT:-5001}" \
--workers ${SERVER_WORKER_AMOUNT:-1} \
--worker-class ${SERVER_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} \
--worker-class ${SERVER_WORKER_CLASS:-gevent} \
--worker-connections ${SERVER_WORKER_CONNECTIONS:-10} \
--timeout ${GUNICORN_TIMEOUT:-200} \
app:socketio_app
app:app
fi
fi
+5 -10
View File
@@ -1,12 +1,7 @@
from configs import dify_config
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN, HEADER_NAME_PASSPORT
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN
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
@@ -22,7 +17,7 @@ def init_app(app: DifyApp):
CORS(
service_api_bp,
allow_headers=list(SERVICE_API_HEADERS),
allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE],
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
)
app.register_blueprint(service_api_bp)
@@ -31,7 +26,7 @@ def init_app(app: DifyApp):
web_bp,
resources={r"/*": {"origins": dify_config.WEB_API_CORS_ALLOW_ORIGINS}},
supports_credentials=True,
allow_headers=list(AUTHENTICATED_HEADERS),
allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN],
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
expose_headers=["X-Version", "X-Env"],
)
@@ -41,7 +36,7 @@ def init_app(app: DifyApp):
console_app_bp,
resources={r"/*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}},
supports_credentials=True,
allow_headers=list(AUTHENTICATED_HEADERS),
allow_headers=["Content-Type", "Authorization", HEADER_NAME_CSRF_TOKEN],
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
expose_headers=["X-Version", "X-Env"],
)
@@ -49,7 +44,7 @@ def init_app(app: DifyApp):
CORS(
files_bp,
allow_headers=list(FILES_HEADERS),
allow_headers=["Content-Type", HEADER_NAME_CSRF_TOKEN],
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
)
app.register_blueprint(files_bp)
-3
View File
@@ -1,3 +0,0 @@
import socketio
sio = socketio.Server(async_mode="gevent", cors_allowed_origins="*")
-17
View File
@@ -1,17 +0,0 @@
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)),
}
-96
View File
@@ -1,96 +0,0 @@
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,
}
+3 -1
View File
@@ -1,3 +1,5 @@
from typing import Any
import jwt
from werkzeug.exceptions import Unauthorized
@@ -11,7 +13,7 @@ class PassportService:
def issue(self, payload):
return jwt.encode(payload, self.sk, algorithm="HS256")
def verify(self, token):
def verify(self, token) -> dict[str, Any]:
try:
return jwt.decode(token, self.sk, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
@@ -1,90 +0,0 @@
"""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 ###
-8
View File
@@ -9,11 +9,6 @@ from .account import (
TenantStatus,
)
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
from .comment import (
WorkflowComment,
WorkflowCommentMention,
WorkflowCommentReply,
)
from .dataset import (
AppDatasetJoin,
Dataset,
@@ -179,9 +174,6 @@ __all__ = [
"Workflow",
"WorkflowAppLog",
"WorkflowAppLogCreatedFrom",
"WorkflowComment",
"WorkflowCommentMention",
"WorkflowCommentReply",
"WorkflowNodeExecutionModel",
"WorkflowNodeExecutionOffload",
"WorkflowNodeExecutionTriggeredFrom",
-189
View File
@@ -1,189 +0,0 @@
"""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)
+1 -1
View File
@@ -335,7 +335,7 @@ class Workflow(Base):
:return: hash
"""
entity = {"graph": self.graph_dict}
entity = {"graph": self.graph_dict, "features": self.features_dict}
return helper.generate_text_hash(json.dumps(entity, sort_keys=True))
-2
View File
@@ -20,7 +20,6 @@ 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",
@@ -69,7 +68,6 @@ 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",
-2
View File
@@ -151,7 +151,6 @@ 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
@@ -212,7 +211,6 @@ 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 != ""
-311
View File
@@ -1,311 +0,0 @@
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)
+1 -75
View File
@@ -197,17 +197,15 @@ 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 and not force_upload:
if workflow and workflow.unique_hash != unique_hash:
raise WorkflowHashNotEqualError()
# validate features structure
@@ -245,78 +243,6 @@ 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,7 +267,6 @@ 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"
@@ -292,7 +291,6 @@ 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
@@ -342,7 +340,6 @@ 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"
@@ -364,7 +361,6 @@ 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
View File
@@ -553,15 +553,6 @@ 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"
@@ -1321,7 +1312,6 @@ dependencies = [
{ name = "flask-restx" },
{ name = "flask-sqlalchemy" },
{ name = "gevent" },
{ name = "gevent-websocket" },
{ name = "gmpy2" },
{ name = "google-api-core" },
{ name = "google-api-python-client" },
@@ -1369,7 +1359,6 @@ dependencies = [
{ name = "pypdfium2" },
{ name = "python-docx" },
{ name = "python-dotenv" },
{ name = "python-socketio" },
{ name = "pyyaml" },
{ name = "readabilipy" },
{ name = "redis", extra = ["hiredis"] },
@@ -1514,7 +1503,6 @@ 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" },
@@ -1562,7 +1550,6 @@ 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" },
@@ -2117,18 +2104,6 @@ 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"
@@ -5098,18 +5073,6 @@ 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"
@@ -5166,19 +5129,6 @@ 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"
@@ -5684,18 +5634,6 @@ 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"
@@ -7094,18 +7032,6 @@ 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"
-5
View File
@@ -122,10 +122,6 @@ 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
@@ -153,7 +149,6 @@ 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,14 +14,6 @@ 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;
+1 -1
View File
@@ -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};
@@ -1,4 +0,0 @@
// Mock for context-block plugin to avoid circular dependency in Storybook
export const ContextBlockNode = null
export const ContextBlockReplacementBlock = null
export default null
@@ -1,4 +0,0 @@
// Mock for history-block plugin to avoid circular dependency in Storybook
export const HistoryBlockNode = null
export const HistoryBlockReplacementBlock = null
export default null
-4
View File
@@ -1,4 +0,0 @@
// Mock for query-block plugin to avoid circular dependency in Storybook
export const QueryBlockNode = null
export const QueryBlockReplacementBlock = null
export default null
-17
View File
@@ -1,9 +1,4 @@
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)'],
@@ -30,17 +25,5 @@ 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
+2 -1
View File
@@ -160,7 +160,8 @@ describe('Navigation Utilities', () => {
page: 1,
limit: '',
keyword: 'test',
filter: '',
empty: null,
undefined,
})
expect(path).toBe('/datasets/123/documents?page=1&keyword=test')
+102 -147
View File
@@ -39,38 +39,28 @@ 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: handleAddListener,
removeListener: handleRemoveListener,
addEventListener: handleAddEventListener,
removeEventListener: handleRemoveEventListener,
dispatchEvent: handleDispatchEvent,
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
},
}
return mediaQueryList
@@ -79,121 +69,6 @@ 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)
@@ -352,7 +227,39 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
setupMockEnvironment('dark')
const timingData: Array<{ phase: string; timestamp: number; styles: any }> = []
const TimingPageComponent = createTimingPageComponent(timingData)
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>
)
}
render(
<TestThemeProvider>
@@ -388,7 +295,33 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
setupMockEnvironment('dark')
const cssStates: Array<{ className: string; timestamp: number }> = []
const CSSTestComponent = createCSSTestComponent(cssStates)
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>
)
}
render(
<TestThemeProvider>
@@ -480,12 +413,34 @@ 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 />
+13 -19
View File
@@ -70,18 +70,14 @@ 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: AppDetail = { tags: [] }
const appDetailWithTags: AppDetail = { tags: [{ id: 'tag1', name: 't' }] }
const appDetailWithUndefinedTags: { tags: Tag[] | undefined } = { tags: undefined }
const appDetailWithoutTags = { tags: [] }
const appDetailWithTags = { tags: [{ id: 'tag1' }] }
const appDetailWithUndefinedTags = { tags: undefined as any }
// This simulates the condition in layout-main.tsx
const shouldFallback1 = appDetailWithoutTags.tags.length === 0
const shouldFallback2 = appDetailWithTags.tags.length === 0
const shouldFallback1 = !appDetailWithoutTags.tags || appDetailWithoutTags.tags.length === 0
const shouldFallback2 = !appDetailWithTags.tags || appDetailWithTags.tags.length === 0
const shouldFallback3 = !appDetailWithUndefinedTags.tags || appDetailWithUndefinedTags.tags.length === 0
expect(shouldFallback1).toBe(true) // Empty array should trigger fallback
@@ -90,26 +86,24 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
})
it('should preserve tags when fallback succeeds', () => {
const originalAppDetail: AppDetail = { tags: [] }
const fallbackResult: { tags?: Tag[] } = { tags: [{ id: 'tag1', name: 'fallback-tag' }] }
const originalAppDetail = { tags: [] as any[] }
const fallbackResult = { tags: [{ id: 'tag1', name: 'fallback-tag' }] }
// This simulates the successful fallback in layout-main.tsx
const tags = fallbackResult.tags
if (tags)
originalAppDetail.tags = tags
if (fallbackResult?.tags)
originalAppDetail.tags = fallbackResult.tags
expect(originalAppDetail.tags).toEqual(fallbackResult.tags)
expect(originalAppDetail.tags.length).toBe(1)
})
it('should continue with empty tags when fallback fails', () => {
const originalAppDetail: AppDetail = { tags: [] }
const fallbackResult = null as FallbackResult
const originalAppDetail: { tags: any[] } = { tags: [] }
const fallbackResult: { tags?: any[] } | null = null
// This simulates fallback failure in layout-main.tsx
const tags: Tag[] | undefined = fallbackResult && 'tags' in fallbackResult ? fallbackResult.tags : undefined
if (tags)
originalAppDetail.tags = tags
if (fallbackResult?.tags)
originalAppDetail.tags = fallbackResult.tags
expect(originalAppDetail.tags).toEqual([])
})
@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useEffect } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppCard from '@/app/components/app/overview/app-card'
@@ -19,8 +19,6 @@ 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
@@ -49,44 +47,15 @@ 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({
@@ -73,7 +73,7 @@ const ConfigPopup: FC<PopupProps> = ({
}
}, [onChooseProvider])
const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | TencentConfig) => {
const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => {
onConfigUpdated(currentProvider!, payload)
hideConfigModal()
}, [currentProvider, hideConfigModal, onConfigUpdated])
+3 -36
View File
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import { useContext } from 'use-context-selector'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, 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, fetchAppDetail, updateAppInfo } from '@/service/apps'
import { copyApp, deleteApp, exportAppConfig, 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,8 +31,6 @@ 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,
@@ -76,19 +74,6 @@ 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,
@@ -117,12 +102,11 @@ 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, emitAppMetaUpdate])
}, [appDetail, notify, setAppDetail, t])
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
if (!appDetail)
@@ -219,23 +203,6 @@ 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)
+1 -33
View File
@@ -47,9 +47,6 @@ 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
@@ -99,7 +96,6 @@ 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)
@@ -124,27 +120,11 @@ 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)
}
}, [appDetail?.id, onPublish, invalidateAppWorkflow])
}, [onPublish])
const handleRestore = useCallback(async () => {
try {
@@ -198,18 +178,6 @@ 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
+1 -22
View File
@@ -32,8 +32,6 @@ 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,
@@ -57,10 +55,9 @@ const AccessControl = dynamic(() => import('@/app/components/app/app-access-cont
export type AppCardProps = {
app: App
onRefresh?: () => void
onlineUsers?: WorkflowOnlineUser[]
}
const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
const AppCard = ({ app, onRefresh }: AppCardProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
@@ -336,19 +333,6 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: 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
@@ -393,11 +377,6 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: 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
+4 -40
View File
@@ -1,11 +1,10 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, 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 {
@@ -20,8 +19,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, WorkflowOnlineUser } from '@/models/app'
import { fetchAppList, fetchWorkflowOnlineUsers } from '@/service/apps'
import type { AppListResponse } from '@/models/app'
import { fetchAppList } from '@/service/apps'
import { useAppContext } from '@/context/app-context'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { CheckModal } from '@/hooks/use-pay'
@@ -113,36 +112,6 @@ 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]' /> },
@@ -244,12 +213,7 @@ 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}
onlineUsers={onlineUsersByWorkflow?.[app.id] ?? []}
/>
<AppCard key={app.id} app={app} onRefresh={mutate} />
)))}
</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'>
@@ -1,262 +0,0 @@
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" />,
},
}
@@ -1,204 +0,0 @@
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<HTMLTextAreaElement>;
ref: React.RefObject<unknown>;
},
) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
+8 -22
View File
@@ -9,7 +9,6 @@ export type AvatarProps = {
className?: string
textClassName?: string
onError?: (x: boolean) => void
backgroundColor?: string
}
const Avatar = ({
name,
@@ -18,18 +17,9 @@ const Avatar = ({
className,
textClassName,
onError,
backgroundColor,
}: AvatarProps) => {
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 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 [imgError, setImgError] = useState(false)
const handleError = () => {
@@ -45,18 +35,14 @@ const Avatar = ({
if (avatar && !imgError) {
return (
<span
<img
className={cn(avatarClassName, className)}
style={style}
>
<img
className='h-full w-full rounded-full object-cover'
alt={name}
src={avatar}
onError={handleError}
onLoad={() => onError?.(false)}
/>
</span>
alt={name}
src={avatar}
onError={handleError}
onLoad={() => onError?.(false)}
/>
)
}
@@ -1,191 +0,0 @@
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,5 +1,7 @@
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 '.'
@@ -32,11 +34,6 @@ const mockedBaseChatItem = {
content: 'Hello, how can I assist you today?',
} satisfies ChatItem
const mockedWorkflowProcess = {
status: 'succeeded',
tracing: [],
}
export const Basic: Story = {
args: {
item: mockedBaseChatItem,
@@ -1,394 +0,0 @@
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,12 +15,11 @@ 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-[70] box-border h-full w-full p-2'
className='absolute left-0 top-0 z-30 box-border h-full w-full p-2'
>
<TransitionChild>
<div
@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 527 B

@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -1,36 +0,0 @@
{
"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"
}
@@ -1,20 +0,0 @@
// 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,7 +1,6 @@
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.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -1,26 +0,0 @@
{
"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"
}
@@ -1,20 +0,0 @@
// 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,5 +1,4 @@
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.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -1,438 +0,0 @@
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,
},
}
@@ -1,424 +0,0 @@
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: '',
},
}
@@ -1,360 +0,0 @@
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,7 +2,6 @@
import type { FC } from 'react'
import React, { useEffect } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import type {
EditorState,
} from 'lexical'
@@ -81,29 +80,6 @@ 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
@@ -317,7 +293,6 @@ const PromptEditor: FC<PromptEditorProps> = ({
<VariableValueBlock />
)
}
<ValueSyncPlugin value={value} />
<OnChangePlugin onChange={handleEditorChange} />
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
<UpdateBlock instanceId={instanceId} />
@@ -1,504 +0,0 @@
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 />,
}
@@ -1,421 +0,0 @@
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 />,
}
@@ -1,435 +0,0 @@
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,
},
}
@@ -1,527 +0,0 @@
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 />,
}

Some files were not shown because too many files have changed in this diff Show More