mirror of
https://github.com/langgenius/dify.git
synced 2026-05-18 07:56:36 +08:00
Compare commits
78 Commits
codex-add-
...
feat/marke
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d82921127 | |||
| f4970091a5 | |||
| 46578a6371 | |||
| 6d64b4c43d | |||
| 9cf2c9da5a | |||
| d7d0d297af | |||
| 521c145817 | |||
| 73f7b9daa6 | |||
| 6eb8433282 | |||
| 0b6bef394a | |||
| 197d8cec10 | |||
| b7549faacb | |||
| 5bbd268fc1 | |||
| 796ee3fee4 | |||
| c52f18f205 | |||
| 99cb5ea8c2 | |||
| 52a9905079 | |||
| ae7f8a7bde | |||
| f491d9daba | |||
| 5c31468567 | |||
| 22d22f2b77 | |||
| 98449de4f6 | |||
| 237eead38c | |||
| 61b6b838de | |||
| 7934b0222d | |||
| 39f4e205a8 | |||
| 2dba133d4d | |||
| ccb34e1020 | |||
| dc4d3de533 | |||
| efb35a97a1 | |||
| 0645eaeef9 | |||
| f49e8954d0 | |||
| a6fd5994ad | |||
| c3f52f8fa0 | |||
| b9ddd7047c | |||
| 600c373ef2 | |||
| 90c734cc93 | |||
| 5f827be44f | |||
| eff202834c | |||
| 0f4b578462 | |||
| e4f5c8f710 | |||
| 7ca4b7f3f9 | |||
| b1722ba53c | |||
| 594516da25 | |||
| 2cc86ae8cd | |||
| 27933ed4ae | |||
| ad1ebd9bbc | |||
| 791289dcf5 | |||
| 5d5a842f37 | |||
| 2d3e244a1f | |||
| 0db446b8ea | |||
| 8108c21d5b | |||
| 26fe8c1cc5 | |||
| 1f724d0f33 | |||
| 8e788714e4 | |||
| 5f6f9ed517 | |||
| 7b41fc4d64 | |||
| 36f42ec0a9 | |||
| 2b62862467 | |||
| 72e92be0cb | |||
| 4961242a8a | |||
| f348258a45 | |||
| 6ef87550e6 | |||
| 5c6da34539 | |||
| 9f8289b185 | |||
| 56c5739e4e | |||
| b241122cf7 | |||
| 984992d0fd | |||
| b9cd625c53 | |||
| f96fbbe03a | |||
| 9a698eaad9 | |||
| 6329647f3b | |||
| 063459599c | |||
| a59023f75b | |||
| 41c1d981a1 | |||
| 3f5037f911 | |||
| cbbb05c189 | |||
| 1ce8c43e2c |
1
.gitignore
vendored
1
.gitignore
vendored
@ -213,7 +213,6 @@ api/.vscode
|
||||
# pnpm
|
||||
/.pnpm-store
|
||||
/node_modules
|
||||
.vite-hooks/_
|
||||
|
||||
# plugin migrate
|
||||
plugins.jsonl
|
||||
|
||||
@ -593,15 +593,17 @@ class PublishedRagPipelineApi(Resource):
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
current_user, _ = current_account_with_tenant()
|
||||
rag_pipeline_service = RagPipelineService()
|
||||
workflow = rag_pipeline_service.publish_workflow(
|
||||
session=db.session, # type: ignore[reportArgumentType,arg-type]
|
||||
pipeline=pipeline,
|
||||
account=current_user,
|
||||
)
|
||||
pipeline.is_published = True
|
||||
pipeline.workflow_id = workflow.id
|
||||
db.session.commit()
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
pipeline = session.merge(pipeline)
|
||||
workflow = rag_pipeline_service.publish_workflow(
|
||||
session=session,
|
||||
pipeline=pipeline,
|
||||
account=current_user,
|
||||
)
|
||||
pipeline.is_published = True
|
||||
pipeline.workflow_id = workflow.id
|
||||
session.add(pipeline)
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
|
||||
@ -6,7 +6,7 @@ from flask import current_app, request
|
||||
from flask_login import user_logged_in
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from extensions.ext_database import db
|
||||
from libs.login import current_user
|
||||
@ -33,7 +33,7 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser:
|
||||
user_id = DefaultEndUserSessionID.DEFAULT_SESSION_ID
|
||||
is_anonymous = user_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID
|
||||
try:
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
with Session(db.engine) as session:
|
||||
user_model = None
|
||||
|
||||
if is_anonymous:
|
||||
@ -56,7 +56,7 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser:
|
||||
session_id=user_id,
|
||||
)
|
||||
session.add(user_model)
|
||||
session.flush()
|
||||
session.commit()
|
||||
session.refresh(user_model)
|
||||
|
||||
except Exception:
|
||||
|
||||
@ -3,7 +3,7 @@ from typing import Any, Literal
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
import services
|
||||
@ -116,7 +116,7 @@ class ConversationApi(Resource):
|
||||
last_id = str(query_args.last_id) if query_args.last_id else None
|
||||
|
||||
try:
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
with Session(db.engine) as session:
|
||||
pagination = ConversationService.pagination_by_last_id(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
|
||||
@ -8,7 +8,7 @@ from graphon.enums import WorkflowExecutionStatus
|
||||
from graphon.graph_engine.manager import GraphEngineManager
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
@ -314,7 +314,7 @@ class WorkflowAppLogApi(Resource):
|
||||
|
||||
# get paginate workflow app logs
|
||||
workflow_app_service = WorkflowAppService()
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
with Session(db.engine) as session:
|
||||
workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
|
||||
@ -2,7 +2,6 @@ import threading
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
from sqlalchemy import select
|
||||
|
||||
import contexts
|
||||
from core.app.app_config.easy_ui_based_app.agent.manager import AgentConfigManager
|
||||
@ -24,25 +23,25 @@ class AgentService:
|
||||
contexts.plugin_tool_providers.set({})
|
||||
contexts.plugin_tool_providers_lock.set(threading.Lock())
|
||||
|
||||
conversation: Conversation | None = db.session.scalar(
|
||||
select(Conversation)
|
||||
conversation: Conversation | None = (
|
||||
db.session.query(Conversation)
|
||||
.where(
|
||||
Conversation.id == conversation_id,
|
||||
Conversation.app_id == app_model.id,
|
||||
)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
raise ValueError(f"Conversation not found: {conversation_id}")
|
||||
|
||||
message: Message | None = db.session.scalar(
|
||||
select(Message)
|
||||
message: Message | None = (
|
||||
db.session.query(Message)
|
||||
.where(
|
||||
Message.id == message_id,
|
||||
Message.conversation_id == conversation_id,
|
||||
)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not message:
|
||||
@ -52,11 +51,16 @@ class AgentService:
|
||||
|
||||
if conversation.from_end_user_id:
|
||||
# only select name field
|
||||
executor_name = db.session.scalar(select(EndUser.name).where(EndUser.id == conversation.from_end_user_id))
|
||||
executor = (
|
||||
db.session.query(EndUser, EndUser.name).where(EndUser.id == conversation.from_end_user_id).first()
|
||||
)
|
||||
else:
|
||||
executor_name = db.session.scalar(select(Account.name).where(Account.id == conversation.from_account_id))
|
||||
executor = db.session.query(Account, Account.name).where(Account.id == conversation.from_account_id).first()
|
||||
|
||||
executor = executor_name or "Unknown"
|
||||
if executor:
|
||||
executor = executor.name
|
||||
else:
|
||||
executor = "Unknown"
|
||||
assert isinstance(current_user, Account)
|
||||
assert current_user.timezone is not None
|
||||
timezone = pytz.timezone(current_user.timezone)
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.extension.api_based_extension_requestor import APIBasedExtensionRequestor
|
||||
from core.helper.encrypter import decrypt_token, encrypt_token
|
||||
from extensions.ext_database import db
|
||||
@ -9,12 +7,11 @@ from models.api_based_extension import APIBasedExtension, APIBasedExtensionPoint
|
||||
class APIBasedExtensionService:
|
||||
@staticmethod
|
||||
def get_all_by_tenant_id(tenant_id: str) -> list[APIBasedExtension]:
|
||||
extension_list = list(
|
||||
db.session.scalars(
|
||||
select(APIBasedExtension)
|
||||
.where(APIBasedExtension.tenant_id == tenant_id)
|
||||
.order_by(APIBasedExtension.created_at.desc())
|
||||
).all()
|
||||
extension_list = (
|
||||
db.session.query(APIBasedExtension)
|
||||
.filter_by(tenant_id=tenant_id)
|
||||
.order_by(APIBasedExtension.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
for extension in extension_list:
|
||||
@ -39,10 +36,11 @@ class APIBasedExtensionService:
|
||||
|
||||
@staticmethod
|
||||
def get_with_tenant_id(tenant_id: str, api_based_extension_id: str) -> APIBasedExtension:
|
||||
extension = db.session.scalar(
|
||||
select(APIBasedExtension)
|
||||
.where(APIBasedExtension.tenant_id == tenant_id, APIBasedExtension.id == api_based_extension_id)
|
||||
.limit(1)
|
||||
extension = (
|
||||
db.session.query(APIBasedExtension)
|
||||
.filter_by(tenant_id=tenant_id)
|
||||
.filter_by(id=api_based_extension_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not extension:
|
||||
@ -60,27 +58,23 @@ class APIBasedExtensionService:
|
||||
|
||||
if not extension_data.id:
|
||||
# case one: check new data, name must be unique
|
||||
is_name_existed = db.session.scalar(
|
||||
select(APIBasedExtension)
|
||||
.where(
|
||||
APIBasedExtension.tenant_id == extension_data.tenant_id,
|
||||
APIBasedExtension.name == extension_data.name,
|
||||
)
|
||||
.limit(1)
|
||||
is_name_existed = (
|
||||
db.session.query(APIBasedExtension)
|
||||
.filter_by(tenant_id=extension_data.tenant_id)
|
||||
.filter_by(name=extension_data.name)
|
||||
.first()
|
||||
)
|
||||
|
||||
if is_name_existed:
|
||||
raise ValueError("name must be unique, it is already existed")
|
||||
else:
|
||||
# case two: check existing data, name must be unique
|
||||
is_name_existed = db.session.scalar(
|
||||
select(APIBasedExtension)
|
||||
.where(
|
||||
APIBasedExtension.tenant_id == extension_data.tenant_id,
|
||||
APIBasedExtension.name == extension_data.name,
|
||||
APIBasedExtension.id != extension_data.id,
|
||||
)
|
||||
.limit(1)
|
||||
is_name_existed = (
|
||||
db.session.query(APIBasedExtension)
|
||||
.filter_by(tenant_id=extension_data.tenant_id)
|
||||
.filter_by(name=extension_data.name)
|
||||
.where(APIBasedExtension.id != extension_data.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if is_name_existed:
|
||||
|
||||
@ -6,7 +6,6 @@ import sqlalchemy as sa
|
||||
from flask_sqlalchemy.pagination import Pagination
|
||||
from graphon.model_runtime.entities.model_entities import ModelPropertyKey, ModelType
|
||||
from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from constants.model_template import default_app_templates
|
||||
@ -434,7 +433,9 @@ class AppService:
|
||||
meta["tool_icons"][tool_name] = url_prefix + provider_id + "/icon"
|
||||
elif provider_type == "api":
|
||||
try:
|
||||
provider: ApiToolProvider | None = db.session.get(ApiToolProvider, provider_id)
|
||||
provider: ApiToolProvider | None = (
|
||||
db.session.query(ApiToolProvider).where(ApiToolProvider.id == provider_id).first()
|
||||
)
|
||||
if provider is None:
|
||||
raise ValueError(f"provider not found for tool {tool_name}")
|
||||
meta["tool_icons"][tool_name] = json.loads(provider.icon)
|
||||
@ -450,7 +451,7 @@ class AppService:
|
||||
:param app_id: app id
|
||||
:return: app code
|
||||
"""
|
||||
site = db.session.scalar(select(Site).where(Site.app_id == app_id).limit(1))
|
||||
site = db.session.query(Site).where(Site.app_id == app_id).first()
|
||||
if not site:
|
||||
raise ValueError(f"App with id {app_id} not found")
|
||||
return str(site.code)
|
||||
@ -462,7 +463,7 @@ class AppService:
|
||||
:param app_code: app code
|
||||
:return: app id
|
||||
"""
|
||||
site = db.session.scalar(select(Site).where(Site.code == app_code).limit(1))
|
||||
site = db.session.query(Site).where(Site.code == app_code).first()
|
||||
if not site:
|
||||
raise ValueError(f"App with code {app_code} not found")
|
||||
return str(site.app_id)
|
||||
|
||||
@ -4,7 +4,7 @@ import json
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Response
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import or_
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.enums import FeedbackRating
|
||||
@ -41,8 +41,8 @@ class FeedbackService:
|
||||
raise ValueError(f"Unsupported format: {format_type}")
|
||||
|
||||
# Build base query
|
||||
stmt = (
|
||||
select(MessageFeedback, Message, Conversation, App, Account)
|
||||
query = (
|
||||
db.session.query(MessageFeedback, Message, Conversation, App, Account)
|
||||
.join(Message, MessageFeedback.message_id == Message.id)
|
||||
.join(Conversation, MessageFeedback.conversation_id == Conversation.id)
|
||||
.join(App, MessageFeedback.app_id == App.id)
|
||||
@ -52,36 +52,36 @@ class FeedbackService:
|
||||
|
||||
# Apply filters
|
||||
if from_source:
|
||||
stmt = stmt.where(MessageFeedback.from_source == from_source)
|
||||
query = query.filter(MessageFeedback.from_source == from_source)
|
||||
|
||||
if rating:
|
||||
stmt = stmt.where(MessageFeedback.rating == rating)
|
||||
query = query.filter(MessageFeedback.rating == rating)
|
||||
|
||||
if has_comment is not None:
|
||||
if has_comment:
|
||||
stmt = stmt.where(MessageFeedback.content.isnot(None), MessageFeedback.content != "")
|
||||
query = query.filter(MessageFeedback.content.isnot(None), MessageFeedback.content != "")
|
||||
else:
|
||||
stmt = stmt.where(or_(MessageFeedback.content.is_(None), MessageFeedback.content == ""))
|
||||
query = query.filter(or_(MessageFeedback.content.is_(None), MessageFeedback.content == ""))
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
stmt = stmt.where(MessageFeedback.created_at >= start_dt)
|
||||
query = query.filter(MessageFeedback.created_at >= start_dt)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid start_date format: {start_date}. Use YYYY-MM-DD")
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
stmt = stmt.where(MessageFeedback.created_at <= end_dt)
|
||||
query = query.filter(MessageFeedback.created_at <= end_dt)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid end_date format: {end_date}. Use YYYY-MM-DD")
|
||||
|
||||
# Order by creation date (newest first)
|
||||
stmt = stmt.order_by(MessageFeedback.created_at.desc())
|
||||
query = query.order_by(MessageFeedback.created_at.desc())
|
||||
|
||||
# Execute query
|
||||
results = db.session.execute(stmt).all()
|
||||
results = query.all()
|
||||
|
||||
# Prepare data for export
|
||||
export_data = []
|
||||
|
||||
@ -3,7 +3,6 @@ from typing import Union
|
||||
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
|
||||
@ -76,15 +75,17 @@ class MessageService:
|
||||
fetch_limit = limit + 1
|
||||
|
||||
if first_id:
|
||||
first_message = db.session.scalar(
|
||||
select(Message).where(Message.conversation_id == conversation.id, Message.id == first_id).limit(1)
|
||||
first_message = (
|
||||
db.session.query(Message)
|
||||
.where(Message.conversation_id == conversation.id, Message.id == first_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not first_message:
|
||||
raise FirstMessageNotExistsError()
|
||||
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
history_messages = (
|
||||
db.session.query(Message)
|
||||
.where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.created_at < first_message.created_at,
|
||||
@ -92,14 +93,16 @@ class MessageService:
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(fetch_limit)
|
||||
).all()
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
history_messages = (
|
||||
db.session.query(Message)
|
||||
.where(Message.conversation_id == conversation.id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(fetch_limit)
|
||||
).all()
|
||||
.all()
|
||||
)
|
||||
|
||||
has_more = False
|
||||
if len(history_messages) > limit:
|
||||
@ -126,7 +129,7 @@ class MessageService:
|
||||
if not user:
|
||||
return InfiniteScrollPagination(data=[], limit=limit, has_more=False)
|
||||
|
||||
stmt = select(Message)
|
||||
base_query = db.session.query(Message)
|
||||
|
||||
fetch_limit = limit + 1
|
||||
|
||||
@ -135,27 +138,28 @@ class MessageService:
|
||||
app_model=app_model, user=user, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
stmt = stmt.where(Message.conversation_id == conversation.id)
|
||||
base_query = base_query.where(Message.conversation_id == conversation.id)
|
||||
|
||||
# Check if include_ids is not None and not empty to avoid WHERE false condition
|
||||
if include_ids is not None:
|
||||
if len(include_ids) == 0:
|
||||
return InfiniteScrollPagination(data=[], limit=limit, has_more=False)
|
||||
stmt = stmt.where(Message.id.in_(include_ids))
|
||||
base_query = base_query.where(Message.id.in_(include_ids))
|
||||
|
||||
if last_id:
|
||||
last_message = db.session.scalar(stmt.where(Message.id == last_id).limit(1))
|
||||
last_message = base_query.where(Message.id == last_id).first()
|
||||
|
||||
if not last_message:
|
||||
raise LastMessageNotExistsError()
|
||||
|
||||
history_messages = db.session.scalars(
|
||||
stmt.where(Message.created_at < last_message.created_at, Message.id != last_message.id)
|
||||
history_messages = (
|
||||
base_query.where(Message.created_at < last_message.created_at, Message.id != last_message.id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(fetch_limit)
|
||||
).all()
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
history_messages = db.session.scalars(stmt.order_by(Message.created_at.desc()).limit(fetch_limit)).all()
|
||||
history_messages = base_query.order_by(Message.created_at.desc()).limit(fetch_limit).all()
|
||||
|
||||
has_more = False
|
||||
if len(history_messages) > limit:
|
||||
@ -210,20 +214,21 @@ class MessageService:
|
||||
def get_all_messages_feedbacks(cls, app_model: App, page: int, limit: int):
|
||||
"""Get all feedbacks of an app"""
|
||||
offset = (page - 1) * limit
|
||||
feedbacks = db.session.scalars(
|
||||
select(MessageFeedback)
|
||||
feedbacks = (
|
||||
db.session.query(MessageFeedback)
|
||||
.where(MessageFeedback.app_id == app_model.id)
|
||||
.order_by(MessageFeedback.created_at.desc(), MessageFeedback.id.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
).all()
|
||||
.all()
|
||||
)
|
||||
|
||||
return [record.to_dict() for record in feedbacks]
|
||||
|
||||
@classmethod
|
||||
def get_message(cls, app_model: App, user: Union[Account, EndUser] | None, message_id: str):
|
||||
message = db.session.scalar(
|
||||
select(Message)
|
||||
message = (
|
||||
db.session.query(Message)
|
||||
.where(
|
||||
Message.id == message_id,
|
||||
Message.app_id == app_model.id,
|
||||
@ -231,7 +236,7 @@ class MessageService:
|
||||
Message.from_end_user_id == (user.id if isinstance(user, EndUser) else None),
|
||||
Message.from_account_id == (user.id if isinstance(user, Account) else None),
|
||||
)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not message:
|
||||
@ -277,10 +282,10 @@ class MessageService:
|
||||
)
|
||||
else:
|
||||
if not conversation.override_model_configs:
|
||||
app_model_config = db.session.scalar(
|
||||
select(AppModelConfig)
|
||||
app_model_config = (
|
||||
db.session.query(AppModelConfig)
|
||||
.where(AppModelConfig.id == conversation.app_model_config_id, AppModelConfig.app_id == app_model.id)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
conversation_override_model_configs = _app_model_config_adapter.validate_json(
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.ops.entities.config_entity import BaseTracingConfig
|
||||
from core.ops.ops_trace_manager import OpsTraceManager, provider_config_map
|
||||
from extensions.ext_database import db
|
||||
@ -17,17 +15,17 @@ class OpsService:
|
||||
:param tracing_provider: tracing provider
|
||||
:return:
|
||||
"""
|
||||
trace_config_data: TraceAppConfig | None = db.session.scalar(
|
||||
select(TraceAppConfig)
|
||||
trace_config_data: TraceAppConfig | None = (
|
||||
db.session.query(TraceAppConfig)
|
||||
.where(TraceAppConfig.app_id == app_id, TraceAppConfig.tracing_provider == tracing_provider)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not trace_config_data:
|
||||
return None
|
||||
|
||||
# decrypt_token and obfuscated_token
|
||||
app = db.session.get(App, app_id)
|
||||
app = db.session.query(App).where(App.id == app_id).first()
|
||||
if not app:
|
||||
return None
|
||||
tenant_id = app.tenant_id
|
||||
@ -184,17 +182,17 @@ class OpsService:
|
||||
project_url = None
|
||||
|
||||
# check if trace config already exists
|
||||
trace_config_data: TraceAppConfig | None = db.session.scalar(
|
||||
select(TraceAppConfig)
|
||||
trace_config_data: TraceAppConfig | None = (
|
||||
db.session.query(TraceAppConfig)
|
||||
.where(TraceAppConfig.app_id == app_id, TraceAppConfig.tracing_provider == tracing_provider)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if trace_config_data:
|
||||
return None
|
||||
|
||||
# get tenant id
|
||||
app = db.session.get(App, app_id)
|
||||
app = db.session.query(App).where(App.id == app_id).first()
|
||||
if not app:
|
||||
return None
|
||||
tenant_id = app.tenant_id
|
||||
@ -226,17 +224,17 @@ class OpsService:
|
||||
raise ValueError(f"Invalid tracing provider: {tracing_provider}")
|
||||
|
||||
# check if trace config already exists
|
||||
current_trace_config = db.session.scalar(
|
||||
select(TraceAppConfig)
|
||||
current_trace_config = (
|
||||
db.session.query(TraceAppConfig)
|
||||
.where(TraceAppConfig.app_id == app_id, TraceAppConfig.tracing_provider == tracing_provider)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not current_trace_config:
|
||||
return None
|
||||
|
||||
# get tenant id
|
||||
app = db.session.get(App, app_id)
|
||||
app = db.session.query(App).where(App.id == app_id).first()
|
||||
if not app:
|
||||
return None
|
||||
tenant_id = app.tenant_id
|
||||
@ -263,10 +261,10 @@ class OpsService:
|
||||
:param tracing_provider: tracing provider
|
||||
:return:
|
||||
"""
|
||||
trace_config = db.session.scalar(
|
||||
select(TraceAppConfig)
|
||||
trace_config = (
|
||||
db.session.query(TraceAppConfig)
|
||||
.where(TraceAppConfig.app_id == app_id, TraceAppConfig.tracing_provider == tracing_provider)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not trace_config:
|
||||
|
||||
@ -6,7 +6,6 @@ from uuid import uuid4
|
||||
|
||||
import yaml
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import select
|
||||
|
||||
from constants import DOCUMENT_EXTENSIONS
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
@ -27,7 +26,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class RagPipelineTransformService:
|
||||
def transform_dataset(self, dataset_id: str):
|
||||
dataset = db.session.get(Dataset, dataset_id)
|
||||
dataset = db.session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
if not dataset:
|
||||
raise ValueError("Dataset not found")
|
||||
if dataset.pipeline_id and dataset.runtime_mode == DatasetRuntimeMode.RAG_PIPELINE:
|
||||
@ -307,7 +306,7 @@ class RagPipelineTransformService:
|
||||
jina_node_id = "1752491761974"
|
||||
firecrawl_node_id = "1752565402678"
|
||||
|
||||
documents = db.session.scalars(select(Document).where(Document.dataset_id == dataset.id)).all()
|
||||
documents = db.session.query(Document).where(Document.dataset_id == dataset.id).all()
|
||||
|
||||
for document in documents:
|
||||
data_source_info_dict = document.data_source_info_dict
|
||||
@ -317,7 +316,7 @@ class RagPipelineTransformService:
|
||||
document.data_source_type = DataSourceType.LOCAL_FILE
|
||||
file_id = data_source_info_dict.get("upload_file_id")
|
||||
if file_id:
|
||||
file = db.session.get(UploadFile, file_id)
|
||||
file = db.session.query(UploadFile).where(UploadFile.id == file_id).first()
|
||||
if file:
|
||||
data_source_info = json.dumps(
|
||||
{
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from extensions.ext_database import db
|
||||
from models.model import AccountTrialAppRecord, TrialApp
|
||||
@ -29,7 +27,7 @@ class RecommendedAppService:
|
||||
apps = result["recommended_apps"]
|
||||
for app in apps:
|
||||
app_id = app["app_id"]
|
||||
trial_app_model = db.session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
|
||||
trial_app_model = db.session.query(TrialApp).where(TrialApp.app_id == app_id).first()
|
||||
if trial_app_model:
|
||||
app["can_trial"] = True
|
||||
else:
|
||||
@ -48,7 +46,7 @@ class RecommendedAppService:
|
||||
result: dict = retrieval_instance.get_recommend_app_detail(app_id)
|
||||
if FeatureService.get_system_features().enable_trial_app:
|
||||
app_id = result["id"]
|
||||
trial_app_model = db.session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
|
||||
trial_app_model = db.session.query(TrialApp).where(TrialApp.app_id == app_id).first()
|
||||
if trial_app_model:
|
||||
result["can_trial"] = True
|
||||
else:
|
||||
@ -62,10 +60,10 @@ class RecommendedAppService:
|
||||
:param app_id: app id
|
||||
:return:
|
||||
"""
|
||||
account_trial_app_record = db.session.scalar(
|
||||
select(AccountTrialAppRecord)
|
||||
account_trial_app_record = (
|
||||
db.session.query(AccountTrialAppRecord)
|
||||
.where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
if account_trial_app_record:
|
||||
account_trial_app_record.count += 1
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
from typing import Union
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from extensions.ext_database import db
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from models import Account
|
||||
@ -18,15 +16,16 @@ class SavedMessageService:
|
||||
) -> InfiniteScrollPagination:
|
||||
if not user:
|
||||
raise ValueError("User is required")
|
||||
saved_messages = db.session.scalars(
|
||||
select(SavedMessage)
|
||||
saved_messages = (
|
||||
db.session.query(SavedMessage)
|
||||
.where(
|
||||
SavedMessage.app_id == app_model.id,
|
||||
SavedMessage.created_by_role == ("account" if isinstance(user, Account) else "end_user"),
|
||||
SavedMessage.created_by == user.id,
|
||||
)
|
||||
.order_by(SavedMessage.created_at.desc())
|
||||
).all()
|
||||
.all()
|
||||
)
|
||||
message_ids = [sm.message_id for sm in saved_messages]
|
||||
|
||||
return MessageService.pagination_by_last_id(
|
||||
@ -37,15 +36,15 @@ class SavedMessageService:
|
||||
def save(cls, app_model: App, user: Union[Account, EndUser] | None, message_id: str):
|
||||
if not user:
|
||||
return
|
||||
saved_message = db.session.scalar(
|
||||
select(SavedMessage)
|
||||
saved_message = (
|
||||
db.session.query(SavedMessage)
|
||||
.where(
|
||||
SavedMessage.app_id == app_model.id,
|
||||
SavedMessage.message_id == message_id,
|
||||
SavedMessage.created_by_role == ("account" if isinstance(user, Account) else "end_user"),
|
||||
SavedMessage.created_by == user.id,
|
||||
)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if saved_message:
|
||||
@ -67,15 +66,15 @@ class SavedMessageService:
|
||||
def delete(cls, app_model: App, user: Union[Account, EndUser] | None, message_id: str):
|
||||
if not user:
|
||||
return
|
||||
saved_message = db.session.scalar(
|
||||
select(SavedMessage)
|
||||
saved_message = (
|
||||
db.session.query(SavedMessage)
|
||||
.where(
|
||||
SavedMessage.app_id == app_model.id,
|
||||
SavedMessage.message_id == message_id,
|
||||
SavedMessage.created_by_role == ("account" if isinstance(user, Account) else "end_user"),
|
||||
SavedMessage.created_by == user.id,
|
||||
)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not saved_message:
|
||||
|
||||
@ -332,11 +332,12 @@ class BuiltinToolManageService:
|
||||
get builtin tool provider credentials
|
||||
"""
|
||||
with db.session.no_autoflush:
|
||||
providers = db.session.scalars(
|
||||
select(BuiltinToolProvider)
|
||||
.where(BuiltinToolProvider.tenant_id == tenant_id, BuiltinToolProvider.provider == provider_name)
|
||||
providers = (
|
||||
db.session.query(BuiltinToolProvider)
|
||||
.filter_by(tenant_id=tenant_id, provider=provider_name)
|
||||
.order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc())
|
||||
).all()
|
||||
.all()
|
||||
)
|
||||
|
||||
if len(providers) == 0:
|
||||
return []
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import logging
|
||||
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from core.rag.datasource.keyword.keyword_factory import Keyword
|
||||
@ -30,7 +29,7 @@ class VectorService:
|
||||
|
||||
for segment in segments:
|
||||
if doc_form == IndexStructureType.PARENT_CHILD_INDEX:
|
||||
dataset_document = db.session.get(DatasetDocument, segment.document_id)
|
||||
dataset_document = db.session.query(DatasetDocument).filter_by(id=segment.document_id).first()
|
||||
if not dataset_document:
|
||||
logger.warning(
|
||||
"Expected DatasetDocument record to exist, but none was found, document_id=%s, segment_id=%s",
|
||||
@ -39,7 +38,11 @@ class VectorService:
|
||||
)
|
||||
continue
|
||||
# get the process rule
|
||||
processing_rule = db.session.get(DatasetProcessRule, dataset_document.dataset_process_rule_id)
|
||||
processing_rule = (
|
||||
db.session.query(DatasetProcessRule)
|
||||
.where(DatasetProcessRule.id == dataset_document.dataset_process_rule_id)
|
||||
.first()
|
||||
)
|
||||
if not processing_rule:
|
||||
raise ValueError("No processing rule found.")
|
||||
# get embedding model instance
|
||||
@ -268,8 +271,8 @@ class VectorService:
|
||||
vector.delete_by_ids(old_attachment_ids)
|
||||
|
||||
# Delete existing segment attachment bindings in one operation
|
||||
db.session.execute(
|
||||
delete(SegmentAttachmentBinding).where(SegmentAttachmentBinding.segment_id == segment.id)
|
||||
db.session.query(SegmentAttachmentBinding).where(SegmentAttachmentBinding.segment_id == segment.id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
if not attachment_ids:
|
||||
@ -277,7 +280,7 @@ class VectorService:
|
||||
return
|
||||
|
||||
# Bulk fetch upload files - only fetch needed fields
|
||||
upload_file_list = db.session.scalars(select(UploadFile).where(UploadFile.id.in_(attachment_ids))).all()
|
||||
upload_file_list = db.session.query(UploadFile).where(UploadFile.id.in_(attachment_ids)).all()
|
||||
|
||||
if not upload_file_list:
|
||||
db.session.commit()
|
||||
|
||||
@ -138,14 +138,14 @@ class WorkflowService:
|
||||
if workflow_id:
|
||||
return self.get_published_workflow_by_id(app_model, workflow_id)
|
||||
# fetch draft workflow by app_model
|
||||
workflow = db.session.scalar(
|
||||
select(Workflow)
|
||||
workflow = (
|
||||
db.session.query(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
Workflow.app_id == app_model.id,
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
# return draft workflow
|
||||
@ -155,14 +155,14 @@ class WorkflowService:
|
||||
"""
|
||||
fetch published workflow by workflow_id
|
||||
"""
|
||||
workflow = db.session.scalar(
|
||||
select(Workflow)
|
||||
workflow = (
|
||||
db.session.query(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
Workflow.app_id == app_model.id,
|
||||
Workflow.id == workflow_id,
|
||||
)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
if not workflow:
|
||||
return None
|
||||
@ -182,14 +182,14 @@ class WorkflowService:
|
||||
return None
|
||||
|
||||
# fetch published workflow by workflow_id
|
||||
workflow = db.session.scalar(
|
||||
select(Workflow)
|
||||
workflow = (
|
||||
db.session.query(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
Workflow.app_id == app_model.id,
|
||||
Workflow.id == app_model.workflow_id,
|
||||
)
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
return workflow
|
||||
@ -544,14 +544,14 @@ class WorkflowService:
|
||||
|
||||
# Use the same fallback logic as runtime: get the first available credential
|
||||
# ordered by is_default DESC, created_at ASC (same as tool_manager.py)
|
||||
default_provider = db.session.scalar(
|
||||
select(BuiltinToolProvider)
|
||||
default_provider = (
|
||||
db.session.query(BuiltinToolProvider)
|
||||
.where(
|
||||
BuiltinToolProvider.tenant_id == tenant_id,
|
||||
BuiltinToolProvider.provider == provider,
|
||||
)
|
||||
.order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc())
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not default_provider:
|
||||
|
||||
@ -99,7 +99,7 @@ class TestFeedbackService:
|
||||
)
|
||||
]
|
||||
|
||||
mock_db_session.execute.return_value = mock_query
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
# Test CSV export
|
||||
result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv")
|
||||
@ -138,7 +138,7 @@ class TestFeedbackService:
|
||||
)
|
||||
]
|
||||
|
||||
mock_db_session.execute.return_value = mock_query
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
# Test JSON export
|
||||
result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json")
|
||||
@ -175,7 +175,7 @@ class TestFeedbackService:
|
||||
)
|
||||
]
|
||||
|
||||
mock_db_session.execute.return_value = mock_query
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
# Test with filters
|
||||
result = FeedbackService.export_feedbacks(
|
||||
@ -188,8 +188,11 @@ class TestFeedbackService:
|
||||
format_type="csv",
|
||||
)
|
||||
|
||||
# Verify query was executed (filters are baked into the select statement)
|
||||
assert mock_db_session.execute.called
|
||||
# Verify filters were applied
|
||||
assert mock_query.filter.called
|
||||
filter_calls = mock_query.filter.call_args_list
|
||||
# At least three filter invocations are expected (source, rating, comment)
|
||||
assert len(filter_calls) >= 3
|
||||
|
||||
def test_export_feedbacks_no_data(self, mock_db_session, sample_data):
|
||||
"""Test exporting feedback when no data exists."""
|
||||
@ -203,7 +206,7 @@ class TestFeedbackService:
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.all.return_value = []
|
||||
|
||||
mock_db_session.execute.return_value = mock_query
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv")
|
||||
|
||||
@ -268,7 +271,7 @@ class TestFeedbackService:
|
||||
)
|
||||
]
|
||||
|
||||
mock_db_session.execute.return_value = mock_query
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
# Test export
|
||||
result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json")
|
||||
@ -326,7 +329,7 @@ class TestFeedbackService:
|
||||
)
|
||||
]
|
||||
|
||||
mock_db_session.execute.return_value = mock_query
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
# Test export
|
||||
result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv")
|
||||
@ -364,7 +367,7 @@ class TestFeedbackService:
|
||||
),
|
||||
]
|
||||
|
||||
mock_db_session.execute.return_value = mock_query
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
# Test export
|
||||
result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json")
|
||||
|
||||
@ -41,15 +41,15 @@ class TestGetUser:
|
||||
"""Test get_user function"""
|
||||
|
||||
@patch("controllers.inner_api.plugin.wraps.EndUser")
|
||||
@patch("controllers.inner_api.plugin.wraps.sessionmaker")
|
||||
@patch("controllers.inner_api.plugin.wraps.Session")
|
||||
@patch("controllers.inner_api.plugin.wraps.db")
|
||||
def test_should_return_existing_user_by_id(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask):
|
||||
def test_should_return_existing_user_by_id(self, mock_db, mock_session_class, mock_enduser_class, app: Flask):
|
||||
"""Test returning existing user when found by ID"""
|
||||
# Arrange
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "user123"
|
||||
mock_session = MagicMock()
|
||||
mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
mock_session.get.return_value = mock_user
|
||||
|
||||
# Act
|
||||
@ -61,17 +61,17 @@ class TestGetUser:
|
||||
mock_session.get.assert_called_once()
|
||||
|
||||
@patch("controllers.inner_api.plugin.wraps.EndUser")
|
||||
@patch("controllers.inner_api.plugin.wraps.sessionmaker")
|
||||
@patch("controllers.inner_api.plugin.wraps.Session")
|
||||
@patch("controllers.inner_api.plugin.wraps.db")
|
||||
def test_should_return_existing_anonymous_user_by_session_id(
|
||||
self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask
|
||||
self, mock_db, mock_session_class, mock_enduser_class, app: Flask
|
||||
):
|
||||
"""Test returning existing anonymous user by session_id"""
|
||||
# Arrange
|
||||
mock_user = MagicMock()
|
||||
mock_user.session_id = "anonymous_session"
|
||||
mock_session = MagicMock()
|
||||
mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
# non-anonymous path uses session.get(); anonymous uses session.scalar()
|
||||
mock_session.get.return_value = mock_user
|
||||
|
||||
@ -83,13 +83,13 @@ class TestGetUser:
|
||||
assert result == mock_user
|
||||
|
||||
@patch("controllers.inner_api.plugin.wraps.EndUser")
|
||||
@patch("controllers.inner_api.plugin.wraps.sessionmaker")
|
||||
@patch("controllers.inner_api.plugin.wraps.Session")
|
||||
@patch("controllers.inner_api.plugin.wraps.db")
|
||||
def test_should_create_new_user_when_not_found(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask):
|
||||
def test_should_create_new_user_when_not_found(self, mock_db, mock_session_class, mock_enduser_class, app: Flask):
|
||||
"""Test creating new user when not found in database"""
|
||||
# Arrange
|
||||
mock_session = MagicMock()
|
||||
mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
mock_session.get.return_value = None
|
||||
mock_new_user = MagicMock()
|
||||
mock_enduser_class.return_value = mock_new_user
|
||||
@ -101,20 +101,21 @@ class TestGetUser:
|
||||
# Assert
|
||||
assert result == mock_new_user
|
||||
mock_session.add.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_session.refresh.assert_called_once()
|
||||
|
||||
@patch("controllers.inner_api.plugin.wraps.select")
|
||||
@patch("controllers.inner_api.plugin.wraps.EndUser")
|
||||
@patch("controllers.inner_api.plugin.wraps.sessionmaker")
|
||||
@patch("controllers.inner_api.plugin.wraps.Session")
|
||||
@patch("controllers.inner_api.plugin.wraps.db")
|
||||
def test_should_use_default_session_id_when_user_id_none(
|
||||
self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask
|
||||
self, mock_db, mock_session_class, mock_enduser_class, mock_select, app: Flask
|
||||
):
|
||||
"""Test using default session ID when user_id is None"""
|
||||
# Arrange
|
||||
mock_user = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
# When user_id is None, is_anonymous=True, so session.scalar() is used
|
||||
mock_session.scalar.return_value = mock_user
|
||||
|
||||
@ -126,13 +127,15 @@ class TestGetUser:
|
||||
assert result == mock_user
|
||||
|
||||
@patch("controllers.inner_api.plugin.wraps.EndUser")
|
||||
@patch("controllers.inner_api.plugin.wraps.sessionmaker")
|
||||
@patch("controllers.inner_api.plugin.wraps.Session")
|
||||
@patch("controllers.inner_api.plugin.wraps.db")
|
||||
def test_should_raise_error_on_database_exception(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask):
|
||||
def test_should_raise_error_on_database_exception(
|
||||
self, mock_db, mock_session_class, mock_enduser_class, app: Flask
|
||||
):
|
||||
"""Test raising ValueError when database operation fails"""
|
||||
# Arrange
|
||||
mock_session = MagicMock()
|
||||
mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
mock_session.get.side_effect = Exception("Database error")
|
||||
|
||||
# Act & Assert
|
||||
|
||||
@ -433,20 +433,13 @@ class TestConversationApiController:
|
||||
handler(api, app_model=app_model, end_user=end_user)
|
||||
|
||||
def test_list_last_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _BeginStub:
|
||||
class _SessionStub:
|
||||
def __enter__(self):
|
||||
return SimpleNamespace()
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
class _SessionMakerStub:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def begin(self):
|
||||
return _BeginStub()
|
||||
|
||||
monkeypatch.setattr(
|
||||
ConversationService,
|
||||
"pagination_by_last_id",
|
||||
@ -454,7 +447,7 @@ class TestConversationApiController:
|
||||
)
|
||||
conversation_module = sys.modules["controllers.service_api.app.conversation"]
|
||||
monkeypatch.setattr(conversation_module, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(conversation_module, "sessionmaker", _SessionMakerStub)
|
||||
monkeypatch.setattr(conversation_module, "Session", lambda *_args, **_kwargs: _SessionStub())
|
||||
|
||||
api = ConversationApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
@ -470,23 +470,16 @@ class TestWorkflowTaskStopApi:
|
||||
|
||||
class TestWorkflowAppLogApi:
|
||||
def test_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _BeginStub:
|
||||
class _SessionStub:
|
||||
def __enter__(self):
|
||||
return SimpleNamespace()
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
class _SessionMakerStub:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def begin(self):
|
||||
return _BeginStub()
|
||||
|
||||
workflow_module = sys.modules["controllers.service_api.app.workflow"]
|
||||
monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(workflow_module, "sessionmaker", _SessionMakerStub)
|
||||
monkeypatch.setattr(workflow_module, "Session", lambda *_args, **_kwargs: _SessionStub())
|
||||
monkeypatch.setattr(
|
||||
WorkflowAppService,
|
||||
"get_paginate_workflow_app_logs",
|
||||
@ -642,14 +635,11 @@ class TestWorkflowAppLogApiGet:
|
||||
mock_svc_instance.get_paginate_workflow_app_logs.return_value = mock_pagination
|
||||
mock_wf_svc_cls.return_value = mock_svc_instance
|
||||
|
||||
# Mock sessionmaker(...).begin() context manager
|
||||
# Mock Session context manager
|
||||
mock_session = Mock()
|
||||
mock_db.engine = Mock()
|
||||
mock_begin = Mock()
|
||||
mock_begin.__enter__ = Mock(return_value=mock_session)
|
||||
mock_begin.__exit__ = Mock(return_value=False)
|
||||
mock_session_factory = Mock()
|
||||
mock_session_factory.begin.return_value = mock_begin
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=False)
|
||||
|
||||
from controllers.service_api.app.workflow import WorkflowAppLogApi
|
||||
|
||||
@ -657,7 +647,7 @@ class TestWorkflowAppLogApiGet:
|
||||
"/workflows/logs?page=1&limit=20",
|
||||
method="GET",
|
||||
):
|
||||
with patch("controllers.service_api.app.workflow.sessionmaker", return_value=mock_session_factory):
|
||||
with patch("controllers.service_api.app.workflow.Session", return_value=mock_session):
|
||||
api = WorkflowAppLogApi()
|
||||
result = _unwrap(api.get)(api, app_model=mock_workflow_app)
|
||||
|
||||
|
||||
@ -151,7 +151,12 @@ class TestMessageServicePaginationByFirstId:
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
mock_db.session.scalars.return_value.all.return_value = messages
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = messages
|
||||
|
||||
# Act
|
||||
result = MessageService.pagination_by_first_id(
|
||||
@ -191,7 +196,12 @@ class TestMessageServicePaginationByFirstId:
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
mock_db.session.scalars.return_value.all.return_value = messages
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = messages
|
||||
|
||||
# Act
|
||||
result = MessageService.pagination_by_first_id(
|
||||
@ -236,8 +246,31 @@ class TestMessageServicePaginationByFirstId:
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
mock_db.session.scalar.return_value = first_message
|
||||
mock_db.session.scalars.return_value.all.return_value = history_messages
|
||||
# Setup query mocks
|
||||
mock_query_first = MagicMock()
|
||||
mock_query_history = MagicMock()
|
||||
|
||||
query_calls = []
|
||||
|
||||
def query_side_effect(*args):
|
||||
if args[0] == Message:
|
||||
query_calls.append(args)
|
||||
if len(query_calls) == 1:
|
||||
return mock_query_first
|
||||
else:
|
||||
return mock_query_history
|
||||
|
||||
mock_db.session.query.side_effect = [mock_query_first, mock_query_history]
|
||||
|
||||
# Setup first message query
|
||||
mock_query_first.where.return_value = mock_query_first
|
||||
mock_query_first.first.return_value = first_message
|
||||
|
||||
# Setup history messages query
|
||||
mock_query_history.where.return_value = mock_query_history
|
||||
mock_query_history.order_by.return_value = mock_query_history
|
||||
mock_query_history.limit.return_value = mock_query_history
|
||||
mock_query_history.all.return_value = history_messages
|
||||
|
||||
# Act
|
||||
result = MessageService.pagination_by_first_id(
|
||||
@ -252,6 +285,8 @@ class TestMessageServicePaginationByFirstId:
|
||||
# Assert
|
||||
assert len(result.data) == 5
|
||||
assert result.has_more is False
|
||||
mock_query_first.where.assert_called_once()
|
||||
mock_query_history.where.assert_called_once()
|
||||
|
||||
# Test 06: First message not found
|
||||
@patch("services.message_service.db")
|
||||
@ -265,7 +300,10 @@ class TestMessageServicePaginationByFirstId:
|
||||
|
||||
mock_conversation_service.get_conversation.return_value = conversation
|
||||
|
||||
mock_db.session.scalar.return_value = None # Message not found
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = None # Message not found
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(FirstMessageNotExistsError):
|
||||
@ -298,7 +336,12 @@ class TestMessageServicePaginationByFirstId:
|
||||
for i in range(11)
|
||||
]
|
||||
|
||||
mock_db.session.scalars.return_value.all.return_value = messages
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = messages
|
||||
|
||||
# Act
|
||||
result = MessageService.pagination_by_first_id(
|
||||
@ -326,7 +369,12 @@ class TestMessageServicePaginationByFirstId:
|
||||
|
||||
mock_conversation_service.get_conversation.return_value = conversation
|
||||
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = []
|
||||
|
||||
# Act
|
||||
result = MessageService.pagination_by_first_id(
|
||||
@ -395,7 +443,12 @@ class TestMessageServicePaginationByLastId:
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
mock_db.session.scalars.return_value.all.return_value = messages
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = messages
|
||||
|
||||
# Act
|
||||
result = MessageService.pagination_by_last_id(
|
||||
@ -432,8 +485,22 @@ class TestMessageServicePaginationByLastId:
|
||||
for i in range(6, 10)
|
||||
]
|
||||
|
||||
mock_db.session.scalar.return_value = last_message
|
||||
mock_db.session.scalars.return_value.all.return_value = new_messages
|
||||
# Setup base query mock that returns itself for chaining
|
||||
mock_base_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_base_query
|
||||
|
||||
# First where() call for last_id lookup
|
||||
mock_query_last = MagicMock()
|
||||
mock_query_last.first.return_value = last_message
|
||||
|
||||
# Second where() call for history messages
|
||||
mock_query_history = MagicMock()
|
||||
mock_query_history.order_by.return_value = mock_query_history
|
||||
mock_query_history.limit.return_value = mock_query_history
|
||||
mock_query_history.all.return_value = new_messages
|
||||
|
||||
# Setup where() to return different mocks on consecutive calls
|
||||
mock_base_query.where.side_effect = [mock_query_last, mock_query_history]
|
||||
|
||||
# Act
|
||||
result = MessageService.pagination_by_last_id(
|
||||
@ -455,7 +522,10 @@ class TestMessageServicePaginationByLastId:
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_end_user_mock()
|
||||
|
||||
mock_db.session.scalar.return_value = None # Message not found
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = None # Message not found
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(LastMessageNotExistsError):
|
||||
@ -487,7 +557,12 @@ class TestMessageServicePaginationByLastId:
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
mock_db.session.scalars.return_value.all.return_value = messages
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = messages
|
||||
|
||||
# Act
|
||||
result = MessageService.pagination_by_last_id(
|
||||
@ -501,6 +576,8 @@ class TestMessageServicePaginationByLastId:
|
||||
# Assert
|
||||
assert len(result.data) == 5
|
||||
assert result.has_more is False
|
||||
# Verify conversation_id was used in query
|
||||
mock_query.where.assert_called()
|
||||
mock_conversation_service.get_conversation.assert_called_once()
|
||||
|
||||
# Test 14: Pagination with include_ids filter
|
||||
@ -517,7 +594,12 @@ class TestMessageServicePaginationByLastId:
|
||||
factory.create_message_mock(message_id="msg-003"),
|
||||
]
|
||||
|
||||
mock_db.session.scalars.return_value.all.return_value = messages
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = messages
|
||||
|
||||
# Act
|
||||
result = MessageService.pagination_by_last_id(
|
||||
@ -550,7 +632,12 @@ class TestMessageServicePaginationByLastId:
|
||||
for i in range(11)
|
||||
]
|
||||
|
||||
mock_db.session.scalars.return_value.all.return_value = messages
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = messages
|
||||
|
||||
# Act
|
||||
result = MessageService.pagination_by_last_id(
|
||||
@ -656,13 +743,17 @@ class TestMessageServiceGetMessage:
|
||||
user = factory.create_end_user_mock(user_id="end-user-123")
|
||||
message = factory.create_message_mock()
|
||||
|
||||
mock_db.session.scalar.return_value = message
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = message
|
||||
|
||||
# Act
|
||||
result = MessageService.get_message(app_model=app, user=user, message_id="msg-123")
|
||||
|
||||
# Assert
|
||||
assert result == message
|
||||
mock_query.where.assert_called_once()
|
||||
|
||||
# Test 21: get_message success for Account (Admin)
|
||||
@patch("services.message_service.db")
|
||||
@ -676,7 +767,10 @@ class TestMessageServiceGetMessage:
|
||||
user.id = "account-123"
|
||||
message = factory.create_message_mock()
|
||||
|
||||
mock_db.session.scalar.return_value = message
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = message
|
||||
|
||||
# Act
|
||||
result = MessageService.get_message(app_model=app, user=user, message_id="msg-123")
|
||||
@ -692,7 +786,10 @@ class TestMessageServiceGetMessage:
|
||||
app = factory.create_app_mock()
|
||||
user = factory.create_end_user_mock()
|
||||
|
||||
mock_db.session.scalar.return_value = None
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(MessageNotExistsError):
|
||||
@ -802,13 +899,21 @@ class TestMessageServiceFeedback:
|
||||
feedback = MagicMock()
|
||||
feedback.to_dict.return_value = {"id": "fb-1"}
|
||||
|
||||
mock_db.session.scalars.return_value.all.return_value = [feedback]
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.all.return_value = [feedback]
|
||||
|
||||
# Act
|
||||
result = MessageService.get_all_messages_feedbacks(app_model=app, page=1, limit=10)
|
||||
|
||||
# Assert
|
||||
assert result == [{"id": "fb-1"}]
|
||||
mock_query.limit.assert_called_with(10)
|
||||
mock_query.offset.assert_called_with(0)
|
||||
|
||||
|
||||
class TestMessageServiceSuggestedQuestions:
|
||||
@ -910,7 +1015,10 @@ class TestMessageServiceSuggestedQuestions:
|
||||
app_model_config.suggested_questions_after_answer_dict = {"enabled": True}
|
||||
app_model_config.model_dict = {"provider": "openai", "name": "gpt-4"}
|
||||
|
||||
mock_db.session.scalar.return_value = app_model_config
|
||||
mock_query = MagicMock()
|
||||
mock_db.session.query.return_value = mock_query
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.first.return_value = app_model_config
|
||||
|
||||
mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"]
|
||||
|
||||
@ -921,6 +1029,7 @@ class TestMessageServiceSuggestedQuestions:
|
||||
|
||||
# Assert
|
||||
assert result == ["Q1?"]
|
||||
mock_query.first.assert_called_once()
|
||||
mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once()
|
||||
|
||||
# Test 30: get_suggested_questions_after_answer - Disabled Error
|
||||
|
||||
@ -12,27 +12,28 @@ class TestOpsService:
|
||||
@patch("services.ops_service.OpsTraceManager")
|
||||
def test_get_tracing_app_config_no_config(self, mock_ops_trace_manager, mock_db):
|
||||
# Arrange
|
||||
mock_db.session.scalar.return_value = None
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
|
||||
# Act
|
||||
result = OpsService.get_tracing_app_config("app_id", "arize")
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_db.session.query.assert_called_with(TraceAppConfig)
|
||||
|
||||
@patch("services.ops_service.db")
|
||||
@patch("services.ops_service.OpsTraceManager")
|
||||
def test_get_tracing_app_config_no_app(self, mock_ops_trace_manager, mock_db):
|
||||
# Arrange
|
||||
trace_config = MagicMock(spec=TraceAppConfig)
|
||||
mock_db.session.scalar.return_value = trace_config
|
||||
mock_db.session.get.return_value = None
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [trace_config, None]
|
||||
|
||||
# Act
|
||||
result = OpsService.get_tracing_app_config("app_id", "arize")
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
assert mock_db.session.query.call_count == 2
|
||||
|
||||
@patch("services.ops_service.db")
|
||||
@patch("services.ops_service.OpsTraceManager")
|
||||
@ -42,8 +43,7 @@ class TestOpsService:
|
||||
trace_config.tracing_config = None
|
||||
app = MagicMock(spec=App)
|
||||
app.tenant_id = "tenant_id"
|
||||
mock_db.session.scalar.return_value = trace_config
|
||||
mock_db.session.get.return_value = app
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [trace_config, app]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError, match="Tracing config cannot be None."):
|
||||
@ -72,8 +72,7 @@ class TestOpsService:
|
||||
trace_config.to_dict.return_value = {"tracing_config": {"project_url": default_url}}
|
||||
app = MagicMock(spec=App)
|
||||
app.tenant_id = "tenant_id"
|
||||
mock_db.session.scalar.return_value = trace_config
|
||||
mock_db.session.get.return_value = app
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [trace_config, app]
|
||||
|
||||
mock_ops_trace_manager.decrypt_tracing_config.return_value = {}
|
||||
mock_ops_trace_manager.obfuscated_decrypt_token.return_value = {}
|
||||
@ -98,8 +97,7 @@ class TestOpsService:
|
||||
trace_config.to_dict.return_value = {"tracing_config": {"project_url": "success_url"}}
|
||||
app = MagicMock(spec=App)
|
||||
app.tenant_id = "tenant_id"
|
||||
mock_db.session.scalar.return_value = trace_config
|
||||
mock_db.session.get.return_value = app
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [trace_config, app]
|
||||
|
||||
mock_ops_trace_manager.decrypt_tracing_config.return_value = {}
|
||||
mock_ops_trace_manager.obfuscated_decrypt_token.return_value = {}
|
||||
@ -120,8 +118,7 @@ class TestOpsService:
|
||||
trace_config.to_dict.return_value = {"tracing_config": {"project_url": "https://api.langfuse.com/project/key"}}
|
||||
app = MagicMock(spec=App)
|
||||
app.tenant_id = "tenant_id"
|
||||
mock_db.session.scalar.return_value = trace_config
|
||||
mock_db.session.get.return_value = app
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [trace_config, app]
|
||||
|
||||
mock_ops_trace_manager.decrypt_tracing_config.return_value = {"host": "https://api.langfuse.com"}
|
||||
mock_ops_trace_manager.obfuscated_decrypt_token.return_value = {"host": "https://api.langfuse.com"}
|
||||
@ -142,8 +139,7 @@ class TestOpsService:
|
||||
trace_config.to_dict.return_value = {"tracing_config": {"project_url": "https://api.langfuse.com/"}}
|
||||
app = MagicMock(spec=App)
|
||||
app.tenant_id = "tenant_id"
|
||||
mock_db.session.scalar.return_value = trace_config
|
||||
mock_db.session.get.return_value = app
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [trace_config, app]
|
||||
|
||||
mock_ops_trace_manager.decrypt_tracing_config.return_value = {"host": "https://api.langfuse.com"}
|
||||
mock_ops_trace_manager.obfuscated_decrypt_token.return_value = {"host": "https://api.langfuse.com"}
|
||||
@ -193,7 +189,7 @@ class TestOpsService:
|
||||
mock_ops_trace_manager.check_trace_config_is_effective.return_value = True
|
||||
mock_ops_trace_manager.get_trace_config_project_url.side_effect = Exception("error")
|
||||
mock_ops_trace_manager.get_trace_config_project_key.side_effect = Exception("error")
|
||||
mock_db.session.scalar.return_value = MagicMock(spec=TraceAppConfig)
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock(spec=TraceAppConfig)
|
||||
|
||||
# Act
|
||||
result = OpsService.create_tracing_app_config("app_id", provider, config)
|
||||
@ -210,8 +206,7 @@ class TestOpsService:
|
||||
mock_ops_trace_manager.get_trace_config_project_key.return_value = "key"
|
||||
app = MagicMock(spec=App)
|
||||
app.tenant_id = "tenant_id"
|
||||
mock_db.session.scalar.return_value = None
|
||||
mock_db.session.get.return_value = app
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [None, app]
|
||||
mock_ops_trace_manager.encrypt_tracing_config.return_value = {}
|
||||
|
||||
# Act
|
||||
@ -228,7 +223,7 @@ class TestOpsService:
|
||||
# Arrange
|
||||
provider = TracingProviderEnum.ARIZE
|
||||
mock_ops_trace_manager.check_trace_config_is_effective.return_value = True
|
||||
mock_db.session.scalar.return_value = MagicMock(spec=TraceAppConfig)
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock(spec=TraceAppConfig)
|
||||
|
||||
# Act
|
||||
result = OpsService.create_tracing_app_config("app_id", provider, {})
|
||||
@ -242,8 +237,7 @@ class TestOpsService:
|
||||
# Arrange
|
||||
provider = TracingProviderEnum.ARIZE
|
||||
mock_ops_trace_manager.check_trace_config_is_effective.return_value = True
|
||||
mock_db.session.scalar.return_value = None
|
||||
mock_db.session.get.return_value = None
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [None, None]
|
||||
|
||||
# Act
|
||||
result = OpsService.create_tracing_app_config("app_id", provider, {})
|
||||
@ -259,8 +253,7 @@ class TestOpsService:
|
||||
mock_ops_trace_manager.check_trace_config_is_effective.return_value = True
|
||||
app = MagicMock(spec=App)
|
||||
app.tenant_id = "tenant_id"
|
||||
mock_db.session.scalar.return_value = None
|
||||
mock_db.session.get.return_value = app
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [None, app]
|
||||
mock_ops_trace_manager.encrypt_tracing_config.return_value = {}
|
||||
|
||||
# Act
|
||||
@ -281,8 +274,7 @@ class TestOpsService:
|
||||
mock_ops_trace_manager.get_trace_config_project_url.return_value = "http://project_url"
|
||||
app = MagicMock(spec=App)
|
||||
app.tenant_id = "tenant_id"
|
||||
mock_db.session.scalar.return_value = None
|
||||
mock_db.session.get.return_value = app
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [None, app]
|
||||
mock_ops_trace_manager.encrypt_tracing_config.return_value = {"encrypted": "config"}
|
||||
|
||||
# Act
|
||||
@ -305,7 +297,7 @@ class TestOpsService:
|
||||
def test_update_tracing_app_config_no_config(self, mock_ops_trace_manager, mock_db):
|
||||
# Arrange
|
||||
provider = TracingProviderEnum.ARIZE
|
||||
mock_db.session.scalar.return_value = None
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
|
||||
# Act
|
||||
result = OpsService.update_tracing_app_config("app_id", provider, {})
|
||||
@ -319,8 +311,7 @@ class TestOpsService:
|
||||
# Arrange
|
||||
provider = TracingProviderEnum.ARIZE
|
||||
current_config = MagicMock(spec=TraceAppConfig)
|
||||
mock_db.session.scalar.return_value = current_config
|
||||
mock_db.session.get.return_value = None
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [current_config, None]
|
||||
|
||||
# Act
|
||||
result = OpsService.update_tracing_app_config("app_id", provider, {})
|
||||
@ -336,8 +327,7 @@ class TestOpsService:
|
||||
current_config = MagicMock(spec=TraceAppConfig)
|
||||
app = MagicMock(spec=App)
|
||||
app.tenant_id = "tenant_id"
|
||||
mock_db.session.scalar.return_value = current_config
|
||||
mock_db.session.get.return_value = app
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [current_config, app]
|
||||
mock_ops_trace_manager.decrypt_tracing_config.return_value = {}
|
||||
mock_ops_trace_manager.check_trace_config_is_effective.return_value = False
|
||||
|
||||
@ -354,8 +344,7 @@ class TestOpsService:
|
||||
current_config.to_dict.return_value = {"some": "data"}
|
||||
app = MagicMock(spec=App)
|
||||
app.tenant_id = "tenant_id"
|
||||
mock_db.session.scalar.return_value = current_config
|
||||
mock_db.session.get.return_value = app
|
||||
mock_db.session.query.return_value.where.return_value.first.side_effect = [current_config, app]
|
||||
mock_ops_trace_manager.decrypt_tracing_config.return_value = {}
|
||||
mock_ops_trace_manager.check_trace_config_is_effective.return_value = True
|
||||
|
||||
@ -369,7 +358,7 @@ class TestOpsService:
|
||||
@patch("services.ops_service.db")
|
||||
def test_delete_tracing_app_config_no_config(self, mock_db):
|
||||
# Arrange
|
||||
mock_db.session.scalar.return_value = None
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = None
|
||||
|
||||
# Act
|
||||
result = OpsService.delete_tracing_app_config("app_id", "arize")
|
||||
@ -381,7 +370,7 @@ class TestOpsService:
|
||||
def test_delete_tracing_app_config_success(self, mock_db):
|
||||
# Arrange
|
||||
trace_config = MagicMock(spec=TraceAppConfig)
|
||||
mock_db.session.scalar.return_value = trace_config
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = trace_config
|
||||
|
||||
# Act
|
||||
result = OpsService.delete_tracing_app_config("app_id", "arize")
|
||||
|
||||
@ -77,12 +77,22 @@ def _make_segment(
|
||||
def _mock_db_session_for_update_multimodel(*, upload_files: list[_UploadFileStub] | None) -> MagicMock:
|
||||
session = MagicMock(name="session")
|
||||
|
||||
# db.session.execute() is used for delete(SegmentAttachmentBinding).where(...)
|
||||
session.execute = MagicMock(name="execute")
|
||||
binding_query = MagicMock(name="binding_query")
|
||||
binding_query.where.return_value = binding_query
|
||||
binding_query.delete.return_value = 1
|
||||
|
||||
# db.session.scalars(select(UploadFile).where(...)).all() returns upload files
|
||||
session.scalars.return_value.all.return_value = upload_files or []
|
||||
upload_query = MagicMock(name="upload_query")
|
||||
upload_query.where.return_value = upload_query
|
||||
upload_query.all.return_value = upload_files or []
|
||||
|
||||
def query_side_effect(model: object) -> MagicMock:
|
||||
if model is vector_service_module.SegmentAttachmentBinding:
|
||||
return binding_query
|
||||
if model is vector_service_module.UploadFile:
|
||||
return upload_query
|
||||
return MagicMock(name=f"query({model})")
|
||||
|
||||
session.query.side_effect = query_side_effect
|
||||
db_mock = MagicMock(name="db")
|
||||
db_mock.session = session
|
||||
return db_mock
|
||||
@ -155,15 +165,22 @@ def _mock_parent_child_queries(
|
||||
) -> MagicMock:
|
||||
session = MagicMock(name="session")
|
||||
|
||||
get_dispatch: dict[object, object | None] = {
|
||||
vector_service_module.DatasetDocument: dataset_document,
|
||||
vector_service_module.DatasetProcessRule: processing_rule,
|
||||
}
|
||||
doc_query = MagicMock(name="doc_query")
|
||||
doc_query.filter_by.return_value = doc_query
|
||||
doc_query.first.return_value = dataset_document
|
||||
|
||||
def get_side_effect(model: object, pk: object) -> object | None:
|
||||
return get_dispatch.get(model)
|
||||
rule_query = MagicMock(name="rule_query")
|
||||
rule_query.where.return_value = rule_query
|
||||
rule_query.first.return_value = processing_rule
|
||||
|
||||
session.get.side_effect = get_side_effect
|
||||
def query_side_effect(model: object) -> MagicMock:
|
||||
if model is vector_service_module.DatasetDocument:
|
||||
return doc_query
|
||||
if model is vector_service_module.DatasetProcessRule:
|
||||
return rule_query
|
||||
return MagicMock(name=f"query({model})")
|
||||
|
||||
session.query.side_effect = query_side_effect
|
||||
db_mock = MagicMock(name="db")
|
||||
db_mock.session = session
|
||||
return db_mock
|
||||
@ -592,7 +609,7 @@ def test_update_multimodel_vector_deletes_bindings_and_commits_on_empty_new_ids(
|
||||
|
||||
vector_cls.assert_called_once_with(dataset=dataset)
|
||||
vector_instance.delete_by_ids.assert_called_once_with(["old-1", "old-2"])
|
||||
db_mock.session.execute.assert_called_once()
|
||||
db_mock.session.query.assert_called_once_with(vector_service_module.SegmentAttachmentBinding)
|
||||
db_mock.session.commit.assert_called_once()
|
||||
db_mock.session.add_all.assert_not_called()
|
||||
vector_instance.add_texts.assert_not_called()
|
||||
@ -627,8 +644,6 @@ def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_up
|
||||
|
||||
binding_ctor = MagicMock(side_effect=lambda **kwargs: kwargs)
|
||||
monkeypatch.setattr(vector_service_module, "SegmentAttachmentBinding", binding_ctor)
|
||||
monkeypatch.setattr(vector_service_module, "delete", MagicMock())
|
||||
monkeypatch.setattr(vector_service_module, "select", MagicMock())
|
||||
|
||||
logger_mock = MagicMock()
|
||||
monkeypatch.setattr(vector_service_module, "logger", logger_mock)
|
||||
@ -662,8 +677,6 @@ def test_update_multimodel_vector_updates_bindings_without_multimodal_vector_ops
|
||||
monkeypatch.setattr(
|
||||
vector_service_module, "SegmentAttachmentBinding", MagicMock(side_effect=lambda **kwargs: kwargs)
|
||||
)
|
||||
monkeypatch.setattr(vector_service_module, "delete", MagicMock())
|
||||
monkeypatch.setattr(vector_service_module, "select", MagicMock())
|
||||
|
||||
VectorService.update_multimodel_vector(segment=segment, attachment_ids=["file-1"], dataset=dataset)
|
||||
|
||||
@ -685,8 +698,6 @@ def test_update_multimodel_vector_rolls_back_and_reraises_on_error(monkeypatch:
|
||||
monkeypatch.setattr(
|
||||
vector_service_module, "SegmentAttachmentBinding", MagicMock(side_effect=lambda **kwargs: kwargs)
|
||||
)
|
||||
monkeypatch.setattr(vector_service_module, "delete", MagicMock())
|
||||
monkeypatch.setattr(vector_service_module, "select", MagicMock())
|
||||
|
||||
logger_mock = MagicMock()
|
||||
monkeypatch.setattr(vector_service_module, "logger", logger_mock)
|
||||
|
||||
@ -268,7 +268,7 @@ class TestWorkflowService:
|
||||
Provides mock implementations of:
|
||||
- session.add(): Adding new records
|
||||
- session.commit(): Committing transactions
|
||||
- session.scalar(): Scalar queries
|
||||
- session.query(): Querying database
|
||||
- session.execute(): Executing SQL statements
|
||||
"""
|
||||
with patch("services.workflow_service.db") as mock_db:
|
||||
@ -276,7 +276,7 @@ class TestWorkflowService:
|
||||
mock_db.session = mock_session
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = MagicMock()
|
||||
mock_session.scalar = MagicMock()
|
||||
mock_session.query = MagicMock()
|
||||
mock_session.execute = MagicMock()
|
||||
yield mock_db
|
||||
|
||||
@ -338,8 +338,10 @@ class TestWorkflowService:
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock()
|
||||
|
||||
# Mock db.session.scalar() used by get_draft_workflow
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
|
||||
result = workflow_service.get_draft_workflow(app)
|
||||
|
||||
@ -349,8 +351,10 @@ class TestWorkflowService:
|
||||
"""Test get_draft_workflow returns None when no draft exists."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
|
||||
# Mock db.session.scalar() to return None
|
||||
mock_db_session.session.scalar.return_value = None
|
||||
# Mock database query to return None
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = None
|
||||
|
||||
result = workflow_service.get_draft_workflow(app)
|
||||
|
||||
@ -362,8 +366,10 @@ class TestWorkflowService:
|
||||
workflow_id = "workflow-123"
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(version="v1")
|
||||
|
||||
# Mock db.session.scalar() used by get_published_workflow_by_id
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
|
||||
result = workflow_service.get_draft_workflow(app, workflow_id=workflow_id)
|
||||
|
||||
@ -378,8 +384,10 @@ class TestWorkflowService:
|
||||
workflow_id = "workflow-123"
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id, version="v1")
|
||||
|
||||
# Mock db.session.scalar() used by get_published_workflow_by_id
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
|
||||
result = workflow_service.get_published_workflow_by_id(app, workflow_id)
|
||||
|
||||
@ -398,8 +406,10 @@ class TestWorkflowService:
|
||||
workflow_id=workflow_id, version=Workflow.VERSION_DRAFT
|
||||
)
|
||||
|
||||
# Mock db.session.scalar() used by get_published_workflow_by_id
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
|
||||
with pytest.raises(IsDraftWorkflowError):
|
||||
workflow_service.get_published_workflow_by_id(app, workflow_id)
|
||||
@ -409,8 +419,10 @@ class TestWorkflowService:
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
workflow_id = "nonexistent-workflow"
|
||||
|
||||
# Mock db.session.scalar() to return None
|
||||
mock_db_session.session.scalar.return_value = None
|
||||
# Mock database query to return None
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = None
|
||||
|
||||
result = workflow_service.get_published_workflow_by_id(app, workflow_id)
|
||||
|
||||
@ -422,8 +434,10 @@ class TestWorkflowService:
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock(workflow_id=workflow_id)
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id, version="v1")
|
||||
|
||||
# Mock db.session.scalar() used by get_published_workflow
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
|
||||
result = workflow_service.get_published_workflow(app)
|
||||
|
||||
@ -452,9 +466,11 @@ class TestWorkflowService:
|
||||
graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph()
|
||||
features = {"file_upload": {"enabled": False}}
|
||||
|
||||
# Mock db.session.scalar() to return None (no existing draft)
|
||||
# Mock get_draft_workflow to return None (no existing draft)
|
||||
# This simulates the first time a workflow is created for an app
|
||||
mock_db_session.session.scalar.return_value = None
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = None
|
||||
|
||||
with (
|
||||
patch.object(workflow_service, "validate_features_structure"),
|
||||
@ -488,10 +504,12 @@ class TestWorkflowService:
|
||||
features = {"file_upload": {"enabled": False}}
|
||||
unique_hash = "test-hash-123"
|
||||
|
||||
# Mock existing draft workflow via db.session.scalar()
|
||||
# Mock existing draft workflow
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(unique_hash=unique_hash)
|
||||
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
|
||||
with (
|
||||
patch.object(workflow_service, "validate_features_structure"),
|
||||
@ -527,10 +545,12 @@ class TestWorkflowService:
|
||||
graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph()
|
||||
features = {}
|
||||
|
||||
# Mock existing draft workflow with different hash via db.session.scalar()
|
||||
# Mock existing draft workflow with different hash
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(unique_hash="old-hash")
|
||||
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
|
||||
with pytest.raises(WorkflowHashNotEqualError):
|
||||
workflow_service.sync_draft_workflow(
|
||||
|
||||
@ -347,7 +347,7 @@ class TestGetBuiltinToolProviderCredentials:
|
||||
def test_returns_empty_when_no_providers(self, mock_db):
|
||||
mock_db.session.no_autoflush.__enter__ = MagicMock(return_value=None)
|
||||
mock_db.session.no_autoflush.__exit__ = MagicMock(return_value=False)
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
mock_db.session.query.return_value.filter_by.return_value.order_by.return_value.all.return_value = []
|
||||
|
||||
result = BuiltinToolManageService.get_builtin_tool_provider_credentials("t", "google")
|
||||
|
||||
@ -362,7 +362,7 @@ class TestGetBuiltinToolProviderCredentials:
|
||||
mock_db.session.no_autoflush.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
provider = MagicMock(provider="google", is_default=False)
|
||||
mock_db.session.scalars.return_value.all.return_value = [provider]
|
||||
mock_db.session.query.return_value.filter_by.return_value.order_by.return_value.all.return_value = [provider]
|
||||
|
||||
mock_encrypter = MagicMock()
|
||||
mock_encrypter.decrypt.return_value = {"key": "decrypted"}
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
|
||||
ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
PROJECT_NAME="${PODMAN_PROJECT_NAME:-dify}"
|
||||
COMPOSE_FILE="${PODMAN_COMPOSE_FILE:-$ROOT/docker/podman-compose.middleware.yaml}"
|
||||
NETWORK_NAME="${PODMAN_NETWORK_NAME:-dify}"
|
||||
|
||||
cd "$ROOT/docker"
|
||||
|
||||
podman compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down -v --remove-orphans || true
|
||||
|
||||
if ! podman network inspect "$NETWORK_NAME" >/dev/null 2>&1; then
|
||||
podman network create "$NETWORK_NAME"
|
||||
fi
|
||||
|
||||
podman compose -f "$COMPOSE_FILE" --profile postgresql --profile weaviate -p "$PROJECT_NAME" up -d
|
||||
@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BACKEND_URL="${DIFY_WEB_BACKEND_URL:-http://localhost:5001}"
|
||||
IMAGE="${DIFY_WEB_IMAGE:-docker.io/langgenius/dify-web:1.13.2}"
|
||||
HOST_BIND="${DIFY_WEB_HOSTNAME:-localhost}"
|
||||
WEB_URL="${DIFY_WEB_FRONTEND_URL:-}"
|
||||
CONTAINER_NAME="${DIFY_WEB_CONTAINER_NAME:-dify-web}"
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN="${DIFY_WEB_NEXT_PUBLIC_COOKIE_DOMAIN:-}"
|
||||
|
||||
podman rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
exec podman run --rm --name "$CONTAINER_NAME" --network host \
|
||||
-e HOSTNAME="${HOST_BIND}" \
|
||||
-e CONSOLE_API_URL="${BACKEND_URL}" \
|
||||
-e APP_API_URL="${BACKEND_URL}" \
|
||||
-e SERVICE_API_URL="${BACKEND_URL}" \
|
||||
${WEB_URL:+-e CONSOLE_WEB_URL="${WEB_URL}"} \
|
||||
${NEXT_PUBLIC_COOKIE_DOMAIN:+-e NEXT_PUBLIC_COOKIE_DOMAIN="${NEXT_PUBLIC_COOKIE_DOMAIN}"} \
|
||||
"${IMAGE}"
|
||||
@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
|
||||
ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
COMPOSE_FILE="${PODMAN_COMPOSE_FILE:-$ROOT/docker/podman-compose.middleware.yaml}"
|
||||
PROJECT_NAME="${PODMAN_PROJECT_NAME:-dify}"
|
||||
|
||||
cd "$ROOT/docker"
|
||||
podman compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down -v
|
||||
@ -1,267 +0,0 @@
|
||||
services:
|
||||
# The postgres database.
|
||||
db_postgres:
|
||||
image: docker.io/postgres:15-alpine
|
||||
profiles:
|
||||
- ""
|
||||
- postgresql
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${DB_DATABASE:-dify}
|
||||
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
|
||||
command: >
|
||||
postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}'
|
||||
-c 'shared_buffers=${POSTGRES_SHARED_BUFFERS:-128MB}'
|
||||
-c 'work_mem=${POSTGRES_WORK_MEM:-4MB}'
|
||||
-c 'maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}'
|
||||
-c 'effective_cache_size=${POSTGRES_EFFECTIVE_CACHE_SIZE:-4096MB}'
|
||||
-c 'statement_timeout=${POSTGRES_STATEMENT_TIMEOUT:-0}'
|
||||
-c 'idle_in_transaction_session_timeout=${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-0}'
|
||||
volumes:
|
||||
- ${PGDATA_HOST_VOLUME:-./volumes/db/data}:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${EXPOSE_POSTGRES_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"pg_isready",
|
||||
"-h",
|
||||
"db_postgres",
|
||||
"-U",
|
||||
"${DB_USERNAME:-postgres}",
|
||||
"-d",
|
||||
"${DB_DATABASE:-dify}",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
networks:
|
||||
- dify
|
||||
|
||||
db_mysql:
|
||||
image: docker.io/mysql:8.0
|
||||
profiles:
|
||||
- mysql
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-dify}
|
||||
command: >
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
|
||||
--innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M}
|
||||
--innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2}
|
||||
volumes:
|
||||
- ${MYSQL_HOST_VOLUME:-./volumes/mysql/data}:/var/lib/mysql
|
||||
ports:
|
||||
- "${EXPOSE_MYSQL_PORT:-3306}:3306"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"mysqladmin",
|
||||
"ping",
|
||||
"-u",
|
||||
"root",
|
||||
"-p${DB_PASSWORD:-difyai123456}",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
networks:
|
||||
- dify
|
||||
|
||||
# The redis cache.
|
||||
redis:
|
||||
image: docker.io/redis:6-alpine
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
environment:
|
||||
REDISCLI_AUTH: ${REDIS_PASSWORD:-difyai123456}
|
||||
volumes:
|
||||
# Mount the redis data directory to the container.
|
||||
- ${REDIS_HOST_VOLUME:-./volumes/redis/data}:/data
|
||||
# Set the redis password when startup redis server.
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD:-difyai123456}
|
||||
ports:
|
||||
- "${EXPOSE_REDIS_PORT:-6379}:6379"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"redis-cli -a ${REDIS_PASSWORD:-difyai123456} ping | grep -q PONG",
|
||||
]
|
||||
networks:
|
||||
- dify
|
||||
|
||||
# The DifySandbox
|
||||
sandbox:
|
||||
image: docker.io/langgenius/dify-sandbox:0.2.12
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
environment:
|
||||
# The DifySandbox configurations
|
||||
# Make sure you are changing this key for your deployment with a strong key.
|
||||
# You can generate a strong key using `openssl rand -base64 42`.
|
||||
API_KEY: ${SANDBOX_API_KEY:-dify-sandbox}
|
||||
GIN_MODE: ${SANDBOX_GIN_MODE:-release}
|
||||
WORKER_TIMEOUT: ${SANDBOX_WORKER_TIMEOUT:-15}
|
||||
ENABLE_NETWORK: ${SANDBOX_ENABLE_NETWORK:-true}
|
||||
HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128}
|
||||
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
|
||||
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
volumes:
|
||||
- ./volumes/sandbox/dependencies:/dependencies
|
||||
- ./volumes/sandbox/conf:/conf
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8194/health"]
|
||||
networks:
|
||||
- ssrf_proxy_network
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: docker.io/langgenius/dify-plugin-daemon:0.5.4-local
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text}
|
||||
DB_DATABASE: ${DB_PLUGIN_DATABASE:-dify_plugin}
|
||||
REDIS_HOST: ${REDIS_HOST:-redis}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-difyai123456}
|
||||
SERVER_PORT: ${PLUGIN_DAEMON_PORT:-5002}
|
||||
SERVER_KEY: ${PLUGIN_DAEMON_KEY:-lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi}
|
||||
MAX_PLUGIN_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
PPROF_ENABLED: ${PLUGIN_PPROF_ENABLED:-false}
|
||||
DIFY_INNER_API_URL: ${PLUGIN_DIFY_INNER_API_URL:-http://host.containers.internal:5001}
|
||||
DIFY_INNER_API_KEY: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
PLUGIN_REMOTE_INSTALLING_HOST: ${PLUGIN_DEBUGGING_HOST:-0.0.0.0}
|
||||
PLUGIN_REMOTE_INSTALLING_PORT: ${PLUGIN_DEBUGGING_PORT:-5003}
|
||||
PLUGIN_WORKING_PATH: ${PLUGIN_WORKING_PATH:-/app/storage/cwd}
|
||||
PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
PLUGIN_STORAGE_TYPE: ${PLUGIN_STORAGE_TYPE:-local}
|
||||
PLUGIN_STORAGE_LOCAL_ROOT: ${PLUGIN_STORAGE_LOCAL_ROOT:-/app/storage}
|
||||
PLUGIN_INSTALLED_PATH: ${PLUGIN_INSTALLED_PATH:-plugin}
|
||||
PLUGIN_PACKAGE_CACHE_PATH: ${PLUGIN_PACKAGE_CACHE_PATH:-plugin_packages}
|
||||
PLUGIN_MEDIA_CACHE_PATH: ${PLUGIN_MEDIA_CACHE_PATH:-assets}
|
||||
PLUGIN_STORAGE_OSS_BUCKET: ${PLUGIN_STORAGE_OSS_BUCKET:-}
|
||||
S3_USE_AWS: ${PLUGIN_S3_USE_AWS:-false}
|
||||
S3_USE_AWS_MANAGED_IAM: ${PLUGIN_S3_USE_AWS_MANAGED_IAM:-false}
|
||||
S3_ENDPOINT: ${PLUGIN_S3_ENDPOINT:-}
|
||||
S3_USE_PATH_STYLE: ${PLUGIN_S3_USE_PATH_STYLE:-false}
|
||||
AWS_ACCESS_KEY: ${PLUGIN_AWS_ACCESS_KEY:-}
|
||||
AWS_SECRET_KEY: ${PLUGIN_AWS_SECRET_KEY:-}
|
||||
AWS_REGION: ${PLUGIN_AWS_REGION:-}
|
||||
AZURE_BLOB_STORAGE_CONNECTION_STRING: ${PLUGIN_AZURE_BLOB_STORAGE_CONNECTION_STRING:-}
|
||||
AZURE_BLOB_STORAGE_CONTAINER_NAME: ${PLUGIN_AZURE_BLOB_STORAGE_CONTAINER_NAME:-}
|
||||
TENCENT_COS_SECRET_KEY: ${PLUGIN_TENCENT_COS_SECRET_KEY:-}
|
||||
TENCENT_COS_SECRET_ID: ${PLUGIN_TENCENT_COS_SECRET_ID:-}
|
||||
TENCENT_COS_REGION: ${PLUGIN_TENCENT_COS_REGION:-}
|
||||
ALIYUN_OSS_REGION: ${PLUGIN_ALIYUN_OSS_REGION:-}
|
||||
ALIYUN_OSS_ENDPOINT: ${PLUGIN_ALIYUN_OSS_ENDPOINT:-}
|
||||
ALIYUN_OSS_ACCESS_KEY_ID: ${PLUGIN_ALIYUN_OSS_ACCESS_KEY_ID:-}
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET: ${PLUGIN_ALIYUN_OSS_ACCESS_KEY_SECRET:-}
|
||||
ALIYUN_OSS_AUTH_VERSION: ${PLUGIN_ALIYUN_OSS_AUTH_VERSION:-v4}
|
||||
ALIYUN_OSS_PATH: ${PLUGIN_ALIYUN_OSS_PATH:-}
|
||||
VOLCENGINE_TOS_ENDPOINT: ${PLUGIN_VOLCENGINE_TOS_ENDPOINT:-}
|
||||
VOLCENGINE_TOS_ACCESS_KEY: ${PLUGIN_VOLCENGINE_TOS_ACCESS_KEY:-}
|
||||
VOLCENGINE_TOS_SECRET_KEY: ${PLUGIN_VOLCENGINE_TOS_SECRET_KEY:-}
|
||||
VOLCENGINE_TOS_REGION: ${PLUGIN_VOLCENGINE_TOS_REGION:-}
|
||||
THIRD_PARTY_SIGNATURE_VERIFICATION_ENABLED: true
|
||||
THIRD_PARTY_SIGNATURE_VERIFICATION_PUBLIC_KEYS: /app/keys/publickey.pem
|
||||
FORCE_VERIFYING_SIGNATURE: false
|
||||
depends_on:
|
||||
db_postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${EXPOSE_PLUGIN_DAEMON_PORT:-5002}:${PLUGIN_DAEMON_PORT:-5002}"
|
||||
- "${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}:${PLUGIN_DEBUGGING_PORT:-5003}"
|
||||
volumes:
|
||||
- ./volumes/plugin_daemon:/app/storage
|
||||
networks:
|
||||
- dify
|
||||
|
||||
# ssrf_proxy server
|
||||
# for more information, please refer to
|
||||
# https://docs.dify.ai/learn-more/faq/install-faq#18-why-is-ssrf-proxy-needed%3F
|
||||
ssrf_proxy:
|
||||
image: docker.io/ubuntu/squid:latest
|
||||
restart: always
|
||||
volumes:
|
||||
- ./ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template
|
||||
- ./ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh
|
||||
entrypoint:
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
"cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh",
|
||||
]
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
environment:
|
||||
# pls clearly modify the squid env vars to fit your network environment.
|
||||
HTTP_PORT: ${SSRF_HTTP_PORT:-3128}
|
||||
COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid}
|
||||
REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194}
|
||||
SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox}
|
||||
SANDBOX_PORT: ${SSRF_SANDBOX_PORT:-8194}
|
||||
ports:
|
||||
- "${EXPOSE_SSRF_PROXY_PORT:-3128}:${SSRF_HTTP_PORT:-3128}"
|
||||
- "${EXPOSE_SANDBOX_PORT:-8194}:${SANDBOX_PORT:-8194}"
|
||||
networks:
|
||||
- ssrf_proxy_network
|
||||
- dify
|
||||
|
||||
# The Weaviate vector store.
|
||||
weaviate:
|
||||
image: docker.io/semitechnologies/weaviate:1.27.0
|
||||
profiles:
|
||||
- ""
|
||||
- weaviate
|
||||
restart: always
|
||||
volumes:
|
||||
# Mount the Weaviate data directory to the con tainer.
|
||||
- ${WEAVIATE_HOST_VOLUME:-./volumes/weaviate}:/var/lib/weaviate
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
environment:
|
||||
# The Weaviate configurations
|
||||
# You can refer to the [Weaviate](https://weaviate.io/developers/weaviate/config-refs/env-vars) documentation for more information.
|
||||
PERSISTENCE_DATA_PATH: ${WEAVIATE_PERSISTENCE_DATA_PATH:-/var/lib/weaviate}
|
||||
QUERY_DEFAULTS_LIMIT: ${WEAVIATE_QUERY_DEFAULTS_LIMIT:-25}
|
||||
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: ${WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED:-false}
|
||||
DEFAULT_VECTORIZER_MODULE: ${WEAVIATE_DEFAULT_VECTORIZER_MODULE:-none}
|
||||
CLUSTER_HOSTNAME: ${WEAVIATE_CLUSTER_HOSTNAME:-node1}
|
||||
AUTHENTICATION_APIKEY_ENABLED: ${WEAVIATE_AUTHENTICATION_APIKEY_ENABLED:-true}
|
||||
AUTHENTICATION_APIKEY_ALLOWED_KEYS: ${WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih}
|
||||
AUTHENTICATION_APIKEY_USERS: ${WEAVIATE_AUTHENTICATION_APIKEY_USERS:-hello@dify.ai}
|
||||
AUTHORIZATION_ADMINLIST_ENABLED: ${WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED:-true}
|
||||
AUTHORIZATION_ADMINLIST_USERS: ${WEAVIATE_AUTHORIZATION_ADMINLIST_USERS:-hello@dify.ai}
|
||||
DISABLE_TELEMETRY: ${WEAVIATE_DISABLE_TELEMETRY:-false}
|
||||
ports:
|
||||
- "${EXPOSE_WEAVIATE_PORT:-8080}:8080"
|
||||
- "${EXPOSE_WEAVIATE_GRPC_PORT:-50051}:50051"
|
||||
|
||||
networks:
|
||||
# create a network between sandbox, api and ssrf_proxy, and can not access outside.
|
||||
ssrf_proxy_network:
|
||||
driver: bridge
|
||||
internal: true
|
||||
dify:
|
||||
name: dify
|
||||
driver: bridge
|
||||
12
package.json
12
package.json
@ -1,15 +1,11 @@
|
||||
{
|
||||
"name": "dify",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "vp config"
|
||||
},
|
||||
"devDependencies": {
|
||||
"taze": "catalog:",
|
||||
"vite-plus": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0"
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"devDependencies": {
|
||||
"taze": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
218
pnpm-lock.yaml
generated
218
pnpm-lock.yaml
generated
@ -345,6 +345,9 @@ catalogs:
|
||||
html-to-image:
|
||||
specifier: 1.11.13
|
||||
version: 1.11.13
|
||||
husky:
|
||||
specifier: 9.1.7
|
||||
version: 9.1.7
|
||||
i18next:
|
||||
specifier: 25.10.10
|
||||
version: 25.10.10
|
||||
@ -387,6 +390,9 @@ catalogs:
|
||||
lexical:
|
||||
specifier: 0.42.0
|
||||
version: 0.42.0
|
||||
lint-staged:
|
||||
specifier: 16.4.0
|
||||
version: 16.4.0
|
||||
mermaid:
|
||||
specifier: 11.13.0
|
||||
version: 11.13.0
|
||||
@ -396,6 +402,9 @@ catalogs:
|
||||
mitt:
|
||||
specifier: 3.0.1
|
||||
version: 3.0.1
|
||||
motion:
|
||||
specifier: 12.38.0
|
||||
version: 12.38.0
|
||||
negotiator:
|
||||
specifier: 1.0.0
|
||||
version: 1.0.0
|
||||
@ -618,9 +627,6 @@ importers:
|
||||
taze:
|
||||
specifier: 'catalog:'
|
||||
version: 19.10.0
|
||||
vite-plus:
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)
|
||||
|
||||
e2e:
|
||||
devDependencies:
|
||||
@ -867,6 +873,9 @@ importers:
|
||||
mitt:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.1
|
||||
motion:
|
||||
specifier: 'catalog:'
|
||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
negotiator:
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.0
|
||||
@ -1162,12 +1171,18 @@ importers:
|
||||
hono:
|
||||
specifier: 'catalog:'
|
||||
version: 4.12.9
|
||||
husky:
|
||||
specifier: 'catalog:'
|
||||
version: 9.1.7
|
||||
iconify-import-svg:
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.2
|
||||
knip:
|
||||
specifier: 'catalog:'
|
||||
version: 6.1.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)
|
||||
lint-staged:
|
||||
specifier: 'catalog:'
|
||||
version: 16.4.0
|
||||
postcss:
|
||||
specifier: 'catalog:'
|
||||
version: 8.5.8
|
||||
@ -4742,6 +4757,10 @@ packages:
|
||||
ajv@8.18.0:
|
||||
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
|
||||
|
||||
ansi-escapes@7.3.0:
|
||||
resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ansi-regex@4.1.1:
|
||||
resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==}
|
||||
engines: {node: '>=6'}
|
||||
@ -4762,6 +4781,10 @@ packages:
|
||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansis@4.2.0:
|
||||
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
|
||||
engines: {node: '>=14'}
|
||||
@ -5049,10 +5072,18 @@ packages:
|
||||
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cli-table3@0.6.5:
|
||||
resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==}
|
||||
engines: {node: 10.* || >= 12.*}
|
||||
|
||||
cli-truncate@5.2.0:
|
||||
resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
@ -5079,6 +5110,9 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
comma-separated-tokens@1.0.8:
|
||||
resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==}
|
||||
|
||||
@ -5544,6 +5578,10 @@ packages:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
environment@1.1.0:
|
||||
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
error-stack-parser-es@1.0.5:
|
||||
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
|
||||
|
||||
@ -5933,6 +5971,9 @@ packages:
|
||||
event-target-bus@1.0.0:
|
||||
resolution: {integrity: sha512-uPcWKbj/BJU3Tbw9XqhHqET4/LBOhvv3/SJWr7NksxA6TC5YqBpaZgawE9R+WpYFCBFSAE4Vun+xQS6w4ABdlA==}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
@ -6056,6 +6097,20 @@ packages:
|
||||
fraction.js@5.3.4:
|
||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||
|
||||
framer-motion@12.38.0:
|
||||
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fs-constants@1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
|
||||
@ -6254,6 +6309,11 @@ packages:
|
||||
htmlparser2@10.1.0:
|
||||
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
|
||||
|
||||
husky@9.1.7:
|
||||
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
i18next-resources-to-backend@1.2.1:
|
||||
resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==}
|
||||
|
||||
@ -6379,6 +6439,10 @@ packages:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-fullwidth-code-point@5.1.0:
|
||||
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
is-glob@4.0.3:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -6686,6 +6750,15 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
lint-staged@16.4.0:
|
||||
resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==}
|
||||
engines: {node: '>=20.17'}
|
||||
hasBin: true
|
||||
|
||||
listr2@9.0.5:
|
||||
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
load-tsconfig@0.2.5:
|
||||
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@ -6717,6 +6790,10 @@ packages:
|
||||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
|
||||
log-update@6.1.0:
|
||||
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
@ -7054,6 +7131,26 @@ packages:
|
||||
moo-color@1.0.3:
|
||||
resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==}
|
||||
|
||||
motion-dom@12.38.0:
|
||||
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
|
||||
|
||||
motion-utils@12.36.0:
|
||||
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
|
||||
|
||||
motion@12.38.0:
|
||||
resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
@ -7863,6 +7960,9 @@ packages:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
robust-predicates@3.0.3:
|
||||
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
|
||||
|
||||
@ -7983,6 +8083,14 @@ packages:
|
||||
size-sensor@1.0.3:
|
||||
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
slice-ansi@8.0.0:
|
||||
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
smol-toml@1.6.1:
|
||||
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
|
||||
engines: {node: '>= 18'}
|
||||
@ -8066,6 +8174,10 @@ packages:
|
||||
resolution: {integrity: sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==}
|
||||
engines: {node: '>=0.6.19'}
|
||||
|
||||
string-argv@0.3.2:
|
||||
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
|
||||
engines: {node: '>=0.6.19'}
|
||||
|
||||
string-ts@2.3.1:
|
||||
resolution: {integrity: sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw==}
|
||||
|
||||
@ -8802,6 +8914,10 @@ packages:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
@ -12582,6 +12698,10 @@ snapshots:
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
ansi-escapes@7.3.0:
|
||||
dependencies:
|
||||
environment: 1.1.0
|
||||
|
||||
ansi-regex@4.1.1: {}
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
@ -12594,6 +12714,8 @@ snapshots:
|
||||
|
||||
ansi-styles@5.2.0: {}
|
||||
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
ansis@4.2.0: {}
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
@ -12865,12 +12987,21 @@ snapshots:
|
||||
dependencies:
|
||||
escape-string-regexp: 1.0.5
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
dependencies:
|
||||
restore-cursor: 5.1.0
|
||||
|
||||
cli-table3@0.6.5:
|
||||
dependencies:
|
||||
string-width: 8.2.0
|
||||
optionalDependencies:
|
||||
'@colors/colors': 1.5.0
|
||||
|
||||
cli-truncate@5.2.0:
|
||||
dependencies:
|
||||
slice-ansi: 8.0.0
|
||||
string-width: 8.2.0
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
@ -12907,6 +13038,8 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
comma-separated-tokens@1.0.8: {}
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
@ -13350,6 +13483,8 @@ snapshots:
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
environment@1.1.0: {}
|
||||
|
||||
error-stack-parser-es@1.0.5: {}
|
||||
|
||||
error-stack-parser@2.1.4:
|
||||
@ -14011,6 +14146,8 @@ snapshots:
|
||||
|
||||
event-target-bus@1.0.0: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
expand-template@2.0.3:
|
||||
@ -14129,6 +14266,15 @@ snapshots:
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
|
||||
framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
motion-dom: 12.38.0
|
||||
motion-utils: 12.36.0
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
fs-constants@1.0.0:
|
||||
optional: true
|
||||
|
||||
@ -14399,6 +14545,8 @@ snapshots:
|
||||
domutils: 3.2.2
|
||||
entities: 7.0.1
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
i18next-resources-to-backend@1.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
@ -14497,6 +14645,10 @@ snapshots:
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@5.1.0:
|
||||
dependencies:
|
||||
get-east-asian-width: 1.5.0
|
||||
|
||||
is-glob@4.0.3:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
@ -14750,6 +14902,24 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
lint-staged@16.4.0:
|
||||
dependencies:
|
||||
commander: 14.0.3
|
||||
listr2: 9.0.5
|
||||
picomatch: 4.0.4
|
||||
string-argv: 0.3.2
|
||||
tinyexec: 1.0.4
|
||||
yaml: 2.8.3
|
||||
|
||||
listr2@9.0.5:
|
||||
dependencies:
|
||||
cli-truncate: 5.2.0
|
||||
colorette: 2.0.20
|
||||
eventemitter3: 5.0.4
|
||||
log-update: 6.1.0
|
||||
rfdc: 1.4.1
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
load-tsconfig@0.2.5: {}
|
||||
|
||||
loader-runner@4.3.1: {}
|
||||
@ -14774,6 +14944,14 @@ snapshots:
|
||||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
log-update@6.1.0:
|
||||
dependencies:
|
||||
ansi-escapes: 7.3.0
|
||||
cli-cursor: 5.0.0
|
||||
slice-ansi: 7.1.2
|
||||
strip-ansi: 7.2.0
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
@ -15422,6 +15600,20 @@ snapshots:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
motion-dom@12.38.0:
|
||||
dependencies:
|
||||
motion-utils: 12.36.0
|
||||
|
||||
motion-utils@12.36.0: {}
|
||||
|
||||
motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
framer-motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
@ -16410,6 +16602,8 @@ snapshots:
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
robust-predicates@3.0.3: {}
|
||||
|
||||
rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1):
|
||||
@ -16603,6 +16797,16 @@ snapshots:
|
||||
|
||||
size-sensor@1.0.3: {}
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
is-fullwidth-code-point: 5.1.0
|
||||
|
||||
slice-ansi@8.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
is-fullwidth-code-point: 5.1.0
|
||||
|
||||
smol-toml@1.6.1: {}
|
||||
|
||||
solid-js@1.9.11:
|
||||
@ -16703,6 +16907,8 @@ snapshots:
|
||||
|
||||
string-argv@0.3.1: {}
|
||||
|
||||
string-argv@0.3.2: {}
|
||||
|
||||
string-ts@2.3.1: {}
|
||||
|
||||
string-width@8.2.0:
|
||||
@ -17547,6 +17753,12 @@ snapshots:
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 8.2.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.20.0: {}
|
||||
|
||||
@ -3,7 +3,7 @@ minimumReleaseAge: 1440
|
||||
blockExoticSubdeps: true
|
||||
strictDepBuilds: true
|
||||
allowBuilds:
|
||||
'@parcel/watcher': false
|
||||
"@parcel/watcher": false
|
||||
canvas: false
|
||||
esbuild: false
|
||||
sharp: false
|
||||
@ -183,6 +183,7 @@ catalog:
|
||||
hono: 4.12.9
|
||||
html-entities: 2.6.0
|
||||
html-to-image: 1.11.13
|
||||
husky: 9.1.7
|
||||
i18next: 25.10.10
|
||||
i18next-resources-to-backend: 1.2.1
|
||||
iconify-import-svg: 0.1.2
|
||||
@ -197,9 +198,11 @@ catalog:
|
||||
ky: 1.14.3
|
||||
lamejs: 1.2.1
|
||||
lexical: 0.42.0
|
||||
lint-staged: 16.4.0
|
||||
mermaid: 11.13.0
|
||||
mime: 4.1.0
|
||||
mitt: 3.0.1
|
||||
motion: 12.38.0
|
||||
negotiator: 1.0.0
|
||||
next: 16.2.1
|
||||
next-themes: 0.4.6
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
|
||||
export default defineConfig({
|
||||
staged: {},
|
||||
})
|
||||
2
.vite-hooks/pre-commit → web/.husky/pre-commit
Executable file → Normal file
2
.vite-hooks/pre-commit → web/.husky/pre-commit
Executable file → Normal file
@ -77,7 +77,7 @@ if $web_modified; then
|
||||
fi
|
||||
|
||||
cd ./web || exit 1
|
||||
vp staged
|
||||
lint-staged
|
||||
|
||||
if $web_ts_modified; then
|
||||
echo "Running TypeScript type-check:tsgo"
|
||||
@ -31,7 +31,7 @@ RUN corepack install
|
||||
|
||||
# Install only the web workspace to keep image builds from pulling in
|
||||
# unrelated workspace dependencies such as e2e tooling.
|
||||
RUN VITE_GIT_HOOKS=0 pnpm install --filter ./web... --frozen-lockfile
|
||||
RUN pnpm install --filter ./web... --frozen-lockfile
|
||||
|
||||
# build resources
|
||||
FROM base AS builder
|
||||
|
||||
@ -22,6 +22,7 @@ web/node_modules
|
||||
web/dist
|
||||
web/build
|
||||
web/coverage
|
||||
web/.husky
|
||||
web/.next
|
||||
web/.pnpm-store
|
||||
web/.vscode
|
||||
|
||||
@ -75,11 +75,10 @@ vi.mock('@/app/components/plugins/card/base/description', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
|
||||
default: ({ orgName, packageName }: { orgName: string, packageName: string }) => (
|
||||
default: ({ orgName, downloadCount }: { orgName: string, downloadCount?: number }) => (
|
||||
<div data-testid="org-info">
|
||||
{orgName}
|
||||
/
|
||||
{packageName}
|
||||
{typeof downloadCount === 'number' ? ` · ${downloadCount}` : null}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -124,7 +123,7 @@ describe('Plugin Card Rendering Integration', () => {
|
||||
|
||||
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Google Search')
|
||||
expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius/google-search')
|
||||
expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius')
|
||||
expect(screen.getByTestId('description')).toHaveTextContent('Search the web using Google')
|
||||
})
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ const PluginList = () => {
|
||||
return (
|
||||
<PluginPage
|
||||
plugins={<PluginsPanel />}
|
||||
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
|
||||
marketplace={<Marketplace />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
overlay?: React.ReactNode
|
||||
}
|
||||
|
||||
type CarouselContextValue = {
|
||||
@ -49,7 +50,7 @@ type TCarousel = {
|
||||
>
|
||||
|
||||
const Carousel: TCarousel = React.forwardRef(
|
||||
({ orientation = 'horizontal', opts, plugins, className, children, ...props }, ref) => {
|
||||
({ orientation = 'horizontal', opts, plugins, overlay, className, children, ...props }, ref) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{ ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' },
|
||||
plugins,
|
||||
@ -115,14 +116,19 @@ const Carousel: TCarousel = React.forwardRef(
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
// onKeyDownCapture={handleKeyDown}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
className={cn('relative', className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{overlay}
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden [border-radius:inherit]"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
<svg width="588" height="588" viewBox="0 0 588 588" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.2" clip-path="url(#clip0_20862_53031)">
|
||||
<g filter="url(#filter0_d_20862_53031)">
|
||||
<path d="M204.231 152.332L201.643 142.673C194.496 115.999 210.326 88.5823 236.999 81.4353C263.672 74.2882 291.089 90.1173 298.236 116.791L300.824 126.45L407.076 97.9798C417.745 95.1209 428.712 101.453 431.571 112.122L452.276 189.396C453.706 194.731 450.539 200.214 445.205 201.643C418.532 208.79 402.703 236.208 409.85 262.881C416.997 289.554 444.414 305.383 471.087 298.236C476.421 296.807 481.905 299.973 483.335 305.307L504.04 382.581C506.899 393.251 500.568 404.217 489.898 407.076L180.802 489.898C170.132 492.757 159.166 486.426 156.307 475.756L83.8375 205.297C80.9787 194.628 87.3104 183.661 97.9796 180.802L204.231 152.332Z" fill="#F2F4F7"/>
|
||||
<path d="M237.257 82.4012C263.397 75.3971 290.266 90.9096 297.27 117.049L300.117 127.675L407.335 98.9457C417.471 96.2297 427.889 102.245 430.605 112.381L451.31 189.655C452.597 194.456 449.747 199.391 444.946 200.677C417.74 207.967 401.594 235.933 408.884 263.139C416.174 290.346 444.139 306.492 471.346 299.202C476.146 297.916 481.082 300.766 482.369 305.566L503.074 382.84C505.79 392.976 499.775 403.394 489.639 406.11L180.543 488.932C170.407 491.648 159.989 485.633 157.273 475.497L84.8034 205.038C82.0875 194.902 88.1027 184.484 98.2384 181.768L205.456 153.039L202.609 142.414C195.605 116.274 211.118 89.4053 237.257 82.4012Z" stroke="white" stroke-width="2"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_20862_53031" x="31.151" y="59.719" width="525.576" height="514.866" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="12" operator="erode" in="SourceAlpha" result="effect1_dropShadow_20862_53031"/>
|
||||
<feOffset dy="32"/>
|
||||
<feGaussianBlur stdDeviation="32"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.14 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_20862_53031"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_20862_53031" result="shape"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_20862_53031">
|
||||
<rect width="480" height="480" fill="white" transform="translate(0 124.233) rotate(-15)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@ -0,0 +1,26 @@
|
||||
<svg width="588" height="588" viewBox="0 0 588 588" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.2" clip-path="url(#clip0_21509_19682)">
|
||||
<g filter="url(#filter0_d_21509_19682)">
|
||||
<path d="M346.36 300.589C389.034 289.159 432.902 314.489 444.34 357.157C455.774 399.831 430.445 443.698 387.771 455.137C345.095 466.572 301.227 441.245 289.792 398.568C278.362 355.895 303.687 312.023 346.36 300.589Z" fill="#F2F4F7"/>
|
||||
<path d="M116.295 221.279C122.148 217.181 129.755 216.517 136.23 219.537L261.798 278.096C268.274 281.114 272.666 287.369 273.288 294.489C273.908 301.604 270.669 308.514 264.818 312.611L151.323 392.076C145.47 396.175 137.869 396.858 131.393 393.838C124.917 390.819 120.544 384.556 119.922 377.439L107.85 239.415C107.227 232.299 110.444 225.378 116.295 221.279Z" fill="#F2F4F7"/>
|
||||
<path d="M283.278 160.623C283.279 127.486 310.148 100.606 343.285 100.606L383.281 100.606C416.416 100.607 443.279 127.482 443.279 160.618L443.279 200.613C443.28 233.747 416.42 260.604 383.286 260.607L343.272 260.612C310.138 260.609 283.28 233.752 283.278 200.619L283.278 160.623Z" fill="#F2F4F7"/>
|
||||
<path d="M346.619 301.555C388.759 290.268 432.079 315.281 443.374 357.416C454.666 399.557 429.653 442.875 387.513 454.171C345.37 465.464 302.05 440.453 290.758 398.31C279.471 356.169 304.479 312.846 346.619 301.555ZM116.869 222.099C122.429 218.205 129.656 217.574 135.808 220.443L261.376 279.002C267.529 281.87 271.701 287.814 272.291 294.576C272.88 301.333 269.804 307.898 264.245 311.791L150.75 391.257C145.361 395.03 138.416 395.757 132.395 393.19L131.815 392.931C125.665 390.064 121.51 384.115 120.918 377.353L108.846 239.329C108.254 232.567 111.311 225.992 116.869 222.099ZM284.278 160.623C284.28 128.038 310.701 101.606 343.285 101.606L383.281 101.606C415.864 101.608 442.279 128.034 442.28 160.618L442.28 200.614C442.28 233.195 415.868 259.604 383.286 259.607L343.272 259.612C310.691 259.61 284.281 233.2 284.278 200.619L284.278 160.623Z" stroke="white" stroke-width="2"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_21509_19682" x="55.7732" y="80.6057" width="443.312" height="461.277" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="12" operator="erode" in="SourceAlpha" result="effect1_dropShadow_21509_19682"/>
|
||||
<feOffset dy="32"/>
|
||||
<feGaussianBlur stdDeviation="32"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.14 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_21509_19682"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_21509_19682" result="shape"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_21509_19682">
|
||||
<rect width="480" height="480" fill="white" transform="translate(0 124.233) rotate(-15)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.6301 11.3333C11.6301 12.4379 10.7347 13.3333 9.63013 13.3333C8.52559 13.3333 7.63013 12.4379 7.63013 11.3333C7.63013 10.2287 8.52559 9.33325 9.63013 9.33325C10.7347 9.33325 11.6301 10.2287 11.6301 11.3333Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M3.1353 4.75464L6.67352 7.72353L2.33325 9.30327L3.1353 4.75464Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M9.79576 2.5L13.6595 3.53527L12.6242 7.399L8.7605 6.36371L9.79576 2.5Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 658 B |
@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 6.66675H17.5V15.8334H2.5V6.66675Z" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.16675 6.66659V3.33325H8.33341V6.66659" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.6667 6.66659V3.33325H15.8334V6.66659" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 509 B |
@ -0,0 +1,183 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "588",
|
||||
"height": "588",
|
||||
"viewBox": "0 0 588 588",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"opacity": "0.2",
|
||||
"clip-path": "url(#clip0_20862_53031)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter0_d_20862_53031)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M204.231 152.332L201.643 142.673C194.496 115.999 210.326 88.5823 236.999 81.4353C263.672 74.2882 291.089 90.1173 298.236 116.791L300.824 126.45L407.076 97.9798C417.745 95.1209 428.712 101.453 431.571 112.122L452.276 189.396C453.706 194.731 450.539 200.214 445.205 201.643C418.532 208.79 402.703 236.208 409.85 262.881C416.997 289.554 444.414 305.383 471.087 298.236C476.421 296.807 481.905 299.973 483.335 305.307L504.04 382.581C506.899 393.251 500.568 404.217 489.898 407.076L180.802 489.898C170.132 492.757 159.166 486.426 156.307 475.756L83.8375 205.297C80.9787 194.628 87.3104 183.661 97.9796 180.802L204.231 152.332Z",
|
||||
"fill": "#F2F4F7"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M237.257 82.4012C263.397 75.3971 290.266 90.9096 297.27 117.049L300.117 127.675L407.335 98.9457C417.471 96.2297 427.889 102.245 430.605 112.381L451.31 189.655C452.597 194.456 449.747 199.391 444.946 200.677C417.74 207.967 401.594 235.933 408.884 263.139C416.174 290.346 444.139 306.492 471.346 299.202C476.146 297.916 481.082 300.766 482.369 305.566L503.074 382.84C505.79 392.976 499.775 403.394 489.639 406.11L180.543 488.932C170.407 491.648 159.989 485.633 157.273 475.497L84.8034 205.038C82.0875 194.902 88.1027 184.484 98.2384 181.768L205.456 153.039L202.609 142.414C195.605 116.274 211.118 89.4053 237.257 82.4012Z",
|
||||
"stroke": "white",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter0_d_20862_53031",
|
||||
"x": "31.151",
|
||||
"y": "59.719",
|
||||
"width": "525.576",
|
||||
"height": "514.866",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feColorMatrix",
|
||||
"attributes": {
|
||||
"in": "SourceAlpha",
|
||||
"type": "matrix",
|
||||
"values": "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0",
|
||||
"result": "hardAlpha"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feMorphology",
|
||||
"attributes": {
|
||||
"radius": "12",
|
||||
"operator": "erode",
|
||||
"in": "SourceAlpha",
|
||||
"result": "effect1_dropShadow_20862_53031"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feOffset",
|
||||
"attributes": {
|
||||
"dy": "32"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "32"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feComposite",
|
||||
"attributes": {
|
||||
"in2": "hardAlpha",
|
||||
"operator": "out"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feColorMatrix",
|
||||
"attributes": {
|
||||
"type": "matrix",
|
||||
"values": "0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.14 0"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "effect1_dropShadow_20862_53031"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "effect1_dropShadow_20862_53031",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "clipPath",
|
||||
"attributes": {
|
||||
"id": "clip0_20862_53031"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "480",
|
||||
"height": "480",
|
||||
"fill": "white",
|
||||
"transform": "translate(0 124.233) rotate(-15)"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "PluginHeaderBg"
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './PluginHeaderBg.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'PluginHeaderBg'
|
||||
|
||||
export default Icon
|
||||
@ -0,0 +1,201 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "588",
|
||||
"height": "588",
|
||||
"viewBox": "0 0 588 588",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"opacity": "0.2",
|
||||
"clip-path": "url(#clip0_21509_19682)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter0_d_21509_19682)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M346.36 300.589C389.034 289.159 432.902 314.489 444.34 357.157C455.774 399.831 430.445 443.698 387.771 455.137C345.095 466.572 301.227 441.245 289.792 398.568C278.362 355.895 303.687 312.023 346.36 300.589Z",
|
||||
"fill": "#F2F4F7"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M116.295 221.279C122.148 217.181 129.755 216.517 136.23 219.537L261.798 278.096C268.274 281.114 272.666 287.369 273.288 294.489C273.908 301.604 270.669 308.514 264.818 312.611L151.323 392.076C145.47 396.175 137.869 396.858 131.393 393.838C124.917 390.819 120.544 384.556 119.922 377.439L107.85 239.415C107.227 232.299 110.444 225.378 116.295 221.279Z",
|
||||
"fill": "#F2F4F7"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M283.278 160.623C283.279 127.486 310.148 100.606 343.285 100.606L383.281 100.606C416.416 100.607 443.279 127.482 443.279 160.618L443.279 200.613C443.28 233.747 416.42 260.604 383.286 260.607L343.272 260.612C310.138 260.609 283.28 233.752 283.278 200.619L283.278 160.623Z",
|
||||
"fill": "#F2F4F7"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M346.619 301.555C388.759 290.268 432.079 315.281 443.374 357.416C454.666 399.557 429.653 442.875 387.513 454.171C345.37 465.464 302.05 440.453 290.758 398.31C279.471 356.169 304.479 312.846 346.619 301.555ZM116.869 222.099C122.429 218.205 129.656 217.574 135.808 220.443L261.376 279.002C267.529 281.87 271.701 287.814 272.291 294.576C272.88 301.333 269.804 307.898 264.245 311.791L150.75 391.257C145.361 395.03 138.416 395.757 132.395 393.19L131.815 392.931C125.665 390.064 121.51 384.115 120.918 377.353L108.846 239.329C108.254 232.567 111.311 225.992 116.869 222.099ZM284.278 160.623C284.28 128.038 310.701 101.606 343.285 101.606L383.281 101.606C415.864 101.608 442.279 128.034 442.28 160.618L442.28 200.614C442.28 233.195 415.868 259.604 383.286 259.607L343.272 259.612C310.691 259.61 284.281 233.2 284.278 200.619L284.278 160.623Z",
|
||||
"stroke": "white",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter0_d_21509_19682",
|
||||
"x": "55.7732",
|
||||
"y": "80.6057",
|
||||
"width": "443.312",
|
||||
"height": "461.277",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feColorMatrix",
|
||||
"attributes": {
|
||||
"in": "SourceAlpha",
|
||||
"type": "matrix",
|
||||
"values": "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0",
|
||||
"result": "hardAlpha"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feMorphology",
|
||||
"attributes": {
|
||||
"radius": "12",
|
||||
"operator": "erode",
|
||||
"in": "SourceAlpha",
|
||||
"result": "effect1_dropShadow_21509_19682"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feOffset",
|
||||
"attributes": {
|
||||
"dy": "32"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "32"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feComposite",
|
||||
"attributes": {
|
||||
"in2": "hardAlpha",
|
||||
"operator": "out"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feColorMatrix",
|
||||
"attributes": {
|
||||
"type": "matrix",
|
||||
"values": "0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.14 0"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "effect1_dropShadow_21509_19682"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "effect1_dropShadow_21509_19682",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "clipPath",
|
||||
"attributes": {
|
||||
"id": "clip0_21509_19682"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "480",
|
||||
"height": "480",
|
||||
"fill": "white",
|
||||
"transform": "translate(0 124.233) rotate(-15)"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "TemplateHeaderBg"
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './TemplateHeaderBg.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'TemplateHeaderBg'
|
||||
|
||||
export default Icon
|
||||
@ -1,6 +1,8 @@
|
||||
export { default as Google } from './Google'
|
||||
export { default as PartnerDark } from './PartnerDark'
|
||||
export { default as PartnerLight } from './PartnerLight'
|
||||
export { default as PluginHeaderBg } from './PluginHeaderBg'
|
||||
export { default as TemplateHeaderBg } from './TemplateHeaderBg'
|
||||
export { default as VerifiedDark } from './VerifiedDark'
|
||||
export { default as VerifiedLight } from './VerifiedLight'
|
||||
export { default as WebReader } from './WebReader'
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
{
|
||||
"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": "M11.6301 11.3333C11.6301 12.4379 10.7347 13.3333 9.63013 13.3333C8.52559 13.3333 7.63013 12.4379 7.63013 11.3333C7.63013 10.2287 8.52559 9.33325 9.63013 9.33325C10.7347 9.33325 11.6301 10.2287 11.6301 11.3333Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M3.1353 4.75464L6.67352 7.72353L2.33325 9.30327L3.1353 4.75464Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M9.79576 2.5L13.6595 3.53527L12.6242 7.399L8.7605 6.36371L9.79576 2.5Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Playground"
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Playground.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Playground'
|
||||
|
||||
export default Icon
|
||||
53
web/app/components/base/icons/src/vender/plugin/Plugin.json
Normal file
53
web/app/components/base/icons/src/vender/plugin/Plugin.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "20",
|
||||
"height": "20",
|
||||
"viewBox": "0 0 20 20",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M2.5 6.66675H17.5V15.8334H2.5V6.66675Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M4.16675 6.66659V3.33325H8.33341V6.66659",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M11.6667 6.66659V3.33325H15.8334V6.66659",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Plugin"
|
||||
}
|
||||
20
web/app/components/base/icons/src/vender/plugin/Plugin.tsx
Normal file
20
web/app/components/base/icons/src/vender/plugin/Plugin.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Plugin.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Plugin'
|
||||
|
||||
export default Icon
|
||||
@ -1,3 +1,5 @@
|
||||
export { default as BoxSparkleFill } from './BoxSparkleFill'
|
||||
export { default as LeftCorner } from './LeftCorner'
|
||||
export { default as Playground } from './Playground'
|
||||
export { default as Plugin } from './Plugin'
|
||||
export { default as Trigger } from './Trigger'
|
||||
|
||||
@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@ -41,6 +41,13 @@ export const preprocessThinkTag = (content: string) => {
|
||||
])(content)
|
||||
}
|
||||
|
||||
export const preprocessMarkdownContent = (content: string) => {
|
||||
return flow([
|
||||
preprocessThinkTag,
|
||||
preprocessLaTeX,
|
||||
])(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a URI for use in react-markdown, ensuring security and compatibility.
|
||||
* This function is designed to work with react-markdown v9+ which has stricter
|
||||
|
||||
@ -64,8 +64,8 @@ const InstallFromMarketplace = ({
|
||||
{
|
||||
!isAllPluginsLoading && !collapse && (
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton
|
||||
cardContainerClassName="grid grid-cols-2 gap-2"
|
||||
|
||||
@ -69,8 +69,8 @@ const InstallFromMarketplace = ({
|
||||
{
|
||||
!isAllPluginsLoading && !collapse && (
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton
|
||||
cardContainerClassName="grid grid-cols-2 gap-2"
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CardMoreInfo from '../card-more-info'
|
||||
|
||||
vi.mock('../base/download-count', () => ({
|
||||
default: ({ downloadCount }: { downloadCount: number }) => (
|
||||
<span data-testid="download-count">{downloadCount}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('CardMoreInfo', () => {
|
||||
it('renders tags with # prefix', () => {
|
||||
render(<CardMoreInfo tags={['search', 'agent']} />)
|
||||
expect(screen.getByText('search')).toBeInTheDocument()
|
||||
expect(screen.getByText('agent')).toBeInTheDocument()
|
||||
// # prefixes
|
||||
const hashmarks = screen.getAllByText('#')
|
||||
expect(hashmarks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders download count when provided', () => {
|
||||
render(<CardMoreInfo downloadCount={1000} tags={[]} />)
|
||||
expect(screen.getByTestId('download-count')).toHaveTextContent('1000')
|
||||
})
|
||||
|
||||
it('does not render download count when undefined', () => {
|
||||
render(<CardMoreInfo tags={['tag1']} />)
|
||||
expect(screen.queryByTestId('download-count')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders separator between download count and tags', () => {
|
||||
render(<CardMoreInfo downloadCount={500} tags={['test']} />)
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render separator when no tags', () => {
|
||||
render(<CardMoreInfo downloadCount={500} tags={[]} />)
|
||||
expect(screen.queryByText('·')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render separator when no download count', () => {
|
||||
render(<CardMoreInfo tags={['tag1']} />)
|
||||
expect(screen.queryByText('·')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles empty tags array', () => {
|
||||
const { container } = render(<CardMoreInfo tags={[]} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -181,7 +181,7 @@ describe('Card', () => {
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.getByText('my-org')).toBeInTheDocument()
|
||||
expect(screen.getByText('my-plugin')).toBeInTheDocument()
|
||||
expect(screen.queryByText('my-plugin')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin icon', () => {
|
||||
@ -596,7 +596,7 @@ describe('Card', () => {
|
||||
|
||||
render(<Card payload={plugin} />)
|
||||
|
||||
expect(screen.getByText('plugin-with-special-chars!@#$%')).toBeInTheDocument()
|
||||
expect(screen.getByText('org<script>alert(1)</script>')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long title', () => {
|
||||
|
||||
@ -2,6 +2,12 @@ import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import DownloadCount from '../download-count'
|
||||
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key === 'marketplace.installs' ? 'installs' : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatNumber: (n: number) => {
|
||||
if (n >= 1000)
|
||||
@ -13,16 +19,16 @@ vi.mock('@/utils/format', () => ({
|
||||
describe('DownloadCount', () => {
|
||||
it('renders formatted download count', () => {
|
||||
render(<DownloadCount downloadCount={1500} />)
|
||||
expect(screen.getByText('1.5k')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.5k installs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders small numbers directly', () => {
|
||||
render(<DownloadCount downloadCount={42} />)
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
expect(screen.getByText('42 installs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders zero download count', () => {
|
||||
render(<DownloadCount downloadCount={0} />)
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(screen.getByText('0 installs')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { RiInstallLine } from '@remixicon/react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import * as React from 'react'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
@ -9,10 +9,13 @@ type Props = {
|
||||
const DownloadCountComponent = ({
|
||||
downloadCount,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1 text-text-tertiary">
|
||||
<RiInstallLine className="h-3 w-3 shrink-0" />
|
||||
<div className="system-xs-regular">{formatNumber(downloadCount)}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{formatNumber(downloadCount)}
|
||||
{' '}
|
||||
{t('marketplace.installs')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import DownloadCount from './download-count'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
orgName?: string
|
||||
packageName: string
|
||||
packageName?: string
|
||||
packageNameClassName?: string
|
||||
downloadCount?: number
|
||||
linkToOrg?: boolean
|
||||
}
|
||||
|
||||
const OrgInfo = ({
|
||||
@ -12,7 +16,42 @@ const OrgInfo = ({
|
||||
orgName,
|
||||
packageName,
|
||||
packageNameClassName,
|
||||
downloadCount,
|
||||
linkToOrg = true,
|
||||
}: Props) => {
|
||||
// New format: "by {orgName} · {downloadCount} installs" (for marketplace cards)
|
||||
if (downloadCount !== undefined) {
|
||||
return (
|
||||
<div className={cn('system-xs-regular flex h-4 items-center gap-2 text-text-tertiary', className)}>
|
||||
{orgName && (
|
||||
<span className="shrink-0">
|
||||
<span className="mr-1 text-text-tertiary">by</span>
|
||||
{linkToOrg
|
||||
? (
|
||||
<Link
|
||||
href={`/creator/${orgName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-text-secondary hover:underline"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{orgName}
|
||||
</Link>
|
||||
)
|
||||
: (
|
||||
<span className="text-text-tertiary">
|
||||
{orgName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0">·</span>
|
||||
<DownloadCount downloadCount={downloadCount} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Legacy format: "{orgName} / {packageName}" (for plugin detail panels)
|
||||
return (
|
||||
<div className={cn('flex h-4 items-center space-x-0.5', className)}>
|
||||
{orgName && (
|
||||
@ -21,9 +60,11 @@ const OrgInfo = ({
|
||||
<span className="system-xs-regular shrink-0 text-text-quaternary">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
|
||||
{packageName}
|
||||
</span>
|
||||
{packageName && (
|
||||
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
|
||||
{packageName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import DownloadCount from './base/download-count'
|
||||
|
||||
type Props = {
|
||||
downloadCount?: number
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const CardMoreInfoComponent = ({
|
||||
downloadCount,
|
||||
tags,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex h-5 items-center">
|
||||
{downloadCount !== undefined && <DownloadCount downloadCount={downloadCount} />}
|
||||
{downloadCount !== undefined && tags && tags.length > 0 && <div className="system-xs-regular mx-2 text-text-quaternary">·</div>}
|
||||
{tags && tags.length > 0 && (
|
||||
<>
|
||||
<div className="flex h-4 flex-wrap space-x-2 overflow-hidden">
|
||||
{tags.map(tag => (
|
||||
<div
|
||||
key={tag}
|
||||
className="system-xs-regular flex max-w-[120px] space-x-1 overflow-hidden"
|
||||
title={`# ${tag}`}
|
||||
>
|
||||
<span className="text-text-quaternary">#</span>
|
||||
<span className="truncate text-text-tertiary">{tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Memoize to prevent unnecessary re-renders when tags array hasn't changed
|
||||
const CardMoreInfo = React.memo(CardMoreInfoComponent)
|
||||
|
||||
export default CardMoreInfo
|
||||
34
web/app/components/plugins/card/card-tags.tsx
Normal file
34
web/app/components/plugins/card/card-tags.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { RiPriceTag3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
|
||||
type Props = {
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const CardTagsComponent = ({
|
||||
tags,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="mt-2 flex min-h-[20px] items-center gap-1">
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 overflow-hidden">
|
||||
{tags.slice(0, 2).map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex max-w-[100px] items-center gap-0.5 truncate rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]"
|
||||
title={tag}
|
||||
>
|
||||
<RiPriceTag3Line className="h-3 w-3 shrink-0 text-text-quaternary" />
|
||||
<span className="system-2xs-medium-uppercase text-text-tertiary">{tag.toUpperCase()}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Memoize to prevent unnecessary re-renders when tags array hasn't changed
|
||||
const CardTags = React.memo(CardTagsComponent)
|
||||
|
||||
export default CardTags
|
||||
@ -34,6 +34,7 @@ export type Props = {
|
||||
isLoading?: boolean
|
||||
loadingFileName?: string
|
||||
limitedInstall?: boolean
|
||||
disableOrgLink?: boolean
|
||||
}
|
||||
|
||||
const Card = ({
|
||||
@ -48,12 +49,13 @@ const Card = ({
|
||||
isLoading = false,
|
||||
loadingFileName,
|
||||
limitedInstall = false,
|
||||
disableOrgLink = false,
|
||||
}: Props) => {
|
||||
const locale = useGetLanguage()
|
||||
const { t } = useTranslation()
|
||||
const { categoriesMap } = useCategories(true)
|
||||
const currentWorkspaceId = useSelector(s => s.currentWorkspace.id)
|
||||
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [], from } = payload
|
||||
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [], from, install_count } = payload
|
||||
const { theme } = useTheme()
|
||||
const iconSrc = getPluginCardIconUrl(
|
||||
{ from, name, org, type },
|
||||
@ -93,7 +95,8 @@ const Card = ({
|
||||
<OrgInfo
|
||||
className="mt-0.5"
|
||||
orgName={org}
|
||||
packageName={name}
|
||||
downloadCount={install_count}
|
||||
linkToOrg={!disableOrgLink}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,16 +4,32 @@ import { Provider as JotaiProvider } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import {
|
||||
useActivePluginType,
|
||||
useActivePluginCategory,
|
||||
useFilterPluginTags,
|
||||
useMarketplaceMoreClick,
|
||||
useMarketplacePluginSort,
|
||||
useMarketplacePluginSortValue,
|
||||
useMarketplaceSearchMode,
|
||||
useMarketplaceSort,
|
||||
useMarketplaceSortValue,
|
||||
useSearchPluginText,
|
||||
useSetMarketplaceSort,
|
||||
useSearchText,
|
||||
useSetMarketplacePluginSort,
|
||||
} from '../atoms'
|
||||
import { DEFAULT_SORT } from '../constants'
|
||||
import { DEFAULT_PLUGIN_SORT } from '../constants'
|
||||
|
||||
const { mockRouterPush, mockNavigation } = vi.hoisted(() => ({
|
||||
mockRouterPush: vi.fn(),
|
||||
mockNavigation: {
|
||||
pathname: '/plugins',
|
||||
params: {} as Record<string, string | undefined>,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
}),
|
||||
usePathname: () => mockNavigation.pathname,
|
||||
useParams: () => mockNavigation.params,
|
||||
}))
|
||||
|
||||
const createWrapper = (searchParams = '') => {
|
||||
const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams })
|
||||
@ -30,28 +46,30 @@ const createWrapper = (searchParams = '') => {
|
||||
describe('Marketplace sort atoms', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNavigation.pathname = '/plugins'
|
||||
mockNavigation.params = {}
|
||||
})
|
||||
|
||||
it('should return default sort value from useMarketplaceSort', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })
|
||||
const { result } = renderHook(() => useMarketplacePluginSort(), { wrapper })
|
||||
|
||||
expect(result.current[0]).toEqual(DEFAULT_SORT)
|
||||
expect(result.current[0]).toEqual(DEFAULT_PLUGIN_SORT)
|
||||
expect(typeof result.current[1]).toBe('function')
|
||||
})
|
||||
|
||||
it('should return default sort value from useMarketplaceSortValue', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useMarketplaceSortValue(), { wrapper })
|
||||
const { result } = renderHook(() => useMarketplacePluginSortValue(), { wrapper })
|
||||
|
||||
expect(result.current).toEqual(DEFAULT_SORT)
|
||||
expect(result.current).toEqual(DEFAULT_PLUGIN_SORT)
|
||||
})
|
||||
|
||||
it('should return setter from useSetMarketplaceSort', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => ({
|
||||
setSort: useSetMarketplaceSort(),
|
||||
sortValue: useMarketplaceSortValue(),
|
||||
setSort: useSetMarketplacePluginSort(),
|
||||
sortValue: useMarketplacePluginSortValue(),
|
||||
}), { wrapper })
|
||||
|
||||
act(() => {
|
||||
@ -63,7 +81,7 @@ describe('Marketplace sort atoms', () => {
|
||||
|
||||
it('should update sort value via useMarketplaceSort setter', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })
|
||||
const { result } = renderHook(() => useMarketplacePluginSort(), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current[1]({ sortBy: 'created_at', sortOrder: 'ASC' })
|
||||
@ -73,14 +91,16 @@ describe('Marketplace sort atoms', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSearchPluginText', () => {
|
||||
describe('useSearchText', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNavigation.pathname = '/plugins'
|
||||
mockNavigation.params = {}
|
||||
})
|
||||
|
||||
it('should return empty string as default', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
|
||||
const { result } = renderHook(() => useSearchText(), { wrapper })
|
||||
|
||||
expect(result.current[0]).toBe('')
|
||||
expect(typeof result.current[1]).toBe('function')
|
||||
@ -88,14 +108,14 @@ describe('useSearchPluginText', () => {
|
||||
|
||||
it('should parse q from search params', () => {
|
||||
const { wrapper } = createWrapper('?q=hello')
|
||||
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
|
||||
const { result } = renderHook(() => useSearchText(), { wrapper })
|
||||
|
||||
expect(result.current[0]).toBe('hello')
|
||||
})
|
||||
|
||||
it('should expose a setter function for search text', async () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
|
||||
const { result } = renderHook(() => useSearchText(), { wrapper })
|
||||
|
||||
await act(async () => {
|
||||
result.current[1]('search term')
|
||||
@ -105,21 +125,23 @@ describe('useSearchPluginText', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useActivePluginType', () => {
|
||||
describe('useActivePluginCategory', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNavigation.pathname = '/plugins'
|
||||
mockNavigation.params = {}
|
||||
})
|
||||
|
||||
it('should return "all" as default category', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useActivePluginType(), { wrapper })
|
||||
const { result } = renderHook(() => useActivePluginCategory(), { wrapper })
|
||||
|
||||
expect(result.current[0]).toBe('all')
|
||||
})
|
||||
|
||||
it('should parse category from search params', () => {
|
||||
const { wrapper } = createWrapper('?category=tool')
|
||||
const { result } = renderHook(() => useActivePluginType(), { wrapper })
|
||||
const { result } = renderHook(() => useActivePluginCategory(), { wrapper })
|
||||
|
||||
expect(result.current[0]).toBe('tool')
|
||||
})
|
||||
@ -128,6 +150,8 @@ describe('useActivePluginType', () => {
|
||||
describe('useFilterPluginTags', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNavigation.pathname = '/plugins'
|
||||
mockNavigation.params = {}
|
||||
})
|
||||
|
||||
it('should return empty array as default', () => {
|
||||
@ -148,6 +172,8 @@ describe('useFilterPluginTags', () => {
|
||||
describe('useMarketplaceSearchMode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNavigation.pathname = '/plugins'
|
||||
mockNavigation.params = {}
|
||||
})
|
||||
|
||||
it('should return false when no search text, no tags, and category has collections (all)', () => {
|
||||
@ -161,7 +187,7 @@ describe('useMarketplaceSearchMode', () => {
|
||||
const { wrapper } = createWrapper('?q=test&category=all')
|
||||
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
expect(result.current).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return true when tags are present', () => {
|
||||
@ -189,6 +215,8 @@ describe('useMarketplaceSearchMode', () => {
|
||||
describe('useMarketplaceMoreClick', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNavigation.pathname = '/plugins'
|
||||
mockNavigation.params = {}
|
||||
})
|
||||
|
||||
it('should return a callback function', () => {
|
||||
@ -202,8 +230,8 @@ describe('useMarketplaceMoreClick', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => ({
|
||||
handleMoreClick: useMarketplaceMoreClick(),
|
||||
sort: useMarketplaceSortValue(),
|
||||
searchText: useSearchPluginText()[0],
|
||||
sort: useMarketplacePluginSortValue(),
|
||||
searchText: useSearchText()[0],
|
||||
}), { wrapper })
|
||||
|
||||
const sortBefore = result.current.sort
|
||||
@ -222,7 +250,7 @@ describe('useMarketplaceMoreClick', () => {
|
||||
|
||||
const { result } = renderHook(() => ({
|
||||
handleMoreClick: useMarketplaceMoreClick(),
|
||||
sort: useMarketplaceSortValue(),
|
||||
sort: useMarketplacePluginSortValue(),
|
||||
}), { wrapper })
|
||||
|
||||
act(() => {
|
||||
@ -240,13 +268,13 @@ describe('useMarketplaceMoreClick', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => ({
|
||||
handleMoreClick: useMarketplaceMoreClick(),
|
||||
sort: useMarketplaceSortValue(),
|
||||
sort: useMarketplacePluginSortValue(),
|
||||
}), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.handleMoreClick({})
|
||||
})
|
||||
|
||||
expect(result.current.sort).toEqual(DEFAULT_SORT)
|
||||
expect(result.current.sort).toEqual(DEFAULT_PLUGIN_SORT)
|
||||
})
|
||||
})
|
||||
|
||||
@ -42,8 +42,10 @@ const mockCollectionPlugins = vi.fn()
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceClient: {
|
||||
collections: (...args: unknown[]) => mockCollections(...args),
|
||||
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
|
||||
plugins: {
|
||||
collections: (...args: unknown[]) => mockCollections(...args),
|
||||
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -90,8 +92,8 @@ describe('useMarketplaceCollectionsAndPlugins (integration)', () => {
|
||||
expect(result.current.isSuccess).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.marketplaceCollections).toBeDefined()
|
||||
expect(result.current.marketplaceCollectionPluginsMap).toBeDefined()
|
||||
expect(result.current.pluginCollections).toBeDefined()
|
||||
expect(result.current.pluginCollectionPluginsMap).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle query with empty params (truthy)', async () => {
|
||||
|
||||
@ -118,10 +118,10 @@ describe('useMarketplaceCollectionsAndPlugins', () => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.isSuccess).toBe(false)
|
||||
expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function')
|
||||
expect(typeof result.current.setMarketplaceCollections).toBe('function')
|
||||
expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function')
|
||||
expect(result.current.marketplaceCollections).toBeUndefined()
|
||||
expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined()
|
||||
expect(typeof result.current.setPluginCollections).toBe('function')
|
||||
expect(typeof result.current.setPluginCollectionPluginsMap).toBe('function')
|
||||
expect(result.current.pluginCollections).toBeUndefined()
|
||||
expect(result.current.pluginCollectionPluginsMap).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -427,8 +427,8 @@ describe('Hooks queryFn Coverage', () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true)
|
||||
})
|
||||
expect(result.current.marketplaceCollections).toBeDefined()
|
||||
expect(result.current.marketplaceCollectionPluginsMap).toBeDefined()
|
||||
expect(result.current.pluginCollections).toBeDefined()
|
||||
expect(result.current.pluginCollectionPluginsMap).toBeDefined()
|
||||
})
|
||||
|
||||
it('should test getNextPageParam via fetchNextPage behavior', async () => {
|
||||
|
||||
@ -2,6 +2,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('next/headers', () => ({
|
||||
headers: async () => ({
|
||||
get: (name: string) => name === 'sec-fetch-dest' ? 'document' : null,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: '/api',
|
||||
APP_VERSION: '1.0.0',
|
||||
@ -15,15 +21,24 @@ vi.mock('@/utils/var', () => ({
|
||||
|
||||
const mockCollections = vi.fn()
|
||||
const mockCollectionPlugins = vi.fn()
|
||||
const mockSearchAdvanced = vi.fn()
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceClient: {
|
||||
collections: (...args: unknown[]) => mockCollections(...args),
|
||||
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
|
||||
plugins: {
|
||||
collections: (...args: unknown[]) => mockCollections(...args),
|
||||
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
|
||||
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
|
||||
},
|
||||
},
|
||||
marketplaceQuery: {
|
||||
collections: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'collections', params],
|
||||
plugins: {
|
||||
collections: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'plugins', 'collections', params],
|
||||
},
|
||||
searchAdvanced: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'plugins', 'searchAdvanced', params],
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
@ -46,6 +61,9 @@ describe('HydrateQueryClient', () => {
|
||||
mockCollectionPlugins.mockResolvedValue({
|
||||
data: { plugins: [] },
|
||||
})
|
||||
mockSearchAdvanced.mockResolvedValue({
|
||||
data: { plugins: [], total: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
it('should render children within HydrationBoundary', async () => {
|
||||
@ -81,6 +99,7 @@ describe('HydrateQueryClient', () => {
|
||||
|
||||
await HydrateQueryClient({
|
||||
searchParams: Promise.resolve({ category: 'all' }),
|
||||
isMarketplacePlatform: true,
|
||||
children: <div>Child</div>,
|
||||
})
|
||||
|
||||
@ -92,31 +111,36 @@ describe('HydrateQueryClient', () => {
|
||||
|
||||
await HydrateQueryClient({
|
||||
searchParams: Promise.resolve({ category: 'tool' }),
|
||||
isMarketplacePlatform: true,
|
||||
children: <div>Child</div>,
|
||||
})
|
||||
|
||||
expect(mockCollections).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not prefetch when category does not have collections (model)', async () => {
|
||||
it('should prefetch search results when category does not have collections (model)', async () => {
|
||||
const { HydrateQueryClient } = await import('../hydration-server')
|
||||
|
||||
await HydrateQueryClient({
|
||||
searchParams: Promise.resolve({ category: 'model' }),
|
||||
isMarketplacePlatform: true,
|
||||
children: <div>Child</div>,
|
||||
})
|
||||
|
||||
expect(mockCollections).not.toHaveBeenCalled()
|
||||
expect(mockCollections).toHaveBeenCalled()
|
||||
expect(mockSearchAdvanced).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not prefetch when category does not have collections (bundle)', async () => {
|
||||
it('should prefetch search results when category does not have collections (bundle)', async () => {
|
||||
const { HydrateQueryClient } = await import('../hydration-server')
|
||||
|
||||
await HydrateQueryClient({
|
||||
searchParams: Promise.resolve({ category: 'bundle' }),
|
||||
isMarketplacePlatform: true,
|
||||
children: <div>Child</div>,
|
||||
})
|
||||
|
||||
expect(mockCollections).not.toHaveBeenCalled()
|
||||
expect(mockCollections).toHaveBeenCalled()
|
||||
expect(mockSearchAdvanced).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,24 +8,44 @@ vi.mock('@/context/query-client', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../hydration-server', () => ({
|
||||
HydrateQueryClient: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="hydration-client">{children}</div>
|
||||
HydrateQueryClient: ({
|
||||
children,
|
||||
isMarketplacePlatform,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
isMarketplacePlatform?: boolean
|
||||
}) => (
|
||||
<div data-testid="hydrate-query-client" data-marketplace-platform={String(Boolean(isMarketplacePlatform))}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../description', () => ({
|
||||
default: () => <div data-testid="description">Description</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../list/list-wrapper', () => ({
|
||||
default: ({ showInstallButton }: { showInstallButton: boolean }) => (
|
||||
<div data-testid="list-wrapper" data-show-install={showInstallButton}>ListWrapper</div>
|
||||
vi.mock('../hydration-client', () => ({
|
||||
HydrateClient: ({
|
||||
children,
|
||||
isMarketplacePlatform,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
isMarketplacePlatform?: boolean
|
||||
}) => (
|
||||
<div data-testid="hydrate-client" data-marketplace-platform={String(Boolean(isMarketplacePlatform))}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../sticky-search-and-switch-wrapper', () => ({
|
||||
default: ({ pluginTypeSwitchClassName }: { pluginTypeSwitchClassName?: string }) => (
|
||||
<div data-testid="sticky-wrapper" data-classname={pluginTypeSwitchClassName}>StickyWrapper</div>
|
||||
vi.mock('../marketplace-header', () => ({
|
||||
default: ({
|
||||
marketplaceNav,
|
||||
}: {
|
||||
marketplaceNav?: React.ReactNode
|
||||
}) => (
|
||||
<div data-testid="marketplace-header">
|
||||
{marketplaceNav}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../marketplace-content', () => ({
|
||||
default: ({ showInstallButton }: { showInstallButton?: boolean }) => (
|
||||
<div data-testid="marketplace-content" data-show-install={String(Boolean(showInstallButton))}>MarketplaceContent</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -47,20 +67,20 @@ describe('Marketplace', () => {
|
||||
const { getByTestId } = render(element as React.ReactElement)
|
||||
|
||||
expect(getByTestId('tanstack-initializer')).toBeInTheDocument()
|
||||
expect(getByTestId('hydration-client')).toBeInTheDocument()
|
||||
expect(getByTestId('description')).toBeInTheDocument()
|
||||
expect(getByTestId('sticky-wrapper')).toBeInTheDocument()
|
||||
expect(getByTestId('list-wrapper')).toBeInTheDocument()
|
||||
expect(getByTestId('hydrate-query-client')).toBeInTheDocument()
|
||||
expect(getByTestId('hydrate-client')).toBeInTheDocument()
|
||||
expect(getByTestId('marketplace-header')).toBeInTheDocument()
|
||||
expect(getByTestId('marketplace-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass showInstallButton=true by default to ListWrapper', async () => {
|
||||
it('should pass showInstallButton=true by default to MarketplaceContent', async () => {
|
||||
const Marketplace = (await import('../index')).default
|
||||
const element = await Marketplace({})
|
||||
|
||||
const { getByTestId } = render(element as React.ReactElement)
|
||||
|
||||
const listWrapper = getByTestId('list-wrapper')
|
||||
expect(listWrapper.getAttribute('data-show-install')).toBe('true')
|
||||
const marketplaceContent = getByTestId('marketplace-content')
|
||||
expect(marketplaceContent.getAttribute('data-show-install')).toBe('true')
|
||||
})
|
||||
|
||||
it('should pass showInstallButton=false when specified', async () => {
|
||||
@ -69,27 +89,26 @@ describe('Marketplace', () => {
|
||||
|
||||
const { getByTestId } = render(element as React.ReactElement)
|
||||
|
||||
const listWrapper = getByTestId('list-wrapper')
|
||||
expect(listWrapper.getAttribute('data-show-install')).toBe('false')
|
||||
const marketplaceContent = getByTestId('marketplace-content')
|
||||
expect(marketplaceContent.getAttribute('data-show-install')).toBe('false')
|
||||
})
|
||||
|
||||
it('should pass pluginTypeSwitchClassName to StickySearchAndSwitchWrapper', async () => {
|
||||
it('should pass marketplaceNav to MarketplaceHeader', async () => {
|
||||
const Marketplace = (await import('../index')).default
|
||||
const element = await Marketplace({ pluginTypeSwitchClassName: 'top-14' })
|
||||
const element = await Marketplace({ marketplaceNav: <div data-testid="nav">Nav</div> })
|
||||
|
||||
const { getByTestId } = render(element as React.ReactElement)
|
||||
|
||||
const stickyWrapper = getByTestId('sticky-wrapper')
|
||||
expect(stickyWrapper.getAttribute('data-classname')).toBe('top-14')
|
||||
expect(getByTestId('nav')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render without pluginTypeSwitchClassName', async () => {
|
||||
it('should pass isMarketplacePlatform to hydrate wrappers', async () => {
|
||||
const Marketplace = (await import('../index')).default
|
||||
const element = await Marketplace({})
|
||||
const element = await Marketplace({ isMarketplacePlatform: true })
|
||||
|
||||
const { getByTestId } = render(element as React.ReactElement)
|
||||
|
||||
const stickyWrapper = getByTestId('sticky-wrapper')
|
||||
expect(stickyWrapper.getAttribute('data-classname')).toBeNull()
|
||||
expect(getByTestId('hydrate-query-client').getAttribute('data-marketplace-platform')).toBe('true')
|
||||
expect(getByTestId('hydrate-client').getAttribute('data-marketplace-platform')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,7 +3,23 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import PluginTypeSwitch from '../plugin-type-switch'
|
||||
import { PluginCategorySwitch } from '../category-switch/plugin'
|
||||
|
||||
const { mockRouterPush, mockNavigation } = vi.hoisted(() => ({
|
||||
mockRouterPush: vi.fn(),
|
||||
mockNavigation: {
|
||||
pathname: '/plugins',
|
||||
params: {} as Record<string, string | undefined>,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
}),
|
||||
usePathname: () => mockNavigation.pathname,
|
||||
useParams: () => mockNavigation.params,
|
||||
}))
|
||||
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
@ -35,14 +51,16 @@ const createWrapper = (searchParams = '') => {
|
||||
return { Wrapper }
|
||||
}
|
||||
|
||||
describe('PluginTypeSwitch', () => {
|
||||
describe('PluginCategorySwitch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNavigation.pathname = '/plugins'
|
||||
mockNavigation.params = {}
|
||||
})
|
||||
|
||||
it('should render all category options', () => {
|
||||
const { Wrapper } = createWrapper()
|
||||
render(<PluginTypeSwitch />, { wrapper: Wrapper })
|
||||
render(<PluginCategorySwitch />, { wrapper: Wrapper })
|
||||
|
||||
expect(screen.getByText('All')).toBeInTheDocument()
|
||||
expect(screen.getByText('Models')).toBeInTheDocument()
|
||||
@ -56,7 +74,7 @@ describe('PluginTypeSwitch', () => {
|
||||
|
||||
it('should apply active styling to current category', () => {
|
||||
const { Wrapper } = createWrapper('?category=all')
|
||||
render(<PluginTypeSwitch />, { wrapper: Wrapper })
|
||||
render(<PluginCategorySwitch />, { wrapper: Wrapper })
|
||||
|
||||
const allButton = screen.getByText('All').closest('div')
|
||||
expect(allButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
|
||||
@ -64,7 +82,7 @@ describe('PluginTypeSwitch', () => {
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { Wrapper } = createWrapper()
|
||||
const { container } = render(<PluginTypeSwitch className="custom-class" />, { wrapper: Wrapper })
|
||||
const { container } = render(<PluginCategorySwitch className="custom-class" />, { wrapper: Wrapper })
|
||||
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv.className).toContain('custom-class')
|
||||
@ -72,7 +90,7 @@ describe('PluginTypeSwitch', () => {
|
||||
|
||||
it('should update category when option is clicked', () => {
|
||||
const { Wrapper } = createWrapper('?category=all')
|
||||
render(<PluginTypeSwitch />, { wrapper: Wrapper })
|
||||
render(<PluginCategorySwitch />, { wrapper: Wrapper })
|
||||
|
||||
fireEvent.click(screen.getByText('Models'))
|
||||
|
||||
@ -82,7 +100,7 @@ describe('PluginTypeSwitch', () => {
|
||||
|
||||
it('should handle clicking on category with collections (Tools)', () => {
|
||||
const { Wrapper } = createWrapper('?category=model')
|
||||
render(<PluginTypeSwitch />, { wrapper: Wrapper })
|
||||
render(<PluginCategorySwitch />, { wrapper: Wrapper })
|
||||
|
||||
fireEvent.click(screen.getByText('Tools'))
|
||||
|
||||
@ -92,7 +110,7 @@ describe('PluginTypeSwitch', () => {
|
||||
|
||||
it('should handle clicking on category without collections (Models)', () => {
|
||||
const { Wrapper } = createWrapper('?category=all')
|
||||
render(<PluginTypeSwitch />, { wrapper: Wrapper })
|
||||
render(<PluginCategorySwitch />, { wrapper: Wrapper })
|
||||
|
||||
fireEvent.click(screen.getByText('Models'))
|
||||
|
||||
@ -102,7 +120,7 @@ describe('PluginTypeSwitch', () => {
|
||||
|
||||
it('should handle clicking on bundles', () => {
|
||||
const { Wrapper } = createWrapper('?category=all')
|
||||
render(<PluginTypeSwitch />, { wrapper: Wrapper })
|
||||
render(<PluginCategorySwitch />, { wrapper: Wrapper })
|
||||
|
||||
fireEvent.click(screen.getByText('Bundles'))
|
||||
|
||||
@ -112,7 +130,7 @@ describe('PluginTypeSwitch', () => {
|
||||
|
||||
it('should handle clicking on each category', () => {
|
||||
const { Wrapper } = createWrapper('?category=all')
|
||||
render(<PluginTypeSwitch />, { wrapper: Wrapper })
|
||||
render(<PluginCategorySwitch />, { wrapper: Wrapper })
|
||||
|
||||
const categories = ['All', 'Models', 'Tools', 'Data Sources', 'Triggers', 'Agents', 'Extensions', 'Bundles']
|
||||
categories.forEach((category) => {
|
||||
@ -125,7 +143,7 @@ describe('PluginTypeSwitch', () => {
|
||||
|
||||
it('should render icons for categories that have them', () => {
|
||||
const { Wrapper } = createWrapper()
|
||||
const { container } = render(<PluginTypeSwitch />, { wrapper: Wrapper })
|
||||
const { container } = render(<PluginCategorySwitch />, { wrapper: Wrapper })
|
||||
|
||||
// "All" has no icon (icon: null), others should have SVG icons
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
|
||||
@ -20,16 +20,20 @@ const mockSearchAdvanced = vi.fn()
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceClient: {
|
||||
collections: (...args: unknown[]) => mockCollections(...args),
|
||||
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
|
||||
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
|
||||
plugins: {
|
||||
collections: (...args: unknown[]) => mockCollections(...args),
|
||||
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
|
||||
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
|
||||
},
|
||||
},
|
||||
marketplaceQuery: {
|
||||
collections: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'collections', params],
|
||||
},
|
||||
searchAdvanced: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params],
|
||||
plugins: {
|
||||
collections: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'plugins', 'collections', params],
|
||||
},
|
||||
searchAdvanced: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'plugins', 'searchAdvanced', params],
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -5,6 +5,10 @@ import { Provider as JotaiProvider } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: '/api',
|
||||
APP_VERSION: '1.0.0',
|
||||
@ -19,19 +23,38 @@ vi.mock('@/utils/var', () => ({
|
||||
const mockCollections = vi.fn()
|
||||
const mockCollectionPlugins = vi.fn()
|
||||
const mockSearchAdvanced = vi.fn()
|
||||
const { mockRouterPush, mockNavigation } = vi.hoisted(() => ({
|
||||
mockRouterPush: vi.fn(),
|
||||
mockNavigation: {
|
||||
pathname: '/plugins',
|
||||
params: {} as Record<string, string | undefined>,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
}),
|
||||
usePathname: () => mockNavigation.pathname,
|
||||
useParams: () => mockNavigation.params,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceClient: {
|
||||
collections: (...args: unknown[]) => mockCollections(...args),
|
||||
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
|
||||
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
|
||||
plugins: {
|
||||
collections: (...args: unknown[]) => mockCollections(...args),
|
||||
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
|
||||
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
|
||||
},
|
||||
},
|
||||
marketplaceQuery: {
|
||||
collections: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'collections', params],
|
||||
},
|
||||
searchAdvanced: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params],
|
||||
plugins: {
|
||||
collections: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'plugins', 'collections', params],
|
||||
},
|
||||
searchAdvanced: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'plugins', 'searchAdvanced', params],
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
@ -55,9 +78,11 @@ const createWrapper = (searchParams = '') => {
|
||||
return { Wrapper, queryClient }
|
||||
}
|
||||
|
||||
describe('useMarketplaceData', () => {
|
||||
describe('usePluginsMarketplaceData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNavigation.pathname = '/plugins'
|
||||
mockNavigation.params = {}
|
||||
|
||||
mockCollections.mockResolvedValue({
|
||||
data: {
|
||||
@ -80,7 +105,7 @@ describe('useMarketplaceData', () => {
|
||||
})
|
||||
|
||||
it('should return initial state with loading and collections data', async () => {
|
||||
const { useMarketplaceData } = await import('../state')
|
||||
const { usePluginsMarketplaceData } = await import('../state')
|
||||
const { Wrapper } = createWrapper('?category=all')
|
||||
|
||||
// Create a mock container for scroll
|
||||
@ -88,14 +113,14 @@ describe('useMarketplaceData', () => {
|
||||
container.id = 'marketplace-container'
|
||||
document.body.appendChild(container)
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
|
||||
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.marketplaceCollections).toBeDefined()
|
||||
expect(result.current.marketplaceCollectionPluginsMap).toBeDefined()
|
||||
expect(result.current.pluginCollections).toBeDefined()
|
||||
expect(result.current.pluginCollectionPluginsMap).toBeDefined()
|
||||
expect(result.current.page).toBeDefined()
|
||||
expect(result.current.isFetchingNextPage).toBe(false)
|
||||
|
||||
@ -103,14 +128,14 @@ describe('useMarketplaceData', () => {
|
||||
})
|
||||
|
||||
it('should return search mode data when search text is present', async () => {
|
||||
const { useMarketplaceData } = await import('../state')
|
||||
const { usePluginsMarketplaceData } = await import('../state')
|
||||
const { Wrapper } = createWrapper('?category=all&q=test')
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.id = 'marketplace-container'
|
||||
document.body.appendChild(container)
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
|
||||
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
@ -123,7 +148,7 @@ describe('useMarketplaceData', () => {
|
||||
})
|
||||
|
||||
it('should return plugins undefined in collection mode (not search mode)', async () => {
|
||||
const { useMarketplaceData } = await import('../state')
|
||||
const { usePluginsMarketplaceData } = await import('../state')
|
||||
// "all" category with no search → collection mode
|
||||
const { Wrapper } = createWrapper('?category=all')
|
||||
|
||||
@ -131,7 +156,7 @@ describe('useMarketplaceData', () => {
|
||||
container.id = 'marketplace-container'
|
||||
document.body.appendChild(container)
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
|
||||
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
@ -144,14 +169,14 @@ describe('useMarketplaceData', () => {
|
||||
})
|
||||
|
||||
it('should enable search for category without collections (e.g. model)', async () => {
|
||||
const { useMarketplaceData } = await import('../state')
|
||||
const { usePluginsMarketplaceData } = await import('../state')
|
||||
const { Wrapper } = createWrapper('?category=model')
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.id = 'marketplace-container'
|
||||
document.body.appendChild(container)
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
|
||||
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
@ -177,7 +202,7 @@ describe('useMarketplaceData', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const { useMarketplaceData } = await import('../state')
|
||||
const { usePluginsMarketplaceData } = await import('../state')
|
||||
// Use "model" to force search mode
|
||||
const { Wrapper } = createWrapper('?category=model')
|
||||
|
||||
@ -189,7 +214,7 @@ describe('useMarketplaceData', () => {
|
||||
Object.defineProperty(container, 'scrollHeight', { value: 1000, writable: true, configurable: true })
|
||||
Object.defineProperty(container, 'clientHeight', { value: 200, writable: true, configurable: true })
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
|
||||
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
|
||||
|
||||
// Wait for data to fully load (isFetching becomes false, plugins become available)
|
||||
await waitFor(() => {
|
||||
@ -206,7 +231,7 @@ describe('useMarketplaceData', () => {
|
||||
})
|
||||
|
||||
it('should handle tags filter in search mode', async () => {
|
||||
const { useMarketplaceData } = await import('../state')
|
||||
const { usePluginsMarketplaceData } = await import('../state')
|
||||
// tags in URL triggers search mode
|
||||
const { Wrapper } = createWrapper('?category=all&tags=search')
|
||||
|
||||
@ -214,7 +239,7 @@ describe('useMarketplaceData', () => {
|
||||
container.id = 'marketplace-container'
|
||||
document.body.appendChild(container)
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
|
||||
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
@ -238,7 +263,7 @@ describe('useMarketplaceData', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const { useMarketplaceData } = await import('../state')
|
||||
const { usePluginsMarketplaceData } = await import('../state')
|
||||
const { Wrapper } = createWrapper('?category=model')
|
||||
|
||||
const container = document.createElement('div')
|
||||
@ -249,7 +274,7 @@ describe('useMarketplaceData', () => {
|
||||
Object.defineProperty(container, 'scrollHeight', { value: 1000, writable: true, configurable: true })
|
||||
Object.defineProperty(container, 'clientHeight', { value: 200, writable: true, configurable: true })
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
|
||||
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins).toBeDefined()
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import StickySearchAndSwitchWrapper from '../sticky-search-and-switch-wrapper'
|
||||
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock child components to isolate wrapper logic
|
||||
vi.mock('../plugin-type-switch', () => ({
|
||||
default: () => <div data-testid="plugin-type-switch">PluginTypeSwitch</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../search-box/search-box-wrapper', () => ({
|
||||
default: () => <div data-testid="search-box-wrapper">SearchBoxWrapper</div>,
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const { wrapper: NuqsWrapper } = createNuqsTestWrapper()
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider>
|
||||
<NuqsWrapper>
|
||||
{children}
|
||||
</NuqsWrapper>
|
||||
</JotaiProvider>
|
||||
)
|
||||
return { Wrapper }
|
||||
}
|
||||
|
||||
describe('StickySearchAndSwitchWrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render SearchBoxWrapper and PluginTypeSwitch', () => {
|
||||
const { Wrapper } = createWrapper()
|
||||
const { getByTestId } = render(
|
||||
<StickySearchAndSwitchWrapper />,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
expect(getByTestId('search-box-wrapper')).toBeInTheDocument()
|
||||
expect(getByTestId('plugin-type-switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not apply sticky class when no pluginTypeSwitchClassName', () => {
|
||||
const { Wrapper } = createWrapper()
|
||||
const { container } = render(
|
||||
<StickySearchAndSwitchWrapper />,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv.className).toContain('mt-4')
|
||||
expect(outerDiv.className).not.toContain('sticky')
|
||||
})
|
||||
|
||||
it('should apply sticky class when pluginTypeSwitchClassName contains top-', () => {
|
||||
const { Wrapper } = createWrapper()
|
||||
const { container } = render(
|
||||
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName="top-10" />,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv.className).toContain('sticky')
|
||||
expect(outerDiv.className).toContain('z-10')
|
||||
expect(outerDiv.className).toContain('top-10')
|
||||
})
|
||||
|
||||
it('should not apply sticky class when pluginTypeSwitchClassName does not contain top-', () => {
|
||||
const { Wrapper } = createWrapper()
|
||||
const { container } = render(
|
||||
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName="custom-class" />,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv.className).not.toContain('sticky')
|
||||
expect(outerDiv.className).toContain('custom-class')
|
||||
})
|
||||
})
|
||||
@ -23,9 +23,11 @@ const mockSearchAdvanced = vi.fn()
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceClient: {
|
||||
collections: (...args: unknown[]) => mockCollections(...args),
|
||||
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
|
||||
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
|
||||
plugins: {
|
||||
collections: (...args: unknown[]) => mockCollections(...args),
|
||||
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
|
||||
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -107,7 +109,7 @@ describe('getPluginLinkInMarketplace', () => {
|
||||
const { getPluginLinkInMarketplace } = await import('../utils')
|
||||
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
|
||||
const link = getPluginLinkInMarketplace(plugin)
|
||||
expect(link).toBe('https://marketplace.dify.ai/plugins/test-org/test-plugin')
|
||||
expect(link).toBe('https://marketplace.dify.ai/plugin/test-org/test-plugin')
|
||||
})
|
||||
|
||||
it('should return correct link for bundle', async () => {
|
||||
@ -123,7 +125,7 @@ describe('getPluginDetailLinkInMarketplace', () => {
|
||||
const { getPluginDetailLinkInMarketplace } = await import('../utils')
|
||||
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
|
||||
const link = getPluginDetailLinkInMarketplace(plugin)
|
||||
expect(link).toBe('/plugins/test-org/test-plugin')
|
||||
expect(link).toBe('/plugin/test-org/test-plugin')
|
||||
})
|
||||
|
||||
it('should return correct detail link for bundle', async () => {
|
||||
@ -134,69 +136,69 @@ describe('getPluginDetailLinkInMarketplace', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceListCondition', () => {
|
||||
describe('getPluginCondition', () => {
|
||||
it('should return category condition for tool', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.tool)).toBe('category=tool')
|
||||
const { getPluginCondition } = await import('../utils')
|
||||
expect(getPluginCondition(PluginCategoryEnum.tool)).toBe('category=tool')
|
||||
})
|
||||
|
||||
it('should return category condition for model', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.model)).toBe('category=model')
|
||||
const { getPluginCondition } = await import('../utils')
|
||||
expect(getPluginCondition(PluginCategoryEnum.model)).toBe('category=model')
|
||||
})
|
||||
|
||||
it('should return category condition for agent', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
|
||||
const { getPluginCondition } = await import('../utils')
|
||||
expect(getPluginCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
|
||||
})
|
||||
|
||||
it('should return category condition for datasource', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
|
||||
const { getPluginCondition } = await import('../utils')
|
||||
expect(getPluginCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
|
||||
})
|
||||
|
||||
it('should return category condition for trigger', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
|
||||
const { getPluginCondition } = await import('../utils')
|
||||
expect(getPluginCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
|
||||
})
|
||||
|
||||
it('should return endpoint category for extension', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
|
||||
const { getPluginCondition } = await import('../utils')
|
||||
expect(getPluginCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
|
||||
})
|
||||
|
||||
it('should return type condition for bundle', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition('bundle')).toBe('type=bundle')
|
||||
const { getPluginCondition } = await import('../utils')
|
||||
expect(getPluginCondition('bundle')).toBe('type=bundle')
|
||||
})
|
||||
|
||||
it('should return empty string for all', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition('all')).toBe('')
|
||||
const { getPluginCondition } = await import('../utils')
|
||||
expect(getPluginCondition('all')).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for unknown type', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
expect(getMarketplaceListCondition('unknown')).toBe('')
|
||||
const { getPluginCondition } = await import('../utils')
|
||||
expect(getPluginCondition('unknown')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceListFilterType', () => {
|
||||
describe('getPluginFilterType', () => {
|
||||
it('should return undefined for all', async () => {
|
||||
const { getMarketplaceListFilterType } = await import('../utils')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
|
||||
const { getPluginFilterType } = await import('../utils')
|
||||
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return bundle for bundle', async () => {
|
||||
const { getMarketplaceListFilterType } = await import('../utils')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
|
||||
const { getPluginFilterType } = await import('../utils')
|
||||
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
|
||||
})
|
||||
|
||||
it('should return plugin for other categories', async () => {
|
||||
const { getMarketplaceListFilterType } = await import('../utils')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe('plugin')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.model)).toBe('plugin')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.agent)).toBe('plugin')
|
||||
const { getPluginFilterType } = await import('../utils')
|
||||
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe('plugin')
|
||||
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.model)).toBe('plugin')
|
||||
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.agent)).toBe('plugin')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,31 +1,159 @@
|
||||
import type { SearchTab } from './search-params'
|
||||
import type { PluginsSort, SearchParamsFromCollection } from './types'
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { marketplaceSearchParamsParsers } from './search-params'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { CATEGORY_ALL, DEFAULT_PLUGIN_SORT, DEFAULT_TEMPLATE_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params'
|
||||
|
||||
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT)
|
||||
export function useMarketplaceSort() {
|
||||
return useAtom(marketplaceSortAtom)
|
||||
export const isMarketplacePlatformAtom = atom<boolean>(false)
|
||||
|
||||
const marketplacePluginSortAtom = atom<PluginsSort>(DEFAULT_PLUGIN_SORT)
|
||||
export function useMarketplacePluginSort() {
|
||||
return useAtom(marketplacePluginSortAtom)
|
||||
}
|
||||
export function useMarketplaceSortValue() {
|
||||
return useAtomValue(marketplaceSortAtom)
|
||||
export function useMarketplacePluginSortValue() {
|
||||
return useAtomValue(marketplacePluginSortAtom)
|
||||
}
|
||||
export function useSetMarketplaceSort() {
|
||||
return useSetAtom(marketplaceSortAtom)
|
||||
export function useSetMarketplacePluginSort() {
|
||||
return useSetAtom(marketplacePluginSortAtom)
|
||||
}
|
||||
|
||||
export function useSearchPluginText() {
|
||||
const marketplaceTemplateSortAtom = atom<PluginsSort>(DEFAULT_TEMPLATE_SORT)
|
||||
export function useMarketplaceTemplateSort() {
|
||||
return useAtom(marketplaceTemplateSortAtom)
|
||||
}
|
||||
export function useMarketplaceTemplateSortValue() {
|
||||
return useAtomValue(marketplaceTemplateSortAtom)
|
||||
}
|
||||
export function useSetMarketplaceTemplateSort() {
|
||||
return useSetAtom(marketplaceTemplateSortAtom)
|
||||
}
|
||||
|
||||
export function useSearchText() {
|
||||
return useQueryState('q', marketplaceSearchParamsParsers.q)
|
||||
}
|
||||
export function useActivePluginType() {
|
||||
return useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
export function useActivePluginCategory() {
|
||||
const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom)
|
||||
|
||||
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
const categoryFromPath = segments[1] || CATEGORY_ALL
|
||||
const validatedCategory = getValidatedPluginCategory(categoryFromPath)
|
||||
const handleChange = useCallback(
|
||||
(newCategory: string) => {
|
||||
// Preserve the current query string (e.g. ?languages=en) so manual
|
||||
// filters survive a category change.
|
||||
const search = typeof window !== 'undefined' ? window.location.search : ''
|
||||
router.push(`/plugins/${newCategory}${search}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
if (isAtMarketplace) {
|
||||
return [validatedCategory, handleChange] as const
|
||||
}
|
||||
return [getValidatedPluginCategory(category), setCategory] as const
|
||||
}
|
||||
|
||||
export function useActiveTemplateCategory() {
|
||||
const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom)
|
||||
|
||||
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
const categoryFromPath = segments[1] || CATEGORY_ALL
|
||||
const validatedCategory = getValidatedTemplateCategory(categoryFromPath)
|
||||
const handleChange = useCallback(
|
||||
(newCategory: string) => {
|
||||
// Preserve the current query string (e.g. ?languages=en) so manual
|
||||
// filters survive a category change.
|
||||
const search = typeof window !== 'undefined' ? window.location.search : ''
|
||||
router.push(`/${CREATION_TYPE.templates}/${newCategory}${search}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
if (isAtMarketplace) {
|
||||
return [validatedCategory, handleChange] as const
|
||||
}
|
||||
return [getValidatedTemplateCategory(category), setCategory] as const
|
||||
}
|
||||
export function useFilterPluginTags() {
|
||||
return useQueryState('tags', marketplaceSearchParamsParsers.tags)
|
||||
}
|
||||
|
||||
export function useFilterTemplateLanguages() {
|
||||
return useQueryState('languages', marketplaceSearchParamsParsers.languages)
|
||||
}
|
||||
|
||||
export function useSearchTab() {
|
||||
const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom)
|
||||
|
||||
const state = useQueryState('searchTab', marketplaceSearchParamsParsers.searchTab)
|
||||
|
||||
const router = useRouter()
|
||||
// /search/[searchTab]
|
||||
const { searchTab } = useParams()
|
||||
const handleChange = useCallback(
|
||||
(newTab: string) => {
|
||||
const location = new URL(window.location.href)
|
||||
const isAlreadyOnSearch = location.pathname.startsWith('/search/')
|
||||
location.pathname = `/search/${newTab}`
|
||||
if (isAlreadyOnSearch)
|
||||
router.replace(location.href)
|
||||
else
|
||||
router.push(location.href)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
if (isAtMarketplace) {
|
||||
return [searchTab, handleChange] as const
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export function useCreationType() {
|
||||
const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom)
|
||||
|
||||
const [creationType] = useQueryState('creationType', marketplaceSearchParamsParsers.creationType)
|
||||
|
||||
const pathname = usePathname()
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
|
||||
if (isAtMarketplace) {
|
||||
if (segments[0] === CREATION_TYPE.templates || segments[0] === 'template')
|
||||
return CREATION_TYPE.templates
|
||||
return CREATION_TYPE.plugins
|
||||
}
|
||||
return creationType
|
||||
}
|
||||
|
||||
// Search-page-specific filter hooks (separate from list-page category/tags)
|
||||
export function useSearchFilterCategories() {
|
||||
return useQueryState('searchCategories', marketplaceSearchParamsParsers.searchCategories)
|
||||
}
|
||||
|
||||
export function useSearchFilterLanguages() {
|
||||
return useQueryState('searchLanguages', marketplaceSearchParamsParsers.searchLanguages)
|
||||
}
|
||||
|
||||
export function useSearchFilterType() {
|
||||
const [type, setType] = useQueryState('searchType', marketplaceSearchParamsParsers.searchType)
|
||||
return [getValidatedPluginCategory(type), setType] as const
|
||||
}
|
||||
|
||||
export function useSearchFilterTags() {
|
||||
return useQueryState('searchTags', marketplaceSearchParamsParsers.searchTags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Not all categories have collections, so we need to
|
||||
* force the search mode for those categories.
|
||||
@ -33,30 +161,74 @@ export function useFilterPluginTags() {
|
||||
export const searchModeAtom = atom<true | null>(null)
|
||||
|
||||
export function useMarketplaceSearchMode() {
|
||||
const [searchPluginText] = useSearchPluginText()
|
||||
const creationType = useCreationType()
|
||||
const [searchText] = useSearchText()
|
||||
const [searchTab] = useSearchTab()
|
||||
const [filterPluginTags] = useFilterPluginTags()
|
||||
const [activePluginType] = useActivePluginType()
|
||||
const [filterTemplateLanguages] = useFilterTemplateLanguages()
|
||||
const [activePluginCategory] = useActivePluginCategory()
|
||||
const [activeTemplateCategory] = useActiveTemplateCategory()
|
||||
const isPluginsView = creationType === CREATION_TYPE.plugins
|
||||
|
||||
const searchMode = useAtomValue(searchModeAtom)
|
||||
const isSearchMode = !!searchPluginText
|
||||
|| filterPluginTags.length > 0
|
||||
|| (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType)))
|
||||
const isSearchMode = searchTab || searchText
|
||||
|| (isPluginsView && filterPluginTags.length > 0)
|
||||
|| (searchMode ?? (isPluginsView && !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginCategory)))
|
||||
|| (!isPluginsView && activeTemplateCategory !== CATEGORY_ALL)
|
||||
|| (!isPluginsView && filterTemplateLanguages.length > 0)
|
||||
return isSearchMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active sort state based on the current creationType.
|
||||
* Plugins use `marketplacePluginSortAtom`, templates use `marketplaceTemplateSortAtom`.
|
||||
*/
|
||||
export function useActiveSort(): [PluginsSort, (sort: PluginsSort) => void] {
|
||||
const creationType = useCreationType()
|
||||
const [pluginSort, setPluginSort] = useAtom(marketplacePluginSortAtom)
|
||||
const [templateSort, setTemplateSort] = useAtom(marketplaceTemplateSortAtom)
|
||||
const isTemplates = creationType === CREATION_TYPE.templates
|
||||
|
||||
const sort = isTemplates ? templateSort : pluginSort
|
||||
const setSort = useMemo(
|
||||
() => isTemplates ? setTemplateSort : setPluginSort,
|
||||
[isTemplates, setTemplateSort, setPluginSort],
|
||||
)
|
||||
return [sort, setSort]
|
||||
}
|
||||
|
||||
export function useActiveSortValue(): PluginsSort {
|
||||
const creationType = useCreationType()
|
||||
const pluginSort = useAtomValue(marketplacePluginSortAtom)
|
||||
const templateSort = useAtomValue(marketplaceTemplateSortAtom)
|
||||
return creationType === CREATION_TYPE.templates ? templateSort : pluginSort
|
||||
}
|
||||
|
||||
export function useMarketplaceMoreClick() {
|
||||
const [,setQ] = useSearchPluginText()
|
||||
const setSort = useSetAtom(marketplaceSortAtom)
|
||||
const [, setQ] = useSearchText()
|
||||
const [, setSearchTab] = useSearchTab()
|
||||
const setPluginSort = useSetAtom(marketplacePluginSortAtom)
|
||||
const setTemplateSort = useSetAtom(marketplaceTemplateSortAtom)
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
|
||||
return useCallback((searchParams?: SearchParamsFromCollection) => {
|
||||
return useCallback((searchParams?: SearchParamsFromCollection, searchTab?: SearchTab) => {
|
||||
if (!searchParams)
|
||||
return
|
||||
setQ(searchParams?.query || '')
|
||||
setSort({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
|
||||
})
|
||||
if (searchTab === 'templates') {
|
||||
setTemplateSort({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_TEMPLATE_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_TEMPLATE_SORT.sortOrder,
|
||||
})
|
||||
}
|
||||
else {
|
||||
setPluginSort({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_PLUGIN_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_PLUGIN_SORT.sortOrder,
|
||||
})
|
||||
}
|
||||
setSearchMode(true)
|
||||
}, [setQ, setSort, setSearchMode])
|
||||
if (searchTab)
|
||||
setSearchTab(searchTab)
|
||||
}, [setQ, setSearchTab, setPluginSort, setTemplateSort, setSearchMode])
|
||||
}
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import type { ActivePluginType, ActiveTemplateCategory } from '../constants'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP, TEMPLATE_CATEGORY_MAP } from '../constants'
|
||||
|
||||
/**
|
||||
* Returns a getter that translates a plugin category value to its display text.
|
||||
* Pass `allAsAllTypes = true` to use "All types" instead of "All" for the `all` category
|
||||
* (e.g. hero variant in category switch).
|
||||
*/
|
||||
export function usePluginCategoryText() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (category: ActivePluginType, allAsAllTypes = false): string => {
|
||||
switch (category) {
|
||||
case PLUGIN_TYPE_SEARCH_MAP.model:
|
||||
return t('category.models', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.tool:
|
||||
return t('category.tools', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.datasource:
|
||||
return t('category.datasources', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.trigger:
|
||||
return t('category.triggers', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.agent:
|
||||
return t('category.agents', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.extension:
|
||||
return t('category.extensions', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.bundle:
|
||||
return t('category.bundles', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.all:
|
||||
default:
|
||||
return allAsAllTypes
|
||||
? t('category.allTypes', { ns: 'plugin' })
|
||||
: t('category.all', { ns: 'plugin' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a getter that translates a template category value to its display text.
|
||||
*/
|
||||
export function useTemplateCategoryText() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (category: ActiveTemplateCategory): string => {
|
||||
switch (category) {
|
||||
case TEMPLATE_CATEGORY_MAP.marketing:
|
||||
return t('marketplace.templateCategory.marketing', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.sales:
|
||||
return t('marketplace.templateCategory.sales', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.support:
|
||||
return t('marketplace.templateCategory.support', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.operations:
|
||||
return t('marketplace.templateCategory.operations', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.it:
|
||||
return t('marketplace.templateCategory.it', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.knowledge:
|
||||
return t('marketplace.templateCategory.knowledge', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.design:
|
||||
return t('marketplace.templateCategory.design', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.others:
|
||||
return t('marketplace.templateCategory.others', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.all:
|
||||
default:
|
||||
return t('marketplace.templateCategory.all', { ns: 'plugin' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export type CategoryOption = {
|
||||
value: string
|
||||
text: string
|
||||
icon: React.ReactNode | null
|
||||
}
|
||||
|
||||
type CategorySwitchProps = {
|
||||
className?: string
|
||||
variant?: 'default' | 'hero'
|
||||
options: CategoryOption[]
|
||||
activeValue: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const CommonCategorySwitch = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
options,
|
||||
activeValue,
|
||||
onChange,
|
||||
}: CategorySwitchProps) => {
|
||||
const isHeroVariant = variant === 'hero'
|
||||
|
||||
const getItemClassName = (isActive: boolean) => {
|
||||
if (isHeroVariant) {
|
||||
return cn(
|
||||
'system-md-medium flex h-8 cursor-pointer items-center rounded-lg px-3 text-text-primary-on-surface transition-all',
|
||||
isActive
|
||||
? 'bg-components-button-secondary-bg text-saas-dify-blue-inverted'
|
||||
: 'hover:bg-state-base-hover',
|
||||
)
|
||||
}
|
||||
return cn(
|
||||
'system-md-medium flex h-8 cursor-pointer items-center rounded-xl border border-transparent px-3 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
isActive && 'border-components-main-nav-nav-button-border !bg-components-main-nav-nav-button-bg-active !text-components-main-nav-nav-button-text-active shadow-xs',
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center space-x-2',
|
||||
!isHeroVariant && 'justify-center bg-background-body py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={getItemClassName(activeValue === option.value)}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.icon}
|
||||
{option.text}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiArrowDownSLine, RiCloseCircleFill, RiGlobalLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { LANGUAGE_OPTIONS } from '../search-page/constants'
|
||||
|
||||
type HeroLanguagesFilterProps = {
|
||||
languages: string[]
|
||||
onLanguagesChange: (languages: string[]) => void
|
||||
}
|
||||
|
||||
const LANGUAGE_LABEL_MAP: Record<string, string> = LANGUAGE_OPTIONS.reduce((acc, option) => {
|
||||
acc[option.value] = option.nativeLabel
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
const HeroLanguagesFilter = ({
|
||||
languages,
|
||||
onLanguagesChange,
|
||||
}: HeroLanguagesFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const selectedLanguagesLength = languages.length
|
||||
const hasSelected = selectedLanguagesLength > 0
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchText)
|
||||
return LANGUAGE_OPTIONS
|
||||
const normalizedSearchText = searchText.toLowerCase()
|
||||
return LANGUAGE_OPTIONS.filter(option =>
|
||||
option.nativeLabel.toLowerCase().includes(normalizedSearchText)
|
||||
|| option.label.toLowerCase().includes(normalizedSearchText),
|
||||
)
|
||||
}, [searchText])
|
||||
|
||||
const handleCheck = (value: string) => {
|
||||
if (languages.includes(value))
|
||||
onLanguagesChange(languages.filter(language => language !== value))
|
||||
else
|
||||
onLanguagesChange([...languages, value])
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -6,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="shrink-0"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg px-2.5 py-1.5',
|
||||
!hasSelected && 'border border-white/30 text-text-primary-on-surface',
|
||||
!hasSelected && open && 'bg-state-base-hover',
|
||||
!hasSelected && !open && 'hover:bg-state-base-hover',
|
||||
hasSelected && 'border-effect-highlight border bg-components-button-secondary-bg-hover shadow-md backdrop-blur-[5px]',
|
||||
)}
|
||||
>
|
||||
<RiGlobalLine
|
||||
className={cn(
|
||||
'size-4 shrink-0',
|
||||
hasSelected ? 'text-saas-dify-blue-inverted' : 'text-text-primary-on-surface',
|
||||
)}
|
||||
/>
|
||||
<div className="system-md-medium flex items-center gap-0.5">
|
||||
{!hasSelected && (
|
||||
<span>{t('marketplace.languages', { ns: 'plugin' })}</span>
|
||||
)}
|
||||
{hasSelected && (
|
||||
<span className="text-saas-dify-blue-inverted">
|
||||
{languages
|
||||
.map(language => LANGUAGE_LABEL_MAP[language])
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{selectedLanguagesLength > 2 && (
|
||||
<div className="flex min-w-4 items-center justify-center rounded-[5px] border border-saas-dify-blue-inverted px-1 py-0.5">
|
||||
<span className="system-2xs-medium-uppercase text-saas-dify-blue-inverted">
|
||||
+
|
||||
{selectedLanguagesLength - 2}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasSelected && (
|
||||
<RiCloseCircleFill
|
||||
className="size-4 shrink-0 text-saas-dify-blue-inverted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onLanguagesChange([])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!hasSelected && (
|
||||
<RiArrowDownSLine className="size-4 shrink-0 text-text-primary-on-surface" />
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1000]">
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
placeholder={t('marketplace.searchFilterLanguage', { ns: 'plugin' })}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[448px] overflow-y-auto p-1">
|
||||
{filteredOptions.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => handleCheck(option.value)}
|
||||
>
|
||||
<Checkbox
|
||||
className="mr-1"
|
||||
checked={languages.includes(option.value)}
|
||||
/>
|
||||
<div className="system-sm-medium px-1 text-text-secondary">
|
||||
{option.nativeLabel}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HeroLanguagesFilter)
|
||||
@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import HeroTagsTrigger from './hero-tags-trigger'
|
||||
|
||||
type HeroTagsFilterProps = {
|
||||
tags: string[]
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
const HeroTagsFilter = ({
|
||||
tags,
|
||||
onTagsChange,
|
||||
}: HeroTagsFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { tags: options, tagsMap } = useTags()
|
||||
const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
|
||||
const handleCheck = (id: string) => {
|
||||
if (tags.includes(id))
|
||||
onTagsChange(tags.filter((tag: string) => tag !== id))
|
||||
else
|
||||
onTagsChange([...tags, id])
|
||||
}
|
||||
const selectedTagsLength = tags.length
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -6,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="shrink-0"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<HeroTagsTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1000]">
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
placeholder={t('searchTags', { ns: 'pluginTags' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[448px] overflow-y-auto p-1">
|
||||
{
|
||||
filteredOptions.map(option => (
|
||||
<div
|
||||
key={option.name}
|
||||
className="flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => handleCheck(option.name)}
|
||||
>
|
||||
<Checkbox
|
||||
className="mr-1"
|
||||
checked={tags.includes(option.name)}
|
||||
/>
|
||||
<div className="system-sm-medium px-1 text-text-secondary">
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeroTagsFilter
|
||||
@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import type { Tag } from '../../hooks'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiArrowDownSLine, RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type HeroTagsTriggerProps = {
|
||||
selectedTagsLength: number
|
||||
open: boolean
|
||||
tags: string[]
|
||||
tagsMap: Record<string, Tag>
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
const HeroTagsTrigger = ({
|
||||
selectedTagsLength,
|
||||
open,
|
||||
tags,
|
||||
tagsMap,
|
||||
onTagsChange,
|
||||
}: HeroTagsTriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const hasSelected = !!selectedTagsLength
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg px-2.5 py-1.5',
|
||||
!hasSelected && 'border border-white/30 text-text-primary-on-surface',
|
||||
!hasSelected && open && 'bg-state-base-hover',
|
||||
!hasSelected && !open && 'hover:bg-state-base-hover',
|
||||
hasSelected && 'border-effect-highlight border bg-components-button-secondary-bg-hover shadow-md backdrop-blur-[5px]',
|
||||
)}
|
||||
>
|
||||
<RiPriceTag3Line className={cn(
|
||||
'size-4 shrink-0',
|
||||
hasSelected ? 'text-saas-dify-blue-inverted' : 'text-text-primary-on-surface',
|
||||
)}
|
||||
/>
|
||||
<div className="system-md-medium flex items-center gap-0.5">
|
||||
{
|
||||
!hasSelected && (
|
||||
<span>{t('allTags', { ns: 'pluginTags' })}</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
hasSelected && (
|
||||
<span className="text-saas-dify-blue-inverted">
|
||||
{tags.map(tag => tagsMap[tag]?.label).filter(Boolean).slice(0, 2).join(', ')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className="flex min-w-4 items-center justify-center rounded-[5px] border border-saas-dify-blue-inverted px-1 py-0.5">
|
||||
<span className="system-2xs-medium-uppercase text-saas-dify-blue-inverted">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
hasSelected && (
|
||||
<RiCloseCircleFill
|
||||
className="size-4 shrink-0 text-saas-dify-blue-inverted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTagsChange([])
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!hasSelected && (
|
||||
<RiArrowDownSLine className="size-4 shrink-0 text-text-primary-on-surface" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HeroTagsTrigger)
|
||||
@ -0,0 +1,4 @@
|
||||
'use client'
|
||||
|
||||
export { PluginCategorySwitch } from './plugin'
|
||||
export { TemplateCategorySwitch } from './template'
|
||||
@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import type { ActivePluginType } from '../constants'
|
||||
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { RiArchive2Line } from '@remixicon/react'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { Plugin } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { searchModeAtom, useActivePluginCategory, useFilterPluginTags } from '../atoms'
|
||||
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from '../constants'
|
||||
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../plugin-type-icons'
|
||||
import { usePluginCategoryText } from './category-text'
|
||||
import { CommonCategorySwitch } from './common'
|
||||
import HeroTagsFilter from './hero-tags-filter'
|
||||
|
||||
type PluginTypeSwitchProps = {
|
||||
className?: string
|
||||
variant?: 'default' | 'hero'
|
||||
}
|
||||
|
||||
const categoryValues = [
|
||||
PLUGIN_TYPE_SEARCH_MAP.all,
|
||||
PLUGIN_TYPE_SEARCH_MAP.model,
|
||||
PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
PLUGIN_TYPE_SEARCH_MAP.datasource,
|
||||
PLUGIN_TYPE_SEARCH_MAP.trigger,
|
||||
PLUGIN_TYPE_SEARCH_MAP.agent,
|
||||
PLUGIN_TYPE_SEARCH_MAP.extension,
|
||||
PLUGIN_TYPE_SEARCH_MAP.bundle,
|
||||
] as const
|
||||
|
||||
const getTypeIcon = (value: ActivePluginType, isHeroVariant?: boolean) => {
|
||||
if (value === PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
return isHeroVariant ? <Plugin className="mr-1.5 h-4 w-4" /> : null
|
||||
if (value === PLUGIN_TYPE_SEARCH_MAP.bundle)
|
||||
return <RiArchive2Line className="mr-1.5 h-4 w-4" />
|
||||
const Icon = MARKETPLACE_TYPE_ICON_COMPONENTS[value as PluginCategoryEnum]
|
||||
return Icon ? <Icon className="mr-1.5 h-4 w-4" /> : null
|
||||
}
|
||||
|
||||
export const PluginCategorySwitch = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
}: PluginTypeSwitchProps) => {
|
||||
const [activePluginCategory, handleActivePluginCategoryChange] = useActivePluginCategory()
|
||||
const [filterPluginTags, setFilterPluginTags] = useFilterPluginTags()
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
const getPluginCategoryText = usePluginCategoryText()
|
||||
|
||||
const isHeroVariant = variant === 'hero'
|
||||
|
||||
const options = categoryValues.map(value => ({
|
||||
value,
|
||||
text: getPluginCategoryText(value, isHeroVariant),
|
||||
icon: getTypeIcon(value, isHeroVariant),
|
||||
}))
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
handleActivePluginCategoryChange(value)
|
||||
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(value as ActivePluginType)) {
|
||||
setSearchMode(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isHeroVariant) {
|
||||
return (
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activePluginCategory}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<HeroTagsFilter
|
||||
tags={filterPluginTags}
|
||||
onTagsChange={tags => setFilterPluginTags(tags.length ? tags : null)}
|
||||
/>
|
||||
<div className="text-text-primary-on-surface">
|
||||
·
|
||||
</div>
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activePluginCategory}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { Playground } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { useActiveTemplateCategory, useFilterTemplateLanguages } from '../atoms'
|
||||
import { CATEGORY_ALL, TEMPLATE_CATEGORY_MAP } from '../constants'
|
||||
import { useTemplateCategoryText } from './category-text'
|
||||
import { CommonCategorySwitch } from './common'
|
||||
import HeroLanguagesFilter from './hero-languages-filter'
|
||||
|
||||
type TemplateCategorySwitchProps = {
|
||||
className?: string
|
||||
variant?: 'default' | 'hero'
|
||||
}
|
||||
|
||||
const categoryValues = [
|
||||
CATEGORY_ALL,
|
||||
TEMPLATE_CATEGORY_MAP.marketing,
|
||||
TEMPLATE_CATEGORY_MAP.sales,
|
||||
TEMPLATE_CATEGORY_MAP.support,
|
||||
TEMPLATE_CATEGORY_MAP.operations,
|
||||
TEMPLATE_CATEGORY_MAP.it,
|
||||
TEMPLATE_CATEGORY_MAP.knowledge,
|
||||
TEMPLATE_CATEGORY_MAP.design,
|
||||
TEMPLATE_CATEGORY_MAP.others,
|
||||
] as const
|
||||
|
||||
export const TemplateCategorySwitch = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
}: TemplateCategorySwitchProps) => {
|
||||
const [activeTemplateCategory, handleActiveTemplateCategoryChange] = useActiveTemplateCategory()
|
||||
const [filterTemplateLanguages, setFilterTemplateLanguages] = useFilterTemplateLanguages()
|
||||
const getTemplateCategoryText = useTemplateCategoryText()
|
||||
|
||||
const isHeroVariant = variant === 'hero'
|
||||
|
||||
const options = categoryValues.map(value => ({
|
||||
value,
|
||||
text: getTemplateCategoryText(value),
|
||||
icon: value === CATEGORY_ALL && isHeroVariant ? <Playground className="mr-1.5 h-4 w-4" /> : null,
|
||||
}))
|
||||
|
||||
if (!isHeroVariant) {
|
||||
return (
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activeTemplateCategory}
|
||||
onChange={handleActiveTemplateCategoryChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activeTemplateCategory}
|
||||
onChange={handleActiveTemplateCategoryChange}
|
||||
/>
|
||||
<HeroLanguagesFilter
|
||||
languages={filterTemplateLanguages}
|
||||
onLanguagesChange={languages => setFilterTemplateLanguages(languages.length ? languages : null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
web/app/components/plugins/marketplace/constants.spec.ts
Normal file
19
web/app/components/plugins/marketplace/constants.spec.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getValidatedPluginCategory } from './constants'
|
||||
|
||||
describe('getValidatedPluginCategory', () => {
|
||||
it('returns agent-strategy when query value is agent-strategy', () => {
|
||||
expect(getValidatedPluginCategory('agent-strategy')).toBe('agent-strategy')
|
||||
})
|
||||
|
||||
it('returns valid category values unchanged', () => {
|
||||
expect(getValidatedPluginCategory('model')).toBe('model')
|
||||
expect(getValidatedPluginCategory('tool')).toBe('tool')
|
||||
expect(getValidatedPluginCategory('bundle')).toBe('bundle')
|
||||
})
|
||||
|
||||
it('falls back to all for invalid category values', () => {
|
||||
expect(getValidatedPluginCategory('agent')).toBe('all')
|
||||
expect(getValidatedPluginCategory('invalid-category')).toBe('all')
|
||||
})
|
||||
})
|
||||
@ -1,14 +1,21 @@
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
|
||||
export const DEFAULT_SORT = {
|
||||
export const DEFAULT_PLUGIN_SORT = {
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
}
|
||||
|
||||
export const DEFAULT_TEMPLATE_SORT = {
|
||||
sortBy: 'usage_count',
|
||||
sortOrder: 'DESC',
|
||||
}
|
||||
|
||||
export const SCROLL_BOTTOM_THRESHOLD = 100
|
||||
|
||||
export const CATEGORY_ALL = 'all'
|
||||
|
||||
export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
all: 'all',
|
||||
[CATEGORY_ALL]: CATEGORY_ALL,
|
||||
model: PluginCategoryEnum.model,
|
||||
tool: PluginCategoryEnum.tool,
|
||||
agent: PluginCategoryEnum.agent,
|
||||
@ -21,6 +28,7 @@ export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
type ValueOf<T> = T[keyof T]
|
||||
|
||||
export type ActivePluginType = ValueOf<typeof PLUGIN_TYPE_SEARCH_MAP>
|
||||
const VALID_PLUGIN_CATEGORIES = new Set<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP))
|
||||
|
||||
export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
|
||||
[
|
||||
@ -28,3 +36,29 @@ export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
|
||||
PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
],
|
||||
)
|
||||
|
||||
export const TEMPLATE_CATEGORY_MAP = {
|
||||
[CATEGORY_ALL]: CATEGORY_ALL,
|
||||
marketing: 'marketing',
|
||||
sales: 'sales',
|
||||
support: 'support',
|
||||
operations: 'operations',
|
||||
it: 'it',
|
||||
knowledge: 'knowledge',
|
||||
design: 'design',
|
||||
others: 'others',
|
||||
} as const
|
||||
|
||||
export type ActiveTemplateCategory = typeof TEMPLATE_CATEGORY_MAP[keyof typeof TEMPLATE_CATEGORY_MAP]
|
||||
|
||||
export function getValidatedPluginCategory(category: string): ActivePluginType {
|
||||
if (VALID_PLUGIN_CATEGORIES.has(category as ActivePluginType))
|
||||
return category as ActivePluginType
|
||||
|
||||
return CATEGORY_ALL
|
||||
}
|
||||
|
||||
export function getValidatedTemplateCategory(category: string): ActiveTemplateCategory {
|
||||
const key = (category in TEMPLATE_CATEGORY_MAP ? category : CATEGORY_ALL) as keyof typeof TEMPLATE_CATEGORY_MAP
|
||||
return TEMPLATE_CATEGORY_MAP[key]
|
||||
}
|
||||
|
||||
@ -1,649 +1,101 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Description from '../index'
|
||||
import { Description } from '../index'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies
|
||||
// ================================
|
||||
|
||||
// Track mock locale for testing
|
||||
let mockDefaultLocale = 'en-US'
|
||||
|
||||
// Mock translations with realistic values
|
||||
const pluginTranslations: Record<string, string> = {
|
||||
'marketplace.empower': 'Empower your AI development',
|
||||
'marketplace.discover': 'Discover',
|
||||
'marketplace.difyMarketplace': 'Dify Marketplace',
|
||||
'marketplace.and': 'and',
|
||||
'category.models': 'Models',
|
||||
'category.tools': 'Tools',
|
||||
'category.datasources': 'Data Sources',
|
||||
'category.triggers': 'Triggers',
|
||||
'category.agents': 'Agent Strategies',
|
||||
'category.extensions': 'Extensions',
|
||||
'category.bundles': 'Bundles',
|
||||
}
|
||||
|
||||
const commonTranslations: Record<string, string> = {
|
||||
'operation.in': 'in',
|
||||
}
|
||||
|
||||
// Mock i18n hooks
|
||||
vi.mock('#i18n', () => ({
|
||||
useLocale: vi.fn(() => mockDefaultLocale),
|
||||
useTranslation: vi.fn((ns: string) => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
if (ns === 'plugin')
|
||||
return pluginTranslations[key] || key
|
||||
if (ns === 'common')
|
||||
return commonTranslations[key] || key
|
||||
return key
|
||||
const translations: Record<string, string> = {
|
||||
'marketplace.pluginsHeroTitle': 'Build with plugins',
|
||||
'marketplace.pluginsHeroSubtitle': 'Discover and install marketplace plugins.',
|
||||
'marketplace.templatesHeroTitle': 'Build with templates',
|
||||
'marketplace.templatesHeroSubtitle': 'Explore reusable templates.',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
})),
|
||||
}),
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Description Component Tests
|
||||
// ================================
|
||||
let mockCreationType = 'plugins'
|
||||
|
||||
vi.mock('../../atoms', () => ({
|
||||
useCreationType: () => mockCreationType,
|
||||
}))
|
||||
|
||||
vi.mock('../../search-params', () => ({
|
||||
CREATION_TYPE: {
|
||||
plugins: 'plugins',
|
||||
templates: 'templates',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../category-switch', () => ({
|
||||
PluginCategorySwitch: ({ variant }: { variant?: string }) => <div data-testid="plugin-category-switch">{variant}</div>,
|
||||
TemplateCategorySwitch: ({ variant }: { variant?: string }) => <div data-testid="template-category-switch">{variant}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('motion/react', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
|
||||
},
|
||||
useMotionValue: (value: number) => ({ set: vi.fn(), get: () => value }),
|
||||
useSpring: (value: unknown) => value,
|
||||
useTransform: (...args: unknown[]) => {
|
||||
const values = args[0]
|
||||
if (Array.isArray(values))
|
||||
return 0
|
||||
return values
|
||||
},
|
||||
}))
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
describe('Description', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDefaultLocale = 'en-US'
|
||||
mockCreationType = 'plugins'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
return 1
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render h1 heading with empower text', () => {
|
||||
it('should render plugin hero content by default', () => {
|
||||
render(<Description />)
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toBeInTheDocument()
|
||||
expect(heading).toHaveTextContent('Empower your AI development')
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Build with plugins')
|
||||
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Discover and install marketplace plugins.')
|
||||
expect(screen.getByTestId('plugin-category-switch')).toHaveTextContent('hero')
|
||||
})
|
||||
|
||||
it('should render h2 subheading', () => {
|
||||
it('should render template hero content when creationType is templates', () => {
|
||||
mockCreationType = 'templates'
|
||||
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply correct CSS classes to h1', () => {
|
||||
render(<Description />)
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toHaveClass('title-4xl-semi-bold')
|
||||
expect(heading).toHaveClass('mb-2')
|
||||
expect(heading).toHaveClass('text-center')
|
||||
expect(heading).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should apply correct CSS classes to h2', () => {
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('body-md-regular')
|
||||
expect(subheading).toHaveClass('text-center')
|
||||
expect(subheading).toHaveClass('text-text-tertiary')
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Build with templates')
|
||||
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Explore reusable templates.')
|
||||
expect(screen.getByTestId('template-category-switch')).toHaveTextContent('hero')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Non-Chinese Locale Rendering Tests
|
||||
// ================================
|
||||
describe('Non-Chinese Locale Rendering', () => {
|
||||
beforeEach(() => {
|
||||
mockDefaultLocale = 'en-US'
|
||||
describe('Props', () => {
|
||||
it('should render marketplace nav content when provided', () => {
|
||||
render(<Description marketplaceNav={<div data-testid="marketplace-nav">Nav</div>} />)
|
||||
|
||||
expect(screen.getByTestId('marketplace-nav')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render discover text for en-US locale', () => {
|
||||
render(<Description />)
|
||||
it('should apply custom className to the sticky wrapper', () => {
|
||||
const { container } = render(<Description className="custom-hero-class" />)
|
||||
|
||||
expect(screen.getByText(/Discover/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all category names', () => {
|
||||
render(<Description />)
|
||||
|
||||
expect(screen.getByText('Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tools')).toBeInTheDocument()
|
||||
expect(screen.getByText('Data Sources')).toBeInTheDocument()
|
||||
expect(screen.getByText('Triggers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Agent Strategies')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extensions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bundles')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "and" conjunction text', () => {
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('and')
|
||||
})
|
||||
|
||||
it('should render "in" preposition at the end for non-Chinese locales', () => {
|
||||
render(<Description />)
|
||||
|
||||
expect(screen.getByText('in')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Dify Marketplace text at the end for non-Chinese locales', () => {
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should render category spans with styled underline effect', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
const styledSpans = container.querySelectorAll('.body-md-medium.relative.z-\\[1\\]')
|
||||
// 7 category spans (models, tools, datasources, triggers, agents, extensions, bundles)
|
||||
expect(styledSpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply text-text-secondary class to category spans', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
const styledSpans = container.querySelectorAll('.text-text-secondary')
|
||||
expect(styledSpans.length).toBeGreaterThanOrEqual(7)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Chinese (zh-Hans) Locale Rendering Tests
|
||||
// ================================
|
||||
describe('Chinese (zh-Hans) Locale Rendering', () => {
|
||||
beforeEach(() => {
|
||||
mockDefaultLocale = 'zh-Hans'
|
||||
})
|
||||
|
||||
it('should render "in" text at the beginning for zh-Hans locale', () => {
|
||||
render(<Description />)
|
||||
|
||||
// In zh-Hans mode, "in" appears at the beginning
|
||||
const inElements = screen.getAllByText('in')
|
||||
expect(inElements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render Dify Marketplace text for zh-Hans locale', () => {
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should render discover text for zh-Hans locale', () => {
|
||||
render(<Description />)
|
||||
|
||||
expect(screen.getByText(/Discover/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all categories for zh-Hans locale', () => {
|
||||
render(<Description />)
|
||||
|
||||
expect(screen.getByText('Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tools')).toBeInTheDocument()
|
||||
expect(screen.getByText('Data Sources')).toBeInTheDocument()
|
||||
expect(screen.getByText('Triggers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Agent Strategies')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extensions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bundles')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both zh-Hans specific elements and shared elements', () => {
|
||||
render(<Description />)
|
||||
|
||||
// zh-Hans has specific element order: "in" -> Dify Marketplace -> Discover
|
||||
// then the same category list with "and" -> Bundles
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('and')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Locale Variations Tests
|
||||
// ================================
|
||||
describe('Locale Variations', () => {
|
||||
it('should use en-US locale by default', () => {
|
||||
mockDefaultLocale = 'en-US'
|
||||
render(<Description />)
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ja-JP locale as non-Chinese', () => {
|
||||
mockDefaultLocale = 'ja-JP'
|
||||
render(<Description />)
|
||||
|
||||
// Should render in non-Chinese format (discover first, then "in Dify Marketplace" at end)
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should handle ko-KR locale as non-Chinese', () => {
|
||||
mockDefaultLocale = 'ko-KR'
|
||||
render(<Description />)
|
||||
|
||||
// Should render in non-Chinese format
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle de-DE locale as non-Chinese', () => {
|
||||
mockDefaultLocale = 'de-DE'
|
||||
render(<Description />)
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle fr-FR locale as non-Chinese', () => {
|
||||
mockDefaultLocale = 'fr-FR'
|
||||
render(<Description />)
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle pt-BR locale as non-Chinese', () => {
|
||||
mockDefaultLocale = 'pt-BR'
|
||||
render(<Description />)
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle es-ES locale as non-Chinese', () => {
|
||||
mockDefaultLocale = 'es-ES'
|
||||
render(<Description />)
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Conditional Rendering Tests
|
||||
// ================================
|
||||
describe('Conditional Rendering', () => {
|
||||
it('should render zh-Hans specific content when locale is zh-Hans', () => {
|
||||
mockDefaultLocale = 'zh-Hans'
|
||||
const { container } = render(<Description />)
|
||||
|
||||
// zh-Hans has additional span with mr-1 before "in" text at the start
|
||||
const mrSpan = container.querySelector('span.mr-1')
|
||||
expect(mrSpan).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render non-Chinese specific content when locale is not zh-Hans', () => {
|
||||
mockDefaultLocale = 'en-US'
|
||||
render(<Description />)
|
||||
|
||||
// Non-Chinese has "in" and "Dify Marketplace" at the end
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should not render zh-Hans intro content for non-Chinese locales', () => {
|
||||
mockDefaultLocale = 'en-US'
|
||||
render(<Description />)
|
||||
|
||||
// For en-US, the order should be Discover ... in Dify Marketplace
|
||||
// The "in" text should only appear once at the end
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// "in" should appear after "Bundles" and before "Dify Marketplace"
|
||||
const bundlesIndex = content.indexOf('Bundles')
|
||||
const inIndex = content.indexOf('in')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
|
||||
expect(bundlesIndex).toBeLessThan(inIndex)
|
||||
expect(inIndex).toBeLessThan(marketplaceIndex)
|
||||
})
|
||||
|
||||
it('should render zh-Hans with proper word order', () => {
|
||||
mockDefaultLocale = 'zh-Hans'
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// zh-Hans order: in -> Dify Marketplace -> Discover -> categories
|
||||
const inIndex = content.indexOf('in')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
const discoverIndex = content.indexOf('Discover')
|
||||
|
||||
expect(inIndex).toBeLessThan(marketplaceIndex)
|
||||
expect(marketplaceIndex).toBeLessThan(discoverIndex)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Category Styling Tests
|
||||
// ================================
|
||||
describe('Category Styling', () => {
|
||||
it('should apply underline effect with after pseudo-element styling', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
const categorySpan = container.querySelector('.after\\:absolute')
|
||||
expect(categorySpan).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply correct after pseudo-element classes', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
// Check for the specific after pseudo-element classes
|
||||
const categorySpans = container.querySelectorAll('.after\\:bottom-\\[1\\.5px\\]')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply full width to after element', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
const categorySpans = container.querySelectorAll('.after\\:w-full')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply correct height to after element', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
const categorySpans = container.querySelectorAll('.after\\:h-2')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply bg-text-text-selected to after element', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
const categorySpans = container.querySelectorAll('.after\\:bg-text-text-selected')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should have z-index 1 on category spans', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
const categorySpans = container.querySelectorAll('.z-\\[1\\]')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply left margin to category spans', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
const categorySpans = container.querySelectorAll('.ml-1')
|
||||
expect(categorySpans.length).toBeGreaterThanOrEqual(7)
|
||||
})
|
||||
|
||||
it('should apply both left and right margin to specific spans', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
// Extensions and Bundles spans have both ml-1 and mr-1
|
||||
const extensionsBundlesSpans = container.querySelectorAll('.ml-1.mr-1')
|
||||
expect(extensionsBundlesSpans.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should render fragment as root element', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
// Fragment renders h1 and h2 as direct children
|
||||
expect(container.querySelector('h1')).toBeInTheDocument()
|
||||
expect(container.querySelector('h2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zh-Hant as non-Chinese simplified', () => {
|
||||
mockDefaultLocale = 'zh-Hant'
|
||||
render(<Description />)
|
||||
|
||||
// zh-Hant is different from zh-Hans, should use non-Chinese format
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// Check that "Dify Marketplace" appears at the end (non-Chinese format)
|
||||
const discoverIndex = content.indexOf('Discover')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
|
||||
// For non-Chinese locales, Discover should come before Dify Marketplace
|
||||
expect(discoverIndex).toBeLessThan(marketplaceIndex)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Content Structure Tests
|
||||
// ================================
|
||||
describe('Content Structure', () => {
|
||||
it('should have comma separators between categories', () => {
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// Commas should exist between categories
|
||||
expect(content).toMatch(/Models[^\n\r,\u2028\u2029]*,.*Tools[^\n\r,\u2028\u2029]*,.*Data Sources[^\n\r,\u2028\u2029]*,.*Triggers[^\n\r,\u2028\u2029]*,.*Agent Strategies[^\n\r,\u2028\u2029]*,.*Extensions/)
|
||||
})
|
||||
|
||||
it('should have "and" before last category (Bundles)', () => {
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// "and" should appear before Bundles
|
||||
const andIndex = content.indexOf('and')
|
||||
const bundlesIndex = content.indexOf('Bundles')
|
||||
|
||||
expect(andIndex).toBeLessThan(bundlesIndex)
|
||||
})
|
||||
|
||||
it('should render all text elements in correct order for en-US', () => {
|
||||
mockDefaultLocale = 'en-US'
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
const expectedOrder = [
|
||||
'Discover',
|
||||
'Models',
|
||||
'Tools',
|
||||
'Data Sources',
|
||||
'Triggers',
|
||||
'Agent Strategies',
|
||||
'Extensions',
|
||||
'and',
|
||||
'Bundles',
|
||||
'in',
|
||||
'Dify Marketplace',
|
||||
]
|
||||
|
||||
let lastIndex = -1
|
||||
for (const text of expectedOrder) {
|
||||
const currentIndex = content.indexOf(text)
|
||||
expect(currentIndex).toBeGreaterThan(lastIndex)
|
||||
lastIndex = currentIndex
|
||||
}
|
||||
})
|
||||
|
||||
it('should render all text elements in correct order for zh-Hans', () => {
|
||||
mockDefaultLocale = 'zh-Hans'
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// zh-Hans order: in -> Dify Marketplace -> Discover -> categories -> and -> Bundles
|
||||
const inIndex = content.indexOf('in')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
const discoverIndex = content.indexOf('Discover')
|
||||
const modelsIndex = content.indexOf('Models')
|
||||
|
||||
expect(inIndex).toBeLessThan(marketplaceIndex)
|
||||
expect(marketplaceIndex).toBeLessThan(discoverIndex)
|
||||
expect(discoverIndex).toBeLessThan(modelsIndex)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Layout Tests
|
||||
// ================================
|
||||
describe('Layout', () => {
|
||||
it('should have shrink-0 on h1 heading', () => {
|
||||
render(<Description />)
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toHaveClass('shrink-0')
|
||||
})
|
||||
|
||||
it('should have shrink-0 on h2 subheading', () => {
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('shrink-0')
|
||||
})
|
||||
|
||||
it('should have flex layout on h2', () => {
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('flex')
|
||||
})
|
||||
|
||||
it('should have items-center on h2', () => {
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('items-center')
|
||||
})
|
||||
|
||||
it('should have justify-center on h2', () => {
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('justify-center')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Accessibility Tests
|
||||
// ================================
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', () => {
|
||||
render(<Description />)
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 })
|
||||
const h2 = screen.getByRole('heading', { level: 2 })
|
||||
|
||||
expect(h1).toBeInTheDocument()
|
||||
expect(h2).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have readable text content', () => {
|
||||
render(<Description />)
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 })
|
||||
expect(h1.textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('should have visible h1 heading', () => {
|
||||
render(<Description />)
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
it('should have visible h2 heading', () => {
|
||||
render(<Description />)
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toBeVisible()
|
||||
expect(container.querySelector('.custom-hero-class')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Integration Tests
|
||||
// ================================
|
||||
describe('Description Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDefaultLocale = 'en-US'
|
||||
})
|
||||
|
||||
it('should render complete component structure', () => {
|
||||
const { container } = render(<Description />)
|
||||
|
||||
// Main headings
|
||||
expect(container.querySelector('h1')).toBeInTheDocument()
|
||||
expect(container.querySelector('h2')).toBeInTheDocument()
|
||||
|
||||
// All category spans
|
||||
const categorySpans = container.querySelectorAll('.body-md-medium')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should render complete zh-Hans structure', () => {
|
||||
mockDefaultLocale = 'zh-Hans'
|
||||
const { container } = render(<Description />)
|
||||
|
||||
// Main headings
|
||||
expect(container.querySelector('h1')).toBeInTheDocument()
|
||||
expect(container.querySelector('h2')).toBeInTheDocument()
|
||||
|
||||
// All category spans
|
||||
const categorySpans = container.querySelectorAll('.body-md-medium')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should correctly differentiate between zh-Hans and en-US layouts', () => {
|
||||
// Render en-US
|
||||
mockDefaultLocale = 'en-US'
|
||||
const { container: enContainer, unmount: unmountEn } = render(<Description />)
|
||||
const enContent = enContainer.querySelector('h2')?.textContent || ''
|
||||
unmountEn()
|
||||
|
||||
// Render zh-Hans
|
||||
mockDefaultLocale = 'zh-Hans'
|
||||
const { container: zhContainer } = render(<Description />)
|
||||
const zhContent = zhContainer.querySelector('h2')?.textContent || ''
|
||||
|
||||
// Both should have all categories
|
||||
expect(enContent).toContain('Models')
|
||||
expect(zhContent).toContain('Models')
|
||||
|
||||
// But order should differ
|
||||
const enMarketplaceIndex = enContent.indexOf('Dify Marketplace')
|
||||
const enDiscoverIndex = enContent.indexOf('Discover')
|
||||
const zhMarketplaceIndex = zhContent.indexOf('Dify Marketplace')
|
||||
const zhDiscoverIndex = zhContent.indexOf('Discover')
|
||||
|
||||
// en-US: Discover comes before Dify Marketplace
|
||||
expect(enDiscoverIndex).toBeLessThan(enMarketplaceIndex)
|
||||
|
||||
// zh-Hans: Dify Marketplace comes before Discover
|
||||
expect(zhMarketplaceIndex).toBeLessThan(zhDiscoverIndex)
|
||||
})
|
||||
|
||||
it('should maintain consistent styling across locales', () => {
|
||||
// Render en-US
|
||||
mockDefaultLocale = 'en-US'
|
||||
const { container: enContainer, unmount: unmountEn } = render(<Description />)
|
||||
const enCategoryCount = enContainer.querySelectorAll('.body-md-medium').length
|
||||
unmountEn()
|
||||
|
||||
// Render zh-Hans
|
||||
mockDefaultLocale = 'zh-Hans'
|
||||
const { container: zhContainer } = render(<Description />)
|
||||
const zhCategoryCount = zhContainer.querySelectorAll('.body-md-medium').length
|
||||
|
||||
// Both should have same number of styled category spans
|
||||
expect(enCategoryCount).toBe(zhCategoryCount)
|
||||
expect(enCategoryCount).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,72 +1,236 @@
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
'use client'
|
||||
|
||||
const Description = () => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const locale = useLocale()
|
||||
import type { MotionValue } from 'motion/react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { motion, useMotionValue, useSpring, useTransform } from 'motion/react'
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import marketPlaceBg from '@/public/marketplace/hero-bg.jpg'
|
||||
import marketplaceGradientNoise from '@/public/marketplace/hero-gradient-noise.svg'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useCreationType } from '../atoms'
|
||||
import { PluginCategorySwitch, TemplateCategorySwitch } from '../category-switch/index'
|
||||
import { CREATION_TYPE } from '../search-params'
|
||||
|
||||
const isZhHans = locale === 'zh-Hans'
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title-4xl-semi-bold mb-2 shrink-0 text-center text-text-primary">
|
||||
{t('marketplace.empower')}
|
||||
</h1>
|
||||
<h2 className="body-md-regular flex shrink-0 items-center justify-center text-center text-text-tertiary">
|
||||
{
|
||||
isZhHans && (
|
||||
<>
|
||||
<span className="mr-1">{tCommon('operation.in')}</span>
|
||||
{t('marketplace.difyMarketplace')}
|
||||
{t('marketplace.discover')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isZhHans && (
|
||||
<>
|
||||
{t('marketplace.discover')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.models')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.tools')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.datasources')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.triggers')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.agents')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.extensions')}
|
||||
</span>
|
||||
{t('marketplace.and')}
|
||||
<span className="body-md-medium relative z-[1] ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.bundles')}
|
||||
</span>
|
||||
{
|
||||
!isZhHans && (
|
||||
<>
|
||||
<span className="mr-1">{tCommon('operation.in')}</span>
|
||||
{t('marketplace.difyMarketplace')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</h2>
|
||||
</>
|
||||
)
|
||||
type DescriptionProps = {
|
||||
className?: string
|
||||
scrollContainerId?: string
|
||||
marketplaceNav?: React.ReactNode
|
||||
}
|
||||
|
||||
export default Description
|
||||
// Constants for collapse animation
|
||||
const MAX_SCROLL = 120 // pixels to fully collapse
|
||||
const EXPANDED_PADDING_TOP = 32 // pt-8
|
||||
const COLLAPSED_PADDING_TOP = 12 // pt-3
|
||||
const EXPANDED_PADDING_BOTTOM = 24 // pb-6
|
||||
const COLLAPSED_PADDING_BOTTOM = 12 // pb-3
|
||||
|
||||
export const Description = ({
|
||||
className,
|
||||
scrollContainerId = 'marketplace-container',
|
||||
marketplaceNav,
|
||||
}: DescriptionProps) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const creationType = useCreationType()
|
||||
const isTemplatesView = creationType === CREATION_TYPE.templates
|
||||
const heroTitleKey = isTemplatesView ? 'marketplace.templatesHeroTitle' : 'marketplace.pluginsHeroTitle'
|
||||
const heroSubtitleKey = isTemplatesView ? 'marketplace.templatesHeroSubtitle' : 'marketplace.pluginsHeroSubtitle'
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const lastProgressRef = useRef(0)
|
||||
const headerRef = useRef<HTMLDivElement | null>(null)
|
||||
const titleContentRef = useRef<HTMLDivElement | null>(null)
|
||||
const progress = useMotionValue(0)
|
||||
const titleHeight = useMotionValue(72)
|
||||
const smoothProgress = useSpring(progress, { stiffness: 260, damping: 34 })
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = titleContentRef.current
|
||||
if (!node)
|
||||
return
|
||||
|
||||
const updateHeight = () => {
|
||||
titleHeight.set(node.scrollHeight)
|
||||
}
|
||||
|
||||
updateHeight()
|
||||
|
||||
if (typeof ResizeObserver === 'undefined')
|
||||
return
|
||||
|
||||
const observer = new ResizeObserver(updateHeight)
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [titleHeight])
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(scrollContainerId)
|
||||
if (!container)
|
||||
return
|
||||
|
||||
const handleScroll = () => {
|
||||
// Cancel any pending animation frame
|
||||
if (rafRef.current)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
|
||||
// Use requestAnimationFrame for smooth updates
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const scrollTop = Math.round(container.scrollTop)
|
||||
const heightDelta = container.scrollHeight - container.clientHeight
|
||||
const effectiveMaxScroll = Math.max(1, Math.min(MAX_SCROLL, heightDelta))
|
||||
const rawProgress = Math.min(Math.max(scrollTop / effectiveMaxScroll, 0), 1)
|
||||
const snappedProgress = rawProgress >= 0.95
|
||||
? 1
|
||||
: rawProgress <= 0.05
|
||||
? 0
|
||||
: Math.round(rawProgress * 100) / 100
|
||||
|
||||
if (snappedProgress !== lastProgressRef.current) {
|
||||
lastProgressRef.current = snappedProgress
|
||||
progress.set(snappedProgress)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
// Initial check
|
||||
handleScroll()
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
if (rafRef.current)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}, [progress, scrollContainerId])
|
||||
|
||||
// Calculate interpolated values
|
||||
const contentOpacity = useTransform(smoothProgress, [0, 1], [1, 0])
|
||||
const contentScale = useTransform(smoothProgress, [0, 1], [1, 0.9])
|
||||
const titleMaxHeight: MotionValue<number> = useTransform(
|
||||
[smoothProgress, titleHeight],
|
||||
(values: number[]) => values[1] * (1 - values[0]),
|
||||
)
|
||||
const tabsMarginTop = useTransform(smoothProgress, [0, 1], [48, marketplaceNav ? 16 : 0])
|
||||
const titleMarginTop = useTransform(smoothProgress, [0, 1], [marketplaceNav ? 80 : 0, 0])
|
||||
const paddingTop = useTransform(smoothProgress, [0, 1], [marketplaceNav ? COLLAPSED_PADDING_TOP : EXPANDED_PADDING_TOP, COLLAPSED_PADDING_TOP])
|
||||
const paddingBottom = useTransform(smoothProgress, [0, 1], [EXPANDED_PADDING_BOTTOM, COLLAPSED_PADDING_BOTTOM])
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(scrollContainerId)
|
||||
const header = headerRef.current
|
||||
if (!container || !header)
|
||||
return
|
||||
|
||||
let maxHeaderHeight = 0
|
||||
let lastAppliedOffset = 0
|
||||
const updateOffset = () => {
|
||||
const currentHeaderHeight = Math.round(header.getBoundingClientRect().height)
|
||||
maxHeaderHeight = Math.max(maxHeaderHeight, currentHeaderHeight)
|
||||
const collapsedHeight = Math.max(0, maxHeaderHeight - currentHeaderHeight)
|
||||
const currentScrollableTop = container.scrollHeight - container.clientHeight
|
||||
const baseScrollableTop = Math.max(0, currentScrollableTop - lastAppliedOffset)
|
||||
const shouldCompensate = baseScrollableTop <= maxHeaderHeight
|
||||
const nextOffset = shouldCompensate ? collapsedHeight : 0
|
||||
const offsetDelta = nextOffset - lastAppliedOffset
|
||||
|
||||
if (nextOffset > 0) {
|
||||
// Only compensate when content is short enough that header collapse can clamp scrollTop.
|
||||
container.style.setProperty('--marketplace-header-collapse-offset', `${nextOffset}px`)
|
||||
if (offsetDelta !== 0 && container.scrollTop > 0)
|
||||
container.scrollTop = Math.max(0, container.scrollTop + offsetDelta)
|
||||
}
|
||||
else {
|
||||
container.style.removeProperty('--marketplace-header-collapse-offset')
|
||||
}
|
||||
|
||||
lastAppliedOffset = nextOffset
|
||||
}
|
||||
|
||||
updateOffset()
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return () => {
|
||||
container.style.removeProperty('--marketplace-header-collapse-offset')
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateOffset)
|
||||
observer.observe(header)
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
container.style.removeProperty('--marketplace-header-collapse-offset')
|
||||
}
|
||||
}, [scrollContainerId])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={headerRef}
|
||||
className={cn(
|
||||
'sticky top-[60px] z-20 mx-4 mt-4 shrink-0 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border px-6',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
}}
|
||||
>
|
||||
{/* Blue base background */}
|
||||
<div className="absolute inset-0 bg-[rgba(0,51,255,0.9)]" />
|
||||
|
||||
{/* Decorative image with blend mode - showing top 1/3 of the image */}
|
||||
<div
|
||||
className="absolute inset-0 bg-no-repeat opacity-80 mix-blend-lighten"
|
||||
style={{
|
||||
backgroundImage: `url(${marketPlaceBg.src})`,
|
||||
backgroundSize: '110% auto',
|
||||
backgroundPosition: 'center top',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient & Noise overlay */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${marketplaceGradientNoise.src})` }}
|
||||
/>
|
||||
|
||||
{marketplaceNav}
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10">
|
||||
{/* Title and subtitle - fade out and scale down */}
|
||||
<motion.div
|
||||
style={{
|
||||
opacity: contentOpacity,
|
||||
scale: contentScale,
|
||||
transformOrigin: 'left top',
|
||||
maxHeight: titleMaxHeight,
|
||||
overflow: 'hidden',
|
||||
willChange: 'opacity, transform',
|
||||
marginTop: titleMarginTop,
|
||||
}}
|
||||
>
|
||||
<div ref={titleContentRef}>
|
||||
<h1 className="title-4xl-semi-bold mb-2 shrink-0 text-text-primary-on-surface">
|
||||
{t(heroTitleKey)}
|
||||
</h1>
|
||||
<h2 className="body-md-regular shrink-0 text-text-secondary-on-surface">
|
||||
{t(heroSubtitleKey)}
|
||||
</h2>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Category switch tabs - Plugin or Template based on creationType */}
|
||||
<motion.div style={{ marginTop: tabsMarginTop }}>
|
||||
{isTemplatesView
|
||||
? (
|
||||
<TemplateCategorySwitch variant="hero" />
|
||||
)
|
||||
: (
|
||||
<PluginCategorySwitch variant="hero" />
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import type {
|
||||
} from '../types'
|
||||
import type {
|
||||
CollectionsAndPluginsSearchParams,
|
||||
MarketplaceCollection,
|
||||
PluginCollection,
|
||||
PluginsSearchParams,
|
||||
} from './types'
|
||||
import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
|
||||
@ -31,8 +31,8 @@ import {
|
||||
*/
|
||||
export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>()
|
||||
const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
|
||||
const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
|
||||
const [pluginCollectionsOverride, setPluginCollections] = useState<PluginCollection[]>()
|
||||
const [pluginCollectionPluginsMapOverride, setPluginCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
|
||||
|
||||
const {
|
||||
data,
|
||||
@ -54,10 +54,10 @@ export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
const isLoading = !!queryParams && (isFetching || isPending)
|
||||
|
||||
return {
|
||||
marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections,
|
||||
setMarketplaceCollections,
|
||||
marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap,
|
||||
setMarketplaceCollectionPluginsMap,
|
||||
pluginCollections: pluginCollectionsOverride ?? data?.marketplaceCollections,
|
||||
setPluginCollections,
|
||||
pluginCollectionPluginsMap: pluginCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap,
|
||||
setPluginCollectionPluginsMap,
|
||||
queryMarketplaceCollectionsAndPlugins,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
|
||||
17
web/app/components/plugins/marketplace/hydration-client.tsx
Normal file
17
web/app/components/plugins/marketplace/hydration-client.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useHydrateAtoms } from 'jotai/utils'
|
||||
import { isMarketplacePlatformAtom } from './atoms'
|
||||
|
||||
export function HydrateClient({
|
||||
isMarketplacePlatform = false,
|
||||
children,
|
||||
}: {
|
||||
isMarketplacePlatform?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
useHydrateAtoms([
|
||||
[isMarketplacePlatformAtom, isMarketplacePlatform],
|
||||
])
|
||||
return <>{children}</>
|
||||
}
|
||||
@ -1,43 +1,270 @@
|
||||
import type { SearchParams } from 'nuqs/server'
|
||||
import type { MarketplaceSearchParams } from './search-params'
|
||||
import type { CreatorSearchParams, PluginsSearchParams, TemplateSearchParams } from './types'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { headers } from 'next/headers'
|
||||
import { createLoader } from 'nuqs/server'
|
||||
import { getQueryClientServer } from '@/context/query-client-server'
|
||||
import { marketplaceQuery } from '@/service/client'
|
||||
import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { marketplaceSearchParamsParsers } from './search-params'
|
||||
import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils'
|
||||
import {
|
||||
CATEGORY_ALL,
|
||||
DEFAULT_PLUGIN_SORT,
|
||||
DEFAULT_TEMPLATE_SORT,
|
||||
getValidatedPluginCategory,
|
||||
getValidatedTemplateCategory,
|
||||
PLUGIN_CATEGORY_WITH_COLLECTIONS,
|
||||
PLUGIN_TYPE_SEARCH_MAP,
|
||||
} from './constants'
|
||||
import { CREATION_TYPE, marketplaceSearchParamsParsers, SEARCH_TABS } from './search-params'
|
||||
import {
|
||||
getCollectionsParams,
|
||||
getMarketplaceCollectionsAndPlugins,
|
||||
getMarketplaceCreators,
|
||||
getMarketplacePlugins,
|
||||
getMarketplaceTemplateCollectionsAndTemplates,
|
||||
getMarketplaceTemplates,
|
||||
getPluginFilterType,
|
||||
} from './utils'
|
||||
|
||||
// The server side logic should move to marketplace's codebase so that we can get rid of Next.js
|
||||
export type Awaitable<T> = T | PromiseLike<T>
|
||||
|
||||
async function getDehydratedState(searchParams?: Promise<SearchParams>) {
|
||||
if (!searchParams) {
|
||||
const ZERO_WIDTH_SPACE = '\u200B'
|
||||
const SEARCH_PREVIEW_SIZE = 8
|
||||
const SEARCH_PAGE_SIZE = 40
|
||||
|
||||
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
|
||||
|
||||
function pickFirstParam(value: string | string[] | undefined) {
|
||||
if (Array.isArray(value))
|
||||
return value[0]
|
||||
return value
|
||||
}
|
||||
|
||||
function getNextPageParam(lastPage: { page: number, page_size: number, total: number }) {
|
||||
const nextPage = lastPage.page + 1
|
||||
const loaded = lastPage.page * lastPage.page_size
|
||||
return loaded < (lastPage.total || 0) ? nextPage : undefined
|
||||
}
|
||||
|
||||
type RouteParams = { category?: string, creationType?: string, searchTab?: string } | undefined
|
||||
|
||||
async function shouldSkipServerPrefetch() {
|
||||
const requestHeaders = await headers()
|
||||
return requestHeaders.get('sec-fetch-dest') !== 'document'
|
||||
}
|
||||
|
||||
async function getDehydratedState(
|
||||
params?: Awaitable<RouteParams>,
|
||||
searchParams?: Awaitable<SearchParams>,
|
||||
) {
|
||||
if (await shouldSkipServerPrefetch())
|
||||
return
|
||||
}
|
||||
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
|
||||
const params: MarketplaceSearchParams = await loadSearchParams(searchParams)
|
||||
|
||||
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) {
|
||||
return
|
||||
const rawParams = params ? await params : undefined
|
||||
const rawSearchParams = searchParams ? await searchParams : undefined
|
||||
const parsedSearchParams = await loadSearchParams(Promise.resolve(rawSearchParams ?? {}))
|
||||
|
||||
const routeState = rawSearchParams as SearchParams & {
|
||||
category?: string | string[]
|
||||
creationType?: string | string[]
|
||||
searchTab?: string | string[]
|
||||
}
|
||||
|
||||
const creationTypeFromSearch = pickFirstParam(routeState?.creationType)
|
||||
const categoryFromSearch = pickFirstParam(routeState?.category)
|
||||
const searchTabFromSearch = pickFirstParam(routeState?.searchTab)
|
||||
|
||||
const creationType = rawParams?.creationType === CREATION_TYPE.templates || creationTypeFromSearch === CREATION_TYPE.templates
|
||||
? CREATION_TYPE.templates
|
||||
: CREATION_TYPE.plugins
|
||||
const category = creationType === CREATION_TYPE.templates
|
||||
? getValidatedTemplateCategory(rawParams?.category ?? categoryFromSearch ?? CATEGORY_ALL)
|
||||
: getValidatedPluginCategory(rawParams?.category ?? categoryFromSearch ?? CATEGORY_ALL)
|
||||
const searchTabRaw = rawParams?.searchTab ?? searchTabFromSearch ?? ''
|
||||
const searchTab = SEARCH_TABS.includes(searchTabRaw as (typeof SEARCH_TABS)[number])
|
||||
? searchTabRaw as (typeof SEARCH_TABS)[number]
|
||||
: ''
|
||||
|
||||
const queryClient = getQueryClientServer()
|
||||
const prefetches: Promise<void>[] = []
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: marketplaceQuery.collections.queryKey({ input: { query: getCollectionsParams(params.category) } }),
|
||||
queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)),
|
||||
})
|
||||
if (searchTab) {
|
||||
const searchText = parsedSearchParams.q
|
||||
const query = searchText === ZERO_WIDTH_SPACE ? '' : searchText.trim()
|
||||
const hasQuery = !!searchText && (!!query || searchText === ZERO_WIDTH_SPACE)
|
||||
|
||||
if (!hasQuery)
|
||||
return
|
||||
|
||||
const pageSize = searchTab === 'all' ? SEARCH_PREVIEW_SIZE : SEARCH_PAGE_SIZE
|
||||
const searchFilterType = getValidatedPluginCategory(parsedSearchParams.searchType)
|
||||
const fetchPlugins = searchTab === 'all' || searchTab === 'plugins'
|
||||
const fetchTemplates = searchTab === 'all' || searchTab === 'templates'
|
||||
const fetchCreators = searchTab === 'all' || searchTab === 'creators'
|
||||
|
||||
if (fetchPlugins) {
|
||||
const pluginCategory = searchTab === 'plugins' && searchFilterType !== CATEGORY_ALL
|
||||
? searchFilterType
|
||||
: undefined
|
||||
const searchFilterTags = searchTab === 'plugins' && parsedSearchParams.searchTags.length > 0
|
||||
? parsedSearchParams.searchTags
|
||||
: undefined
|
||||
const pluginsParams: PluginsSearchParams = {
|
||||
query,
|
||||
page_size: pageSize,
|
||||
sort_by: DEFAULT_PLUGIN_SORT.sortBy,
|
||||
sort_order: DEFAULT_PLUGIN_SORT.sortOrder,
|
||||
category: pluginCategory,
|
||||
tags: searchFilterTags,
|
||||
type: getPluginFilterType(pluginCategory || PLUGIN_TYPE_SEARCH_MAP.all),
|
||||
}
|
||||
|
||||
prefetches.push(queryClient.prefetchInfiniteQuery({
|
||||
queryKey: marketplaceQuery.plugins.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: pluginsParams,
|
||||
params: { kind: pluginsParams.type === 'bundle' ? 'bundles' : 'plugins' },
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(pluginsParams, pageParam, signal),
|
||||
getNextPageParam,
|
||||
initialPageParam: 1,
|
||||
}))
|
||||
}
|
||||
|
||||
if (fetchTemplates) {
|
||||
const templateCategories = searchTab === 'templates' && parsedSearchParams.searchCategories.length > 0
|
||||
? parsedSearchParams.searchCategories
|
||||
: undefined
|
||||
const templateLanguages = searchTab === 'templates' && parsedSearchParams.searchLanguages.length > 0
|
||||
? parsedSearchParams.searchLanguages
|
||||
: undefined
|
||||
const templatesParams: TemplateSearchParams = {
|
||||
query,
|
||||
page_size: pageSize,
|
||||
sort_by: DEFAULT_TEMPLATE_SORT.sortBy,
|
||||
sort_order: DEFAULT_TEMPLATE_SORT.sortOrder,
|
||||
categories: templateCategories,
|
||||
languages: templateLanguages,
|
||||
}
|
||||
|
||||
prefetches.push(queryClient.prefetchInfiniteQuery({
|
||||
queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: templatesParams,
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplaceTemplates(templatesParams, pageParam, signal),
|
||||
getNextPageParam,
|
||||
initialPageParam: 1,
|
||||
}))
|
||||
}
|
||||
|
||||
if (fetchCreators) {
|
||||
const creatorsParams: CreatorSearchParams = {
|
||||
query,
|
||||
page_size: pageSize,
|
||||
}
|
||||
|
||||
prefetches.push(queryClient.prefetchInfiniteQuery({
|
||||
queryKey: marketplaceQuery.creators.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: creatorsParams,
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplaceCreators(creatorsParams, pageParam, signal),
|
||||
getNextPageParam,
|
||||
initialPageParam: 1,
|
||||
}))
|
||||
}
|
||||
}
|
||||
else if (creationType === CREATION_TYPE.templates) {
|
||||
prefetches.push(queryClient.prefetchQuery({
|
||||
queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query: undefined } }),
|
||||
queryFn: () => getMarketplaceTemplateCollectionsAndTemplates(),
|
||||
}))
|
||||
|
||||
const isSearchMode = !!parsedSearchParams.q
|
||||
|| category !== CATEGORY_ALL
|
||||
|| parsedSearchParams.languages.length > 0
|
||||
|
||||
if (isSearchMode) {
|
||||
const templatesParams: TemplateSearchParams = {
|
||||
query: parsedSearchParams.q,
|
||||
categories: category === CATEGORY_ALL ? undefined : [category],
|
||||
sort_by: DEFAULT_TEMPLATE_SORT.sortBy,
|
||||
sort_order: DEFAULT_TEMPLATE_SORT.sortOrder,
|
||||
...(parsedSearchParams.languages.length > 0 ? { languages: parsedSearchParams.languages } : {}),
|
||||
}
|
||||
|
||||
prefetches.push(queryClient.prefetchInfiniteQuery({
|
||||
queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: templatesParams,
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplaceTemplates(templatesParams, pageParam, signal),
|
||||
getNextPageParam,
|
||||
initialPageParam: 1,
|
||||
}))
|
||||
}
|
||||
}
|
||||
else {
|
||||
const pluginCategory = getValidatedPluginCategory(category)
|
||||
const collectionsParams = getCollectionsParams(pluginCategory)
|
||||
|
||||
prefetches.push(queryClient.prefetchQuery({
|
||||
queryKey: marketplaceQuery.plugins.collections.queryKey({ input: { query: collectionsParams } }),
|
||||
queryFn: () => getMarketplaceCollectionsAndPlugins(collectionsParams),
|
||||
}))
|
||||
|
||||
const isSearchMode = !!parsedSearchParams.q
|
||||
|| parsedSearchParams.tags.length > 0
|
||||
|| !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(pluginCategory)
|
||||
|
||||
if (isSearchMode) {
|
||||
const pluginsParams: PluginsSearchParams = {
|
||||
query: parsedSearchParams.q,
|
||||
category: pluginCategory === CATEGORY_ALL ? undefined : pluginCategory,
|
||||
tags: parsedSearchParams.tags,
|
||||
sort_by: DEFAULT_PLUGIN_SORT.sortBy,
|
||||
sort_order: DEFAULT_PLUGIN_SORT.sortOrder,
|
||||
type: getPluginFilterType(pluginCategory),
|
||||
}
|
||||
|
||||
prefetches.push(queryClient.prefetchInfiniteQuery({
|
||||
queryKey: marketplaceQuery.plugins.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: pluginsParams,
|
||||
params: { kind: pluginsParams.type === 'bundle' ? 'bundles' : 'plugins' },
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(pluginsParams, pageParam, signal),
|
||||
getNextPageParam,
|
||||
initialPageParam: 1,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (!prefetches.length)
|
||||
return
|
||||
|
||||
await Promise.all(prefetches)
|
||||
return dehydrate(queryClient)
|
||||
}
|
||||
|
||||
export async function HydrateQueryClient({
|
||||
params,
|
||||
searchParams,
|
||||
isMarketplacePlatform = false,
|
||||
children,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams> | undefined
|
||||
params?: Awaitable<{ category?: string, creationType?: string, searchTab?: string } | undefined>
|
||||
searchParams?: Awaitable<SearchParams>
|
||||
isMarketplacePlatform?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const dehydratedState = await getDehydratedState(searchParams)
|
||||
const dehydratedState = isMarketplacePlatform ? await getDehydratedState(params, searchParams) : null
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
{children}
|
||||
|
||||
@ -1,34 +1,48 @@
|
||||
import type { SearchParams } from 'nuqs'
|
||||
import type { Awaitable } from './hydration-server'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import Description from './description'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { HydrateClient } from './hydration-client'
|
||||
import { HydrateQueryClient } from './hydration-server'
|
||||
import ListWrapper from './list/list-wrapper'
|
||||
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
import MarketplaceContent from './marketplace-content'
|
||||
import MarketplaceHeader from './marketplace-header'
|
||||
|
||||
type MarketplaceProps = {
|
||||
showInstallButton?: boolean
|
||||
pluginTypeSwitchClassName?: string
|
||||
/**
|
||||
* Pass the search params from the request to prefetch data on the server.
|
||||
* Pass the search params & params from the request to prefetch data on the server.
|
||||
*/
|
||||
searchParams?: Promise<SearchParams>
|
||||
params?: Awaitable<{ category?: string, creationType?: string, searchTab?: string } | undefined>
|
||||
searchParams?: Awaitable<SearchParams>
|
||||
/**
|
||||
* Whether the marketplace is the platform marketplace.
|
||||
*/
|
||||
isMarketplacePlatform?: boolean
|
||||
marketplaceNav?: React.ReactNode
|
||||
}
|
||||
|
||||
const Marketplace = async ({
|
||||
const Marketplace = ({
|
||||
showInstallButton = true,
|
||||
pluginTypeSwitchClassName,
|
||||
params,
|
||||
searchParams,
|
||||
isMarketplacePlatform = false,
|
||||
marketplaceNav,
|
||||
}: MarketplaceProps) => {
|
||||
return (
|
||||
<TanstackQueryInitializer>
|
||||
<HydrateQueryClient searchParams={searchParams}>
|
||||
<Description />
|
||||
<StickySearchAndSwitchWrapper
|
||||
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
|
||||
/>
|
||||
<ListWrapper
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
<HydrateQueryClient
|
||||
isMarketplacePlatform={isMarketplacePlatform}
|
||||
searchParams={searchParams}
|
||||
params={params}
|
||||
>
|
||||
<HydrateClient
|
||||
isMarketplacePlatform={isMarketplacePlatform}
|
||||
>
|
||||
<MarketplaceHeader descriptionClassName={cn('mx-12 mt-1', isMarketplacePlatform && 'top-0 mx-0 mt-0 rounded-none')} marketplaceNav={marketplaceNav} />
|
||||
<MarketplaceContent
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
</HydrateClient>
|
||||
</HydrateQueryClient>
|
||||
</TanstackQueryInitializer>
|
||||
)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { MarketplaceCollection, SearchParamsFromCollection } from '../../types'
|
||||
import type { PluginCollection, SearchParamsFromCollection } from '../../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -20,6 +20,7 @@ vi.mock('#i18n', () => ({
|
||||
'plugin.marketplace.viewMore': 'View More',
|
||||
'plugin.marketplace.pluginsResult': `${options?.num || 0} plugins found`,
|
||||
'plugin.marketplace.noPluginFound': 'No plugins found',
|
||||
'plugin.marketplace.noTemplateFound': 'No template found',
|
||||
'plugin.detailPanel.operation.install': 'Install',
|
||||
'plugin.detailPanel.operation.detail': 'Detail',
|
||||
}
|
||||
@ -34,21 +35,28 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => {
|
||||
mockMarketplaceData: {
|
||||
plugins: undefined as Plugin[] | undefined,
|
||||
pluginsTotal: 0,
|
||||
marketplaceCollections: undefined as MarketplaceCollection[] | undefined,
|
||||
marketplaceCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
|
||||
pluginCollections: undefined as PluginCollection[] | undefined,
|
||||
pluginCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
|
||||
isLoading: false,
|
||||
page: 1,
|
||||
},
|
||||
mockMoreClick: vi.fn(),
|
||||
}
|
||||
})
|
||||
let mockSearchMode = false
|
||||
|
||||
vi.mock('../../state', () => ({
|
||||
useMarketplaceData: () => mockMarketplaceData,
|
||||
isPluginsData: (data: Record<string, unknown>) => 'pluginCollections' in data,
|
||||
}))
|
||||
|
||||
vi.mock('../../atoms', () => ({
|
||||
useMarketplaceMoreClick: () => mockMoreClick,
|
||||
useMarketplaceSearchMode: () => mockSearchMode,
|
||||
useCreationType: () => 'plugins',
|
||||
useFilterPluginTags: () => [[]],
|
||||
useActivePluginCategory: () => ['all'],
|
||||
useActiveTemplateCategory: () => ['all'],
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
@ -99,12 +107,17 @@ vi.mock('@/i18n-config/language', () => ({
|
||||
getLanguage: (locale: string) => locale || 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', () => ({
|
||||
getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record<string, string | undefined>) =>
|
||||
`/plugins/${plugin.org}/${plugin.name}`,
|
||||
getPluginDetailLinkInMarketplace: (plugin: Plugin) =>
|
||||
`/plugins/${plugin.org}/${plugin.name}`,
|
||||
}))
|
||||
// Mock marketplace utils
|
||||
vi.mock('../../utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../utils')>()
|
||||
return {
|
||||
...actual,
|
||||
getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record<string, string | undefined>) =>
|
||||
`/plugin/${plugin.org}/${plugin.name}`,
|
||||
getPluginDetailLinkInMarketplace: (plugin: Plugin) =>
|
||||
`/plugin/${plugin.org}/${plugin.name}`,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/plugins/card', () => ({
|
||||
default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => (
|
||||
@ -116,10 +129,10 @@ vi.mock('@/app/components/plugins/card', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
|
||||
default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => (
|
||||
<div data-testid="card-more-info">
|
||||
<span data-testid="download-count">{downloadCount}</span>
|
||||
// Mock CardTags component
|
||||
vi.mock('@/app/components/plugins/card/card-tags', () => ({
|
||||
default: ({ tags }: { tags: string[] }) => (
|
||||
<div data-testid="card-tags">
|
||||
<span data-testid="tags">{tags.join(',')}</span>
|
||||
</div>
|
||||
),
|
||||
@ -139,10 +152,11 @@ vi.mock('../../sort-dropdown', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Empty component
|
||||
vi.mock('../../empty', () => ({
|
||||
default: ({ className }: { className?: string }) => (
|
||||
default: ({ className, text }: { className?: string, text?: string }) => (
|
||||
<div data-testid="empty-component" className={className}>
|
||||
No plugins found
|
||||
{text || 'No plugins found'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -188,7 +202,7 @@ const createMockPluginList = (count: number): Plugin[] =>
|
||||
label: { 'en-US': `Plugin ${i}` },
|
||||
}))
|
||||
|
||||
const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({
|
||||
const createMockCollection = (overrides?: Partial<PluginCollection>): PluginCollection => ({
|
||||
name: `collection-${Math.random().toString(36).substring(7)}`,
|
||||
label: { 'en-US': 'Test Collection' },
|
||||
description: { 'en-US': 'Test collection description' },
|
||||
@ -200,7 +214,7 @@ const createMockCollection = (overrides?: Partial<MarketplaceCollection>): Marke
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockCollectionList = (count: number): MarketplaceCollection[] =>
|
||||
const createMockCollectionList = (count: number): PluginCollection[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
createMockCollection({
|
||||
name: `collection-${i}`,
|
||||
@ -213,8 +227,8 @@ const createMockCollectionList = (count: number): MarketplaceCollection[] =>
|
||||
// ================================
|
||||
describe('List', () => {
|
||||
const defaultProps = {
|
||||
marketplaceCollections: [] as MarketplaceCollection[],
|
||||
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
pluginCollections: [] as PluginCollection[],
|
||||
pluginCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
plugins: undefined,
|
||||
showInstallButton: false,
|
||||
cardContainerClassName: '',
|
||||
@ -225,6 +239,7 @@ describe('List', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSearchMode = false
|
||||
})
|
||||
|
||||
// ================================
|
||||
@ -248,8 +263,8 @@ describe('List', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
pluginCollections={collections}
|
||||
pluginCollectionPluginsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -294,8 +309,8 @@ describe('List', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
pluginCollections={collections}
|
||||
pluginCollectionPluginsMap={pluginsMap}
|
||||
plugins={[]}
|
||||
/>,
|
||||
)
|
||||
@ -406,12 +421,12 @@ describe('List', () => {
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty marketplaceCollections', () => {
|
||||
it('should handle empty pluginCollections', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -428,8 +443,8 @@ describe('List', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
pluginCollections={collections}
|
||||
pluginCollectionPluginsMap={pluginsMap}
|
||||
plugins={undefined}
|
||||
/>,
|
||||
)
|
||||
@ -476,12 +491,12 @@ describe('List', () => {
|
||||
// ================================
|
||||
describe('ListWithCollection', () => {
|
||||
const defaultProps = {
|
||||
marketplaceCollections: [] as MarketplaceCollection[],
|
||||
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
variant: 'plugins' as const,
|
||||
collections: [] as PluginCollection[],
|
||||
collectionItemsMap: {} as Record<string, Plugin[]>,
|
||||
showInstallButton: false,
|
||||
cardContainerClassName: '',
|
||||
cardRender: undefined,
|
||||
onMoreClick: undefined,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@ -508,8 +523,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -528,8 +543,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -548,8 +563,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -562,21 +577,21 @@ describe('ListWithCollection', () => {
|
||||
// View More Button Tests
|
||||
// ================================
|
||||
describe('View More Button', () => {
|
||||
it('should render View More button when collection is searchable', () => {
|
||||
it('should render View More button when collection is searchable and exceeds display limit', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
name: 'searchable-collection',
|
||||
searchable: true,
|
||||
search_params: { query: 'test' },
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
'searchable-collection': createMockPluginList(5),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -587,16 +602,38 @@ describe('ListWithCollection', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
searchable: false,
|
||||
search_params: undefined,
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
'collection-0': createMockPluginList(5),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('View More')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render View More button when items do not exceed display limit', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'small-collection',
|
||||
searchable: true,
|
||||
search_params: { query: 'test' },
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'small-collection': createMockPluginList(4),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -606,26 +643,106 @@ describe('ListWithCollection', () => {
|
||||
it('should call moreClick hook with search_params when View More is clicked', () => {
|
||||
const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' }
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
name: 'clickable-collection',
|
||||
searchable: true,
|
||||
search_params: searchParams,
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
'clickable-collection': createMockPluginList(5),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('View More'))
|
||||
|
||||
expect(mockMoreClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockMoreClick).toHaveBeenCalledWith(searchParams)
|
||||
expect(mockMoreClick).toHaveBeenCalledWith(searchParams, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Grid Display Limit Tests
|
||||
// ================================
|
||||
describe('Grid Display Limit', () => {
|
||||
it('should render at most 4 cards for searchable collections', () => {
|
||||
const collections = createMockCollectionList(1)
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(8),
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const cards = container.querySelectorAll('[data-testid^="card-plugin-"]')
|
||||
expect(cards.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should render all cards for non-searchable collections in carousel mode', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'carousel-collection',
|
||||
searchable: false,
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'carousel-collection': createMockPluginList(8),
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const cards = container.querySelectorAll('[data-testid^="card-plugin-"]')
|
||||
expect(cards.length).toBe(8)
|
||||
})
|
||||
|
||||
it('should render all cards when count is within the display limit', () => {
|
||||
const collections = createMockCollectionList(1)
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(3),
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const cards = container.querySelectorAll('[data-testid^="card-plugin-"]')
|
||||
expect(cards.length).toBe(3)
|
||||
})
|
||||
|
||||
it('should render exactly 4 cards when collection has exactly 4 items', () => {
|
||||
const collections = createMockCollectionList(1)
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(4),
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const cards = container.querySelectorAll('[data-testid^="card-plugin-"]')
|
||||
expect(cards.length).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
@ -649,8 +766,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
cardRender={customCardRender}
|
||||
/>,
|
||||
)
|
||||
@ -673,8 +790,8 @@ describe('ListWithCollection', () => {
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
cardContainerClassName="custom-container"
|
||||
/>,
|
||||
)
|
||||
@ -691,8 +808,8 @@ describe('ListWithCollection', () => {
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
)
|
||||
@ -710,8 +827,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
collections={[]}
|
||||
collectionItemsMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -726,8 +843,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -744,8 +861,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -761,11 +878,12 @@ describe('ListWithCollection', () => {
|
||||
describe('ListWrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSearchMode = false
|
||||
// Reset mock data
|
||||
mockMarketplaceData.plugins = undefined
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
mockMarketplaceData.marketplaceCollections = undefined
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
|
||||
mockMarketplaceData.pluginCollections = undefined
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = undefined
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.page = 1
|
||||
})
|
||||
@ -804,22 +922,52 @@ describe('ListWrapper', () => {
|
||||
|
||||
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render template empty state with flex content wrapper when templates are empty', () => {
|
||||
mockSearchMode = true
|
||||
delete (mockMarketplaceData as Record<string, unknown>).pluginCollections
|
||||
delete (mockMarketplaceData as Record<string, unknown>).pluginCollectionPluginsMap
|
||||
;(mockMarketplaceData as Record<string, unknown>).templateCollections = []
|
||||
;(mockMarketplaceData as Record<string, unknown>).templateCollectionTemplatesMap = {}
|
||||
;(mockMarketplaceData as Record<string, unknown>).templates = []
|
||||
|
||||
const { container } = render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
|
||||
expect(screen.getByText('No template found')).toBeInTheDocument()
|
||||
expect(container.querySelector('.relative.flex.grow.flex-col')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep plugin empty text when plugins are empty', () => {
|
||||
mockSearchMode = true
|
||||
mockMarketplaceData.plugins = []
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
mockMarketplaceData.pluginCollections = []
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {}
|
||||
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
|
||||
expect(screen.getByText('No plugins found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Plugins Header Tests
|
||||
// ================================
|
||||
describe('Plugins Header', () => {
|
||||
it('should render plugins result count when plugins are present', () => {
|
||||
it('should render list top info when search mode is enabled', () => {
|
||||
mockSearchMode = true
|
||||
mockMarketplaceData.plugins = createMockPluginList(5)
|
||||
mockMarketplaceData.pluginsTotal = 5
|
||||
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('5 plugins found')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render SortDropdown when plugins are present', () => {
|
||||
mockSearchMode = true
|
||||
mockMarketplaceData.plugins = createMockPluginList(1)
|
||||
|
||||
render(<ListWrapper />)
|
||||
@ -842,8 +990,8 @@ describe('ListWrapper', () => {
|
||||
describe('List Rendering Logic', () => {
|
||||
it('should render collections when not loading', () => {
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
@ -855,8 +1003,8 @@ describe('ListWrapper', () => {
|
||||
it('should render List when loading but page > 1', () => {
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 2
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
@ -880,13 +1028,13 @@ describe('ListWrapper', () => {
|
||||
})
|
||||
|
||||
it('should show View More button and call moreClick hook', () => {
|
||||
mockMarketplaceData.marketplaceCollections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
mockMarketplaceData.pluginCollections = [createMockCollection({
|
||||
name: 'wrapper-collection',
|
||||
searchable: true,
|
||||
search_params: { query: 'test' },
|
||||
})]
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'wrapper-collection': createMockPluginList(5),
|
||||
}
|
||||
|
||||
render(<ListWrapper />)
|
||||
@ -902,25 +1050,28 @@ describe('ListWrapper', () => {
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty plugins array', () => {
|
||||
mockSearchMode = true
|
||||
mockMarketplaceData.plugins = []
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('0 plugins found')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large pluginsTotal', () => {
|
||||
it('should handle many plugin results', () => {
|
||||
mockSearchMode = true
|
||||
mockMarketplaceData.plugins = createMockPluginList(10)
|
||||
mockMarketplaceData.pluginsTotal = 10000
|
||||
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('10000 plugins found')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-plugin-9')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle both loading and has plugins', () => {
|
||||
mockSearchMode = true
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 2
|
||||
mockMarketplaceData.plugins = createMockPluginList(5)
|
||||
@ -928,9 +1079,7 @@ describe('ListWrapper', () => {
|
||||
|
||||
render(<ListWrapper />)
|
||||
|
||||
// Should show plugins header and list
|
||||
expect(screen.getByText('50 plugins found')).toBeInTheDocument()
|
||||
// Should not show loading because page > 1
|
||||
expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -954,8 +1103,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@ -963,7 +1112,7 @@ describe('CardWrapper (via List integration)', () => {
|
||||
expect(screen.getByTestId('card-test-plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CardMoreInfo with download count and tags', () => {
|
||||
it('should render CardTags with tags', () => {
|
||||
const plugin = createMockPlugin({
|
||||
name: 'test-plugin',
|
||||
install_count: 5000,
|
||||
@ -972,14 +1121,13 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('card-more-info')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('download-count')).toHaveTextContent('5000')
|
||||
expect(screen.getByTestId('card-tags')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -992,8 +1140,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
/>,
|
||||
)
|
||||
@ -1012,8 +1160,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@ -1032,8 +1180,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@ -1053,15 +1201,15 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const detailLink = screen.getByText('Detail').closest('a')
|
||||
expect(detailLink).toHaveAttribute('href', '/plugins/test-org/link-test-plugin')
|
||||
expect(detailLink).toHaveAttribute('href', '/plugin/test-org/link-test-plugin')
|
||||
expect(detailLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
@ -1071,8 +1219,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@ -1087,8 +1235,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@ -1103,8 +1251,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@ -1129,8 +1277,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={false}
|
||||
/>,
|
||||
@ -1149,8 +1297,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={false}
|
||||
/>,
|
||||
@ -1164,8 +1312,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@ -1187,8 +1335,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@ -1204,8 +1352,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@ -1221,8 +1369,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@ -1243,8 +1391,8 @@ describe('Combined Workflows', () => {
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.page = 1
|
||||
mockMarketplaceData.marketplaceCollections = undefined
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
|
||||
mockMarketplaceData.pluginCollections = undefined
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = undefined
|
||||
})
|
||||
|
||||
it('should transition from loading to showing collections', async () => {
|
||||
@ -1257,8 +1405,8 @@ describe('Combined Workflows', () => {
|
||||
|
||||
// Simulate loading complete
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
@ -1269,8 +1417,9 @@ describe('Combined Workflows', () => {
|
||||
})
|
||||
|
||||
it('should transition from collections to search results', async () => {
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
mockSearchMode = true
|
||||
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
@ -1285,20 +1434,21 @@ describe('Combined Workflows', () => {
|
||||
rerender(<ListWrapper />)
|
||||
|
||||
expect(screen.queryByText('Collection 0')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('5 plugins found')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty search results', () => {
|
||||
mockSearchMode = true
|
||||
mockMarketplaceData.plugins = []
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
|
||||
expect(screen.getByText('0 plugins found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should support pagination (page > 1)', () => {
|
||||
mockSearchMode = true
|
||||
mockMarketplaceData.plugins = createMockPluginList(40)
|
||||
mockMarketplaceData.pluginsTotal = 80
|
||||
mockMarketplaceData.isLoading = true
|
||||
@ -1306,9 +1456,7 @@ describe('Combined Workflows', () => {
|
||||
|
||||
render(<ListWrapper />)
|
||||
|
||||
// Should show existing results while loading more
|
||||
expect(screen.getByText('80 plugins found')).toBeInTheDocument()
|
||||
// Should not show loading spinner for pagination
|
||||
expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1332,8 +1480,9 @@ describe('Accessibility', () => {
|
||||
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
variant="plugins"
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1344,17 +1493,19 @@ describe('Accessibility', () => {
|
||||
|
||||
it('should have clickable View More button', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
name: 'accessible-collection',
|
||||
searchable: true,
|
||||
search_params: { query: 'test' },
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
'accessible-collection': createMockPluginList(5),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
variant="plugins"
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1368,13 +1519,13 @@ describe('Accessibility', () => {
|
||||
|
||||
const { container } = render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
/>,
|
||||
)
|
||||
|
||||
const grid = container.querySelector('.grid-cols-4')
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1393,8 +1544,8 @@ describe('Performance', () => {
|
||||
const startTime = performance.now()
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
/>,
|
||||
)
|
||||
@ -1414,8 +1565,9 @@ describe('Performance', () => {
|
||||
const startTime = performance.now()
|
||||
render(
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
variant="plugins"
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
const endTime = performance.now()
|
||||
|
||||
@ -8,7 +8,7 @@ import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Card from '@/app/components/plugins/card'
|
||||
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
|
||||
import CardTags from '@/app/components/plugins/card/card-tags'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
|
||||
@ -43,14 +43,13 @@ const CardWrapperComponent = ({
|
||||
if (showInstallButton) {
|
||||
return (
|
||||
<div
|
||||
className="group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover"
|
||||
className="group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover"
|
||||
>
|
||||
<Card
|
||||
key={plugin.name}
|
||||
payload={plugin}
|
||||
footer={(
|
||||
<CardMoreInfo
|
||||
downloadCount={plugin.install_count}
|
||||
<CardTags
|
||||
tags={tagLabels}
|
||||
/>
|
||||
)}
|
||||
@ -88,15 +87,15 @@ const CardWrapperComponent = ({
|
||||
|
||||
return (
|
||||
<a
|
||||
className="group relative inline-block cursor-pointer rounded-xl"
|
||||
className="group relative block cursor-pointer rounded-xl"
|
||||
href={getPluginDetailLinkInMarketplace(plugin)}
|
||||
>
|
||||
<Card
|
||||
key={plugin.name}
|
||||
payload={plugin}
|
||||
disableOrgLink
|
||||
footer={(
|
||||
<CardMoreInfo
|
||||
downloadCount={plugin.install_count}
|
||||
<CardTags
|
||||
tags={tagLabels}
|
||||
/>
|
||||
)}
|
||||
|
||||
128
web/app/components/plugins/marketplace/list/carousel.tsx
Normal file
128
web/app/components/plugins/marketplace/list/carousel.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { Carousel as BaseCarousel, useCarousel } from '@/app/components/base/carousel'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CarouselProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
gap?: number
|
||||
showNavigation?: boolean
|
||||
showPagination?: boolean
|
||||
autoPlay?: boolean
|
||||
autoPlayInterval?: number
|
||||
}
|
||||
|
||||
type NavButtonProps = {
|
||||
direction: 'left' | 'right'
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
Icon: RemixiconComponentType
|
||||
}
|
||||
|
||||
const NavButton = ({ direction, disabled, onClick, Icon }: NavButtonProps) => (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer hover:bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={`Scroll ${direction}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-components-button-secondary-text" />
|
||||
</button>
|
||||
)
|
||||
|
||||
type CarouselControlsProps = {
|
||||
showPagination: boolean
|
||||
}
|
||||
|
||||
const CarouselControls = ({ showPagination }: CarouselControlsProps) => {
|
||||
const { api, selectedIndex, scrollPrev, scrollNext } = useCarousel()
|
||||
const scrollSnaps = api?.scrollSnapList() ?? []
|
||||
const totalPages = scrollSnaps.length
|
||||
|
||||
if (totalPages <= 1)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="absolute -top-10 right-0 flex items-center gap-3">
|
||||
{showPagination && (
|
||||
<div className="flex items-center gap-1">
|
||||
{scrollSnaps.map((snap, index) => (
|
||||
<button
|
||||
key={snap}
|
||||
className={cn(
|
||||
'h-[5px] w-[5px] rounded-full transition-all',
|
||||
selectedIndex === index
|
||||
? 'w-4 bg-components-button-primary-bg'
|
||||
: 'bg-components-button-secondary-border hover:bg-components-button-secondary-border-hover',
|
||||
)}
|
||||
onClick={() => api?.scrollTo(index)}
|
||||
aria-label={`Go to page ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<NavButton
|
||||
direction="left"
|
||||
disabled={totalPages <= 1}
|
||||
onClick={scrollPrev}
|
||||
Icon={RiArrowLeftSLine}
|
||||
/>
|
||||
<NavButton
|
||||
direction="right"
|
||||
disabled={totalPages <= 1}
|
||||
onClick={scrollNext}
|
||||
Icon={RiArrowRightSLine}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Carousel = ({
|
||||
children,
|
||||
className,
|
||||
gap = 12,
|
||||
showNavigation = true,
|
||||
showPagination = true,
|
||||
autoPlay = false,
|
||||
autoPlayInterval = 5000,
|
||||
}: CarouselProps) => {
|
||||
const plugins = useMemo(() => {
|
||||
if (!autoPlay)
|
||||
return []
|
||||
|
||||
return [
|
||||
BaseCarousel.Plugin.Autoplay({
|
||||
delay: autoPlayInterval,
|
||||
stopOnInteraction: false,
|
||||
stopOnMouseEnter: true,
|
||||
}),
|
||||
]
|
||||
}, [autoPlay, autoPlayInterval])
|
||||
|
||||
return (
|
||||
<BaseCarousel
|
||||
opts={{ align: 'start', containScroll: 'trimSnaps', loop: true }}
|
||||
plugins={plugins}
|
||||
className={className}
|
||||
overlay={showNavigation ? <CarouselControls showPagination={showPagination} /> : null}
|
||||
>
|
||||
<BaseCarousel.Content style={{ columnGap: `${gap}px` }}>
|
||||
{children}
|
||||
</BaseCarousel.Content>
|
||||
</BaseCarousel>
|
||||
)
|
||||
}
|
||||
|
||||
export default Carousel
|
||||
@ -0,0 +1,34 @@
|
||||
export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
|
||||
|
||||
export const GRID_DISPLAY_LIMIT = 4
|
||||
|
||||
export const CAROUSEL_PAGE_CLASS = 'w-full shrink-0'
|
||||
|
||||
export const CAROUSEL_PAGE_GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
|
||||
|
||||
export const CAROUSEL_PAGE_SIZE = {
|
||||
base: 2,
|
||||
sm: 4,
|
||||
lg: 6,
|
||||
xl: 8,
|
||||
} as const
|
||||
|
||||
export const CAROUSEL_BREAKPOINTS = {
|
||||
sm: 640,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
} as const
|
||||
|
||||
/** Collection name key that triggers carousel display (plugins: partners, templates: featured) */
|
||||
export const CAROUSEL_COLLECTION_NAMES = {
|
||||
partners: 'partners',
|
||||
featured: 'featured',
|
||||
} as const
|
||||
|
||||
export type BaseCollection = {
|
||||
name: string
|
||||
label: Record<string, string>
|
||||
description: Record<string, string>
|
||||
searchable?: boolean
|
||||
search_params?: { query?: string, sort_by?: string, sort_order?: string }
|
||||
}
|
||||
230
web/app/components/plugins/marketplace/list/collection-list.tsx
Normal file
230
web/app/components/plugins/marketplace/list/collection-list.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import type { SearchTab } from '../search-params'
|
||||
import type { SearchParamsFromCollection } from '../types'
|
||||
import type { BaseCollection } from './collection-constants'
|
||||
import type { Locale } from '@/i18n-config/language'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMarketplaceMoreClick } from '../atoms'
|
||||
import Empty from '../empty'
|
||||
import { buildCarouselPages, getItemKeyByField } from '../utils'
|
||||
import Carousel from './carousel'
|
||||
import {
|
||||
CAROUSEL_BREAKPOINTS,
|
||||
CAROUSEL_PAGE_CLASS,
|
||||
CAROUSEL_PAGE_GRID_CLASS,
|
||||
CAROUSEL_PAGE_SIZE,
|
||||
GRID_CLASS,
|
||||
GRID_DISPLAY_LIMIT,
|
||||
} from './collection-constants'
|
||||
|
||||
const getViewportWidth = () => typeof window === 'undefined' ? CAROUSEL_BREAKPOINTS.xl : window.innerWidth
|
||||
|
||||
const getCarouselItemsPerPage = (viewportWidth: number) => {
|
||||
if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl)
|
||||
return CAROUSEL_PAGE_SIZE.xl
|
||||
if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg)
|
||||
return CAROUSEL_PAGE_SIZE.lg
|
||||
if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm)
|
||||
return CAROUSEL_PAGE_SIZE.sm
|
||||
|
||||
return CAROUSEL_PAGE_SIZE.base
|
||||
}
|
||||
|
||||
type ViewMoreButtonProps = {
|
||||
searchParams?: SearchParamsFromCollection
|
||||
searchTab?: SearchTab
|
||||
}
|
||||
|
||||
export function ViewMoreButton({ searchParams, searchTab }: ViewMoreButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const onMoreClick = useMarketplaceMoreClick()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center text-text-accent system-xs-medium"
|
||||
onClick={() => onMoreClick(searchParams, searchTab)}
|
||||
>
|
||||
{t('marketplace.viewMore', { ns: 'plugin' })}
|
||||
<RiArrowRightSLine className="h-4 w-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CollectionHeaderProps<TCollection extends BaseCollection> = {
|
||||
collection: TCollection
|
||||
itemsLength: number
|
||||
locale: Locale
|
||||
viewMore: React.ReactNode
|
||||
}
|
||||
|
||||
export function CollectionHeader<TCollection extends BaseCollection>({
|
||||
collection,
|
||||
itemsLength,
|
||||
locale,
|
||||
viewMore,
|
||||
}: CollectionHeaderProps<TCollection>) {
|
||||
const showViewMore = collection.searchable
|
||||
&& !!collection.search_params
|
||||
&& itemsLength > GRID_DISPLAY_LIMIT
|
||||
|
||||
// The API only ships translations for a subset of locales (e.g. en_US and
|
||||
// zh_Hans). For any other locale (e.g. ja_JP, pt_BR, zh_Hant before fix)
|
||||
// the keyed lookup returns undefined and the title/description render as
|
||||
// empty divs. `renderI18nObject` from `@/i18n-config` handles the fallback
|
||||
// chain (locale → en_US → first available value) consistently across the
|
||||
// codebase.
|
||||
const lang = getLanguage(locale)
|
||||
const label = renderI18nObject(collection.label, lang)
|
||||
const description = renderI18nObject(collection.description, lang)
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex items-end justify-between">
|
||||
<div>
|
||||
<div className="text-text-primary title-xl-semi-bold">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-text-tertiary system-xs-regular">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
{showViewMore && viewMore}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CarouselCollectionProps<TItem> = {
|
||||
items: TItem[]
|
||||
getItemKey: (item: TItem) => string
|
||||
renderCard: (item: TItem) => React.ReactNode
|
||||
cardContainerClassName?: string
|
||||
}
|
||||
|
||||
export function CarouselCollection<TItem>({
|
||||
items,
|
||||
getItemKey,
|
||||
renderCard,
|
||||
cardContainerClassName,
|
||||
}: CarouselCollectionProps<TItem>) {
|
||||
const [viewportWidth, setViewportWidth] = useState(getViewportWidth)
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setViewportWidth(window.innerWidth)
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
const itemsPerPage = useMemo(() => getCarouselItemsPerPage(viewportWidth), [viewportWidth])
|
||||
const pages = useMemo(() => buildCarouselPages(items, itemsPerPage), [items, itemsPerPage])
|
||||
const hasMultiplePages = pages.length > 1
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
showNavigation={hasMultiplePages}
|
||||
showPagination={hasMultiplePages}
|
||||
autoPlay={hasMultiplePages}
|
||||
autoPlayInterval={5000}
|
||||
>
|
||||
{pages.map((pageItems, idx) => (
|
||||
<div
|
||||
key={pageItems[0] ? getItemKey(pageItems[0]) : idx}
|
||||
className={CAROUSEL_PAGE_CLASS}
|
||||
style={{ scrollSnapAlign: 'start' }}
|
||||
>
|
||||
<div className={cn(CAROUSEL_PAGE_GRID_CLASS, cardContainerClassName)}>
|
||||
{pageItems.map(item => (
|
||||
<div key={getItemKey(item)}>{renderCard(item)}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
|
||||
type CollectionListProps<TItem, TCollection extends BaseCollection> = {
|
||||
collections: TCollection[]
|
||||
collectionItemsMap: Record<string, TItem[]>
|
||||
/** Field name to use as item key (e.g. 'plugin_id', 'id'). */
|
||||
itemKeyField: keyof TItem
|
||||
renderCard: (item: TItem) => React.ReactNode
|
||||
/** Search tab for ViewMoreButton (e.g. 'templates' for template collections). */
|
||||
viewMoreSearchTab?: SearchTab
|
||||
gridClassName?: string
|
||||
cardContainerClassName?: string
|
||||
emptyClassName?: string
|
||||
emptyText?: string
|
||||
}
|
||||
|
||||
function CollectionList<TItem, TCollection extends BaseCollection>({
|
||||
collections,
|
||||
collectionItemsMap,
|
||||
itemKeyField,
|
||||
renderCard,
|
||||
viewMoreSearchTab,
|
||||
gridClassName = GRID_CLASS,
|
||||
cardContainerClassName,
|
||||
emptyClassName,
|
||||
emptyText,
|
||||
}: CollectionListProps<TItem, TCollection>) {
|
||||
const locale = useLocale()
|
||||
|
||||
const collectionsWithItems = collections.filter((collection) => {
|
||||
return collectionItemsMap[collection.name]?.length
|
||||
})
|
||||
|
||||
if (collectionsWithItems.length === 0) {
|
||||
return <Empty className={emptyClassName} text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
collectionsWithItems.map((collection) => {
|
||||
const items = collectionItemsMap[collection.name]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={collection.name}
|
||||
className="py-3"
|
||||
>
|
||||
<CollectionHeader
|
||||
collection={collection}
|
||||
itemsLength={items.length}
|
||||
locale={locale}
|
||||
viewMore={<ViewMoreButton searchParams={collection.search_params} searchTab={viewMoreSearchTab} />}
|
||||
/>
|
||||
{!collection.searchable
|
||||
? (
|
||||
<CarouselCollection
|
||||
items={items}
|
||||
getItemKey={item => getItemKeyByField(item, itemKeyField)}
|
||||
renderCard={renderCard}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={cn(gridClassName, cardContainerClassName)}>
|
||||
{items.slice(0, GRID_DISPLAY_LIMIT).map(item => (
|
||||
<div key={getItemKeyByField(item, itemKeyField)}>
|
||||
{renderCard(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CollectionList
|
||||
61
web/app/components/plugins/marketplace/list/flat-list.tsx
Normal file
61
web/app/components/plugins/marketplace/list/flat-list.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import type { Template } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { useTranslation } from '#i18n'
|
||||
import Empty from '../empty'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import { GRID_CLASS } from './collection-constants'
|
||||
import TemplateCard from './template-card'
|
||||
|
||||
type PluginsVariant = {
|
||||
variant: 'plugins'
|
||||
items: Plugin[]
|
||||
showInstallButton?: boolean
|
||||
}
|
||||
|
||||
type TemplatesVariant = {
|
||||
variant: 'templates'
|
||||
items: Template[]
|
||||
}
|
||||
|
||||
type FlatListProps = PluginsVariant | TemplatesVariant
|
||||
|
||||
const FlatList = (props: FlatListProps) => {
|
||||
const { items, variant } = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!items.length) {
|
||||
if (variant === 'templates')
|
||||
return <Empty text={t('marketplace.noTemplateFound', { ns: 'plugin' })} />
|
||||
return <Empty />
|
||||
}
|
||||
|
||||
if (variant === 'plugins') {
|
||||
const { showInstallButton } = props
|
||||
return (
|
||||
<div className={GRID_CLASS}>
|
||||
{items.map(plugin => (
|
||||
<CardWrapper
|
||||
key={`${plugin.org}/${plugin.name}`}
|
||||
plugin={plugin}
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={GRID_CLASS}>
|
||||
{items.map(template => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FlatList
|
||||
@ -1,14 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import type { Plugin } from '../../types'
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import type { PluginCollection } from '../types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Empty from '../empty'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import { GRID_CLASS } from './collection-constants'
|
||||
import ListWithCollection from './list-with-collection'
|
||||
|
||||
type ListProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
pluginCollections: PluginCollection[]
|
||||
pluginCollectionPluginsMap: Record<string, Plugin[]>
|
||||
plugins?: Plugin[]
|
||||
showInstallButton?: boolean
|
||||
cardContainerClassName?: string
|
||||
@ -16,8 +18,8 @@ type ListProps = {
|
||||
emptyClassName?: string
|
||||
}
|
||||
const List = ({
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
pluginCollections,
|
||||
pluginCollectionPluginsMap,
|
||||
plugins,
|
||||
showInstallButton,
|
||||
cardContainerClassName,
|
||||
@ -29,8 +31,9 @@ const List = ({
|
||||
{
|
||||
!plugins && (
|
||||
<ListWithCollection
|
||||
marketplaceCollections={marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
|
||||
variant="plugins"
|
||||
collections={pluginCollections}
|
||||
collectionItemsMap={pluginCollectionPluginsMap}
|
||||
showInstallButton={showInstallButton}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
cardRender={cardRender}
|
||||
@ -39,11 +42,7 @@ const List = ({
|
||||
}
|
||||
{
|
||||
plugins && !!plugins.length && (
|
||||
<div className={cn(
|
||||
'grid grid-cols-4 gap-3',
|
||||
cardContainerClassName,
|
||||
)}
|
||||
>
|
||||
<div className={cn(GRID_CLASS, cardContainerClassName)}>
|
||||
{
|
||||
plugins.map((plugin) => {
|
||||
if (cardRender)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user