Compare commits

..

4 Commits

Author SHA1 Message Date
Vlad D
4ac461d882 fix(api): serialize pipeline file-upload created_at (#32098)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-09 17:50:29 +08:00
Vlad D
fa763216d0 fix(api): register knowledge pipeline service API routes (#32097)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Trigger i18n Sync on Push / trigger (push) Has been cancelled
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com>
2026-02-09 17:43:36 +08:00
wangxiaolei
d546210040 refactor: document_indexing_sync_task split db session (#32129)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-09 17:12:16 +08:00
Stephen Zhou
4e0a7a7f9e chore: fix type for useTranslation in #i18n (#32134)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-09 16:42:53 +08:00
95 changed files with 1377 additions and 4896 deletions

View File

@@ -34,6 +34,7 @@ from .dataset import (
metadata,
segment,
)
from .dataset.rag_pipeline import rag_pipeline_workflow
from .end_user import end_user
from .workspace import models
@@ -53,6 +54,7 @@ __all__ = [
"message",
"metadata",
"models",
"rag_pipeline_workflow",
"segment",
"site",
"workflow",

View File

@@ -1,5 +1,3 @@
import string
import uuid
from collections.abc import Generator
from typing import Any
@@ -12,6 +10,7 @@ from controllers.common.errors import FilenameNotExistsError, NoFileUploadedErro
from controllers.common.schema import register_schema_model
from controllers.service_api import service_api_ns
from controllers.service_api.dataset.error import PipelineRunError
from controllers.service_api.dataset.rag_pipeline.serializers import serialize_upload_file
from controllers.service_api.wraps import DatasetApiResource
from core.app.apps.pipeline.pipeline_generator import PipelineGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
@@ -41,7 +40,7 @@ register_schema_model(service_api_ns, DatasourceNodeRunPayload)
register_schema_model(service_api_ns, PipelineRunApiEntity)
@service_api_ns.route(f"/datasets/{uuid:dataset_id}/pipeline/datasource-plugins")
@service_api_ns.route("/datasets/<uuid:dataset_id>/pipeline/datasource-plugins")
class DatasourcePluginsApi(DatasetApiResource):
"""Resource for datasource plugins."""
@@ -76,7 +75,7 @@ class DatasourcePluginsApi(DatasetApiResource):
return datasource_plugins, 200
@service_api_ns.route(f"/datasets/{uuid:dataset_id}/pipeline/datasource/nodes/{string:node_id}/run")
@service_api_ns.route("/datasets/<uuid:dataset_id>/pipeline/datasource/nodes/<string:node_id>/run")
class DatasourceNodeRunApi(DatasetApiResource):
"""Resource for datasource node run."""
@@ -131,7 +130,7 @@ class DatasourceNodeRunApi(DatasetApiResource):
)
@service_api_ns.route(f"/datasets/{uuid:dataset_id}/pipeline/run")
@service_api_ns.route("/datasets/<uuid:dataset_id>/pipeline/run")
class PipelineRunApi(DatasetApiResource):
"""Resource for datasource node run."""
@@ -232,12 +231,4 @@ class KnowledgebasePipelineFileUploadApi(DatasetApiResource):
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
return {
"id": upload_file.id,
"name": upload_file.name,
"size": upload_file.size,
"extension": upload_file.extension,
"mime_type": upload_file.mime_type,
"created_by": upload_file.created_by,
"created_at": upload_file.created_at,
}, 201
return serialize_upload_file(upload_file), 201

View File

@@ -0,0 +1,22 @@
"""
Serialization helpers for Service API knowledge pipeline endpoints.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from models.model import UploadFile
def serialize_upload_file(upload_file: UploadFile) -> dict[str, Any]:
return {
"id": upload_file.id,
"name": upload_file.name,
"size": upload_file.size,
"extension": upload_file.extension,
"mime_type": upload_file.mime_type,
"created_by": upload_file.created_by,
"created_at": upload_file.created_at.isoformat() if upload_file.created_at else None,
}

View File

@@ -217,6 +217,8 @@ def validate_dataset_token(view: Callable[Concatenate[T, P], R] | None = None):
def decorator(view: Callable[Concatenate[T, P], R]):
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
api_token = validate_and_get_api_token("dataset")
# get url path dataset_id from positional args or kwargs
# Flask passes URL path parameters as positional arguments
dataset_id = None
@@ -253,12 +255,18 @@ def validate_dataset_token(view: Callable[Concatenate[T, P], R] | None = None):
# Validate dataset if dataset_id is provided
if dataset_id:
dataset_id = str(dataset_id)
dataset = db.session.query(Dataset).where(Dataset.id == dataset_id).first()
dataset = (
db.session.query(Dataset)
.where(
Dataset.id == dataset_id,
Dataset.tenant_id == api_token.tenant_id,
)
.first()
)
if not dataset:
raise NotFound("Dataset not found.")
if not dataset.enable_api:
raise Forbidden("Dataset api access is not enabled.")
api_token = validate_and_get_api_token("dataset")
tenant_account_join = (
db.session.query(Tenant, TenantAccountJoin)
.where(Tenant.id == api_token.tenant_id)

View File

@@ -1329,10 +1329,24 @@ class RagPipelineService:
"""
Get datasource plugins
"""
dataset: Dataset | None = db.session.query(Dataset).where(Dataset.id == dataset_id).first()
dataset: Dataset | None = (
db.session.query(Dataset)
.where(
Dataset.id == dataset_id,
Dataset.tenant_id == tenant_id,
)
.first()
)
if not dataset:
raise ValueError("Dataset not found")
pipeline: Pipeline | None = db.session.query(Pipeline).where(Pipeline.id == dataset.pipeline_id).first()
pipeline: Pipeline | None = (
db.session.query(Pipeline)
.where(
Pipeline.id == dataset.pipeline_id,
Pipeline.tenant_id == tenant_id,
)
.first()
)
if not pipeline:
raise ValueError("Pipeline not found")
@@ -1413,10 +1427,24 @@ class RagPipelineService:
"""
Get pipeline
"""
dataset: Dataset | None = db.session.query(Dataset).where(Dataset.id == dataset_id).first()
dataset: Dataset | None = (
db.session.query(Dataset)
.where(
Dataset.id == dataset_id,
Dataset.tenant_id == tenant_id,
)
.first()
)
if not dataset:
raise ValueError("Dataset not found")
pipeline: Pipeline | None = db.session.query(Pipeline).where(Pipeline.id == dataset.pipeline_id).first()
pipeline: Pipeline | None = (
db.session.query(Pipeline)
.where(
Pipeline.id == dataset.pipeline_id,
Pipeline.tenant_id == tenant_id,
)
.first()
)
if not pipeline:
raise ValueError("Pipeline not found")
return pipeline

View File

@@ -23,40 +23,40 @@ def clean_notion_document_task(document_ids: list[str], dataset_id: str):
"""
logger.info(click.style(f"Start clean document when import form notion document deleted: {dataset_id}", fg="green"))
start_at = time.perf_counter()
total_index_node_ids = []
with session_factory.create_session() as session:
try:
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
if not dataset:
raise Exception("Document has no dataset")
index_type = dataset.doc_form
index_processor = IndexProcessorFactory(index_type).init_index_processor()
if not dataset:
raise Exception("Document has no dataset")
index_type = dataset.doc_form
index_processor = IndexProcessorFactory(index_type).init_index_processor()
document_delete_stmt = delete(Document).where(Document.id.in_(document_ids))
session.execute(document_delete_stmt)
document_delete_stmt = delete(Document).where(Document.id.in_(document_ids))
session.execute(document_delete_stmt)
for document_id in document_ids:
segments = session.scalars(
select(DocumentSegment).where(DocumentSegment.document_id == document_id)
).all()
index_node_ids = [segment.index_node_id for segment in segments]
for document_id in document_ids:
segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all()
total_index_node_ids.extend([segment.index_node_id for segment in segments])
index_processor.clean(
dataset, index_node_ids, with_keywords=True, delete_child_chunks=True, delete_summaries=True
)
segment_ids = [segment.id for segment in segments]
segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.id.in_(segment_ids))
session.execute(segment_delete_stmt)
session.commit()
end_at = time.perf_counter()
logger.info(
click.style(
"Clean document when import form notion document deleted end :: {} latency: {}".format(
dataset_id, end_at - start_at
),
fg="green",
)
with session_factory.create_session() as session:
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
if dataset:
index_processor.clean(
dataset, total_index_node_ids, with_keywords=True, delete_child_chunks=True, delete_summaries=True
)
except Exception:
logger.exception("Cleaned document when import form notion document deleted failed")
with session_factory.create_session() as session, session.begin():
segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.document_id.in_(document_ids))
session.execute(segment_delete_stmt)
end_at = time.perf_counter()
logger.info(
click.style(
"Clean document when import form notion document deleted end :: {} latency: {}".format(
dataset_id, end_at - start_at
),
fg="green",
)
)

View File

@@ -27,6 +27,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str):
"""
logger.info(click.style(f"Start sync document: {document_id}", fg="green"))
start_at = time.perf_counter()
tenant_id = None
with session_factory.create_session() as session, session.begin():
document = session.query(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).first()
@@ -35,94 +36,120 @@ def document_indexing_sync_task(dataset_id: str, document_id: str):
logger.info(click.style(f"Document not found: {document_id}", fg="red"))
return
if document.indexing_status == "parsing":
logger.info(click.style(f"Document {document_id} is already being processed, skipping", fg="yellow"))
return
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
if not dataset:
raise Exception("Dataset not found")
data_source_info = document.data_source_info_dict
if document.data_source_type == "notion_import":
if (
not data_source_info
or "notion_page_id" not in data_source_info
or "notion_workspace_id" not in data_source_info
):
raise ValueError("no notion page found")
workspace_id = data_source_info["notion_workspace_id"]
page_id = data_source_info["notion_page_id"]
page_type = data_source_info["type"]
page_edited_time = data_source_info["last_edited_time"]
credential_id = data_source_info.get("credential_id")
if document.data_source_type != "notion_import":
logger.info(click.style(f"Document {document_id} is not a notion_import, skipping", fg="yellow"))
return
# Get credentials from datasource provider
datasource_provider_service = DatasourceProviderService()
credential = datasource_provider_service.get_datasource_credentials(
tenant_id=document.tenant_id,
credential_id=credential_id,
provider="notion_datasource",
plugin_id="langgenius/notion_datasource",
)
if (
not data_source_info
or "notion_page_id" not in data_source_info
or "notion_workspace_id" not in data_source_info
):
raise ValueError("no notion page found")
if not credential:
logger.error(
"Datasource credential not found for document %s, tenant_id: %s, credential_id: %s",
document_id,
document.tenant_id,
credential_id,
)
workspace_id = data_source_info["notion_workspace_id"]
page_id = data_source_info["notion_page_id"]
page_type = data_source_info["type"]
page_edited_time = data_source_info["last_edited_time"]
credential_id = data_source_info.get("credential_id")
tenant_id = document.tenant_id
index_type = document.doc_form
segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all()
index_node_ids = [segment.index_node_id for segment in segments]
# Get credentials from datasource provider
datasource_provider_service = DatasourceProviderService()
credential = datasource_provider_service.get_datasource_credentials(
tenant_id=tenant_id,
credential_id=credential_id,
provider="notion_datasource",
plugin_id="langgenius/notion_datasource",
)
if not credential:
logger.error(
"Datasource credential not found for document %s, tenant_id: %s, credential_id: %s",
document_id,
tenant_id,
credential_id,
)
with session_factory.create_session() as session, session.begin():
document = session.query(Document).filter_by(id=document_id).first()
if document:
document.indexing_status = "error"
document.error = "Datasource credential not found. Please reconnect your Notion workspace."
document.stopped_at = naive_utc_now()
return
return
loader = NotionExtractor(
notion_workspace_id=workspace_id,
notion_obj_id=page_id,
notion_page_type=page_type,
notion_access_token=credential.get("integration_secret"),
tenant_id=document.tenant_id,
)
loader = NotionExtractor(
notion_workspace_id=workspace_id,
notion_obj_id=page_id,
notion_page_type=page_type,
notion_access_token=credential.get("integration_secret"),
tenant_id=tenant_id,
)
last_edited_time = loader.get_notion_last_edited_time()
last_edited_time = loader.get_notion_last_edited_time()
if last_edited_time == page_edited_time:
logger.info(click.style(f"Document {document_id} content unchanged, skipping sync", fg="yellow"))
return
# check the page is updated
if last_edited_time != page_edited_time:
document.indexing_status = "parsing"
document.processing_started_at = naive_utc_now()
logger.info(click.style(f"Document {document_id} content changed, starting sync", fg="green"))
# delete all document segment and index
try:
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
if not dataset:
raise Exception("Dataset not found")
index_type = document.doc_form
index_processor = IndexProcessorFactory(index_type).init_index_processor()
try:
index_processor = IndexProcessorFactory(index_type).init_index_processor()
with session_factory.create_session() as session:
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
if dataset:
index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True)
logger.info(click.style(f"Cleaned vector index for document {document_id}", fg="green"))
except Exception:
logger.exception("Failed to clean vector index for document %s", document_id)
segments = session.scalars(
select(DocumentSegment).where(DocumentSegment.document_id == document_id)
).all()
index_node_ids = [segment.index_node_id for segment in segments]
with session_factory.create_session() as session, session.begin():
document = session.query(Document).filter_by(id=document_id).first()
if not document:
logger.warning(click.style(f"Document {document_id} not found during sync", fg="yellow"))
return
# delete from vector index
index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True)
data_source_info = document.data_source_info_dict
data_source_info["last_edited_time"] = last_edited_time
document.data_source_info = data_source_info
segment_ids = [segment.id for segment in segments]
segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.id.in_(segment_ids))
session.execute(segment_delete_stmt)
document.indexing_status = "parsing"
document.processing_started_at = naive_utc_now()
end_at = time.perf_counter()
logger.info(
click.style(
"Cleaned document when document update data source or process rule: {} latency: {}".format(
document_id, end_at - start_at
),
fg="green",
)
)
except Exception:
logger.exception("Cleaned document when document update data source or process rule failed")
segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.document_id == document_id)
session.execute(segment_delete_stmt)
try:
indexing_runner = IndexingRunner()
indexing_runner.run([document])
end_at = time.perf_counter()
logger.info(click.style(f"update document: {document.id} latency: {end_at - start_at}", fg="green"))
except DocumentIsPausedError as ex:
logger.info(click.style(str(ex), fg="yellow"))
except Exception:
logger.exception("document_indexing_sync_task failed, document_id: %s", document_id)
logger.info(click.style(f"Deleted segments for document {document_id}", fg="green"))
try:
indexing_runner = IndexingRunner()
with session_factory.create_session() as session:
document = session.query(Document).filter_by(id=document_id).first()
if document:
indexing_runner.run([document])
end_at = time.perf_counter()
logger.info(click.style(f"Sync completed for document {document_id} latency: {end_at - start_at}", fg="green"))
except DocumentIsPausedError as ex:
logger.info(click.style(str(ex), fg="yellow"))
except Exception as e:
logger.exception("document_indexing_sync_task failed for document_id: %s", document_id)
with session_factory.create_session() as session, session.begin():
document = session.query(Document).filter_by(id=document_id).first()
if document:
document.indexing_status = "error"
document.error = str(e)
document.stopped_at = naive_utc_now()

View File

@@ -153,8 +153,7 @@ class TestCleanNotionDocumentTask:
# Execute cleanup task
clean_notion_document_task(document_ids, dataset.id)
# Verify documents and segments are deleted
assert db_session_with_containers.query(Document).filter(Document.id.in_(document_ids)).count() == 0
# Verify segments are deleted
assert (
db_session_with_containers.query(DocumentSegment)
.filter(DocumentSegment.document_id.in_(document_ids))
@@ -162,9 +161,9 @@ class TestCleanNotionDocumentTask:
== 0
)
# Verify index processor was called for each document
# Verify index processor was called
mock_processor = mock_index_processor_factory.return_value.init_index_processor.return_value
assert mock_processor.clean.call_count == len(document_ids)
mock_processor.clean.assert_called_once()
# This test successfully verifies:
# 1. Document records are properly deleted from the database
@@ -186,12 +185,12 @@ class TestCleanNotionDocumentTask:
non_existent_dataset_id = str(uuid.uuid4())
document_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
# Execute cleanup task with non-existent dataset
clean_notion_document_task(document_ids, non_existent_dataset_id)
# Execute cleanup task with non-existent dataset - expect exception
with pytest.raises(Exception, match="Document has no dataset"):
clean_notion_document_task(document_ids, non_existent_dataset_id)
# Verify that the index processor was not called
mock_processor = mock_index_processor_factory.return_value.init_index_processor.return_value
mock_processor.clean.assert_not_called()
# Verify that the index processor factory was not used
mock_index_processor_factory.return_value.init_index_processor.assert_not_called()
def test_clean_notion_document_task_empty_document_list(
self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies
@@ -229,9 +228,13 @@ class TestCleanNotionDocumentTask:
# Execute cleanup task with empty document list
clean_notion_document_task([], dataset.id)
# Verify that the index processor was not called
# Verify that the index processor was called once with empty node list
mock_processor = mock_index_processor_factory.return_value.init_index_processor.return_value
mock_processor.clean.assert_not_called()
assert mock_processor.clean.call_count == 1
args, kwargs = mock_processor.clean.call_args
# args: (dataset, total_index_node_ids)
assert isinstance(args[0], Dataset)
assert args[1] == []
def test_clean_notion_document_task_with_different_index_types(
self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies
@@ -315,8 +318,7 @@ class TestCleanNotionDocumentTask:
# Note: This test successfully verifies cleanup with different document types.
# The task properly handles various index types and document configurations.
# Verify documents and segments are deleted
assert db_session_with_containers.query(Document).filter(Document.id == document.id).count() == 0
# Verify segments are deleted
assert (
db_session_with_containers.query(DocumentSegment)
.filter(DocumentSegment.document_id == document.id)
@@ -404,8 +406,7 @@ class TestCleanNotionDocumentTask:
# Execute cleanup task
clean_notion_document_task([document.id], dataset.id)
# Verify documents and segments are deleted
assert db_session_with_containers.query(Document).filter(Document.id == document.id).count() == 0
# Verify segments are deleted
assert (
db_session_with_containers.query(DocumentSegment).filter(DocumentSegment.document_id == document.id).count()
== 0
@@ -508,8 +509,7 @@ class TestCleanNotionDocumentTask:
clean_notion_document_task(documents_to_clean, dataset.id)
# Verify only specified documents and segments are deleted
assert db_session_with_containers.query(Document).filter(Document.id.in_(documents_to_clean)).count() == 0
# Verify only specified documents' segments are deleted
assert (
db_session_with_containers.query(DocumentSegment)
.filter(DocumentSegment.document_id.in_(documents_to_clean))
@@ -697,11 +697,12 @@ class TestCleanNotionDocumentTask:
db_session_with_containers.commit()
# Mock index processor to raise an exception
mock_index_processor = mock_index_processor_factory.init_index_processor.return_value
mock_index_processor = mock_index_processor_factory.return_value.init_index_processor.return_value
mock_index_processor.clean.side_effect = Exception("Index processor error")
# Execute cleanup task - it should handle the exception gracefully
clean_notion_document_task([document.id], dataset.id)
# Execute cleanup task - current implementation propagates the exception
with pytest.raises(Exception, match="Index processor error"):
clean_notion_document_task([document.id], dataset.id)
# Note: This test demonstrates the task's error handling capability.
# Even with external service errors, the database operations complete successfully.
@@ -803,8 +804,7 @@ class TestCleanNotionDocumentTask:
all_document_ids = [doc.id for doc in documents]
clean_notion_document_task(all_document_ids, dataset.id)
# Verify all documents and segments are deleted
assert db_session_with_containers.query(Document).filter(Document.dataset_id == dataset.id).count() == 0
# Verify all segments are deleted
assert (
db_session_with_containers.query(DocumentSegment).filter(DocumentSegment.dataset_id == dataset.id).count()
== 0
@@ -914,8 +914,7 @@ class TestCleanNotionDocumentTask:
clean_notion_document_task([target_document.id], target_dataset.id)
# Verify only documents from target dataset are deleted
assert db_session_with_containers.query(Document).filter(Document.id == target_document.id).count() == 0
# Verify only documents' segments from target dataset are deleted
assert (
db_session_with_containers.query(DocumentSegment)
.filter(DocumentSegment.document_id == target_document.id)
@@ -1030,8 +1029,7 @@ class TestCleanNotionDocumentTask:
all_document_ids = [doc.id for doc in documents]
clean_notion_document_task(all_document_ids, dataset.id)
# Verify all documents and segments are deleted regardless of status
assert db_session_with_containers.query(Document).filter(Document.dataset_id == dataset.id).count() == 0
# Verify all segments are deleted regardless of status
assert (
db_session_with_containers.query(DocumentSegment).filter(DocumentSegment.dataset_id == dataset.id).count()
== 0
@@ -1142,8 +1140,7 @@ class TestCleanNotionDocumentTask:
# Execute cleanup task
clean_notion_document_task([document.id], dataset.id)
# Verify documents and segments are deleted
assert db_session_with_containers.query(Document).filter(Document.id == document.id).count() == 0
# Verify segments are deleted
assert (
db_session_with_containers.query(DocumentSegment).filter(DocumentSegment.document_id == document.id).count()
== 0

View File

@@ -0,0 +1,62 @@
"""
Unit tests for Service API knowledge pipeline file-upload serialization.
"""
import importlib.util
from datetime import UTC, datetime
from pathlib import Path
class FakeUploadFile:
id: str
name: str
size: int
extension: str
mime_type: str
created_by: str
created_at: datetime | None
def _load_serialize_upload_file():
api_dir = Path(__file__).resolve().parents[5]
serializers_path = api_dir / "controllers" / "service_api" / "dataset" / "rag_pipeline" / "serializers.py"
spec = importlib.util.spec_from_file_location("rag_pipeline_serializers", serializers_path)
assert spec
assert spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore[attr-defined]
return module.serialize_upload_file
def test_file_upload_created_at_is_isoformat_string():
serialize_upload_file = _load_serialize_upload_file()
created_at = datetime(2026, 2, 8, 12, 0, 0, tzinfo=UTC)
upload_file = FakeUploadFile()
upload_file.id = "file-1"
upload_file.name = "test.pdf"
upload_file.size = 123
upload_file.extension = "pdf"
upload_file.mime_type = "application/pdf"
upload_file.created_by = "account-1"
upload_file.created_at = created_at
result = serialize_upload_file(upload_file)
assert result["created_at"] == created_at.isoformat()
def test_file_upload_created_at_none_serializes_to_null():
serialize_upload_file = _load_serialize_upload_file()
upload_file = FakeUploadFile()
upload_file.id = "file-1"
upload_file.name = "test.pdf"
upload_file.size = 123
upload_file.extension = "pdf"
upload_file.mime_type = "application/pdf"
upload_file.created_by = "account-1"
upload_file.created_at = None
result = serialize_upload_file(upload_file)
assert result["created_at"] is None

View File

@@ -0,0 +1,54 @@
"""
Unit tests for Service API knowledge pipeline route registration.
"""
import ast
from pathlib import Path
def test_rag_pipeline_routes_registered():
api_dir = Path(__file__).resolve().parents[5]
service_api_init = api_dir / "controllers" / "service_api" / "__init__.py"
rag_pipeline_workflow = (
api_dir / "controllers" / "service_api" / "dataset" / "rag_pipeline" / "rag_pipeline_workflow.py"
)
assert service_api_init.exists()
assert rag_pipeline_workflow.exists()
init_tree = ast.parse(service_api_init.read_text(encoding="utf-8"))
import_found = False
for node in ast.walk(init_tree):
if not isinstance(node, ast.ImportFrom):
continue
if node.module != "dataset.rag_pipeline" or node.level != 1:
continue
if any(alias.name == "rag_pipeline_workflow" for alias in node.names):
import_found = True
break
assert import_found, "from .dataset.rag_pipeline import rag_pipeline_workflow not found in service_api/__init__.py"
workflow_tree = ast.parse(rag_pipeline_workflow.read_text(encoding="utf-8"))
route_paths: set[str] = set()
for node in ast.walk(workflow_tree):
if not isinstance(node, ast.ClassDef):
continue
for decorator in node.decorator_list:
if not isinstance(decorator, ast.Call):
continue
if not isinstance(decorator.func, ast.Attribute):
continue
if decorator.func.attr != "route":
continue
if not decorator.args:
continue
first_arg = decorator.args[0]
if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str):
route_paths.add(first_arg.value)
assert "/datasets/<uuid:dataset_id>/pipeline/datasource-plugins" in route_paths
assert "/datasets/<uuid:dataset_id>/pipeline/datasource/nodes/<string:node_id>/run" in route_paths
assert "/datasets/<uuid:dataset_id>/pipeline/run" in route_paths
assert "/datasets/pipeline/file-upload" in route_paths

View File

@@ -4,7 +4,7 @@ from typing import Any
from uuid import uuid4
import pytest
from hypothesis import given, settings
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from core.file import File, FileTransferMethod, FileType
@@ -493,7 +493,7 @@ def _scalar_value() -> st.SearchStrategy[int | float | str | File | None]:
)
@settings(max_examples=50)
@settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow, HealthCheck.filter_too_much], deadline=None)
@given(_scalar_value())
def test_build_segment_and_extract_values_for_scalar_types(value):
seg = variable_factory.build_segment(value)
@@ -504,7 +504,7 @@ def test_build_segment_and_extract_values_for_scalar_types(value):
assert seg.value == value
@settings(max_examples=50)
@settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow, HealthCheck.filter_too_much], deadline=None)
@given(values=st.lists(_scalar_value(), max_size=20))
def test_build_segment_and_extract_values_for_array_types(values):
seg = variable_factory.build_segment(values)

View File

@@ -109,40 +109,87 @@ def mock_document_segments(document_id):
@pytest.fixture
def mock_db_session():
"""Mock database session via session_factory.create_session()."""
"""Mock database session via session_factory.create_session().
After session split refactor, the code calls create_session() multiple times.
This fixture creates shared query mocks so all sessions use the same
query configuration, simulating database persistence across sessions.
The fixture automatically converts side_effect to cycle to prevent StopIteration.
Tests configure mocks the same way as before, but behind the scenes the values
are cycled infinitely for all sessions.
"""
from itertools import cycle
with patch("tasks.document_indexing_sync_task.session_factory") as mock_sf:
session = MagicMock()
# Ensure tests can observe session.close() via context manager teardown
session.close = MagicMock()
session.commit = MagicMock()
sessions = []
# Mock session.begin() context manager to auto-commit on exit
begin_cm = MagicMock()
begin_cm.__enter__.return_value = session
# Shared query mocks - all sessions use these
shared_query = MagicMock()
shared_filter_by = MagicMock()
shared_scalars_result = MagicMock()
def _begin_exit_side_effect(*args, **kwargs):
# session.begin().__exit__() should commit if no exception
if args[0] is None: # No exception
session.commit()
# Create custom first mock that auto-cycles side_effect
class CyclicMock(MagicMock):
def __setattr__(self, name, value):
if name == "side_effect" and value is not None:
# Convert list/tuple to infinite cycle
if isinstance(value, (list, tuple)):
value = cycle(value)
super().__setattr__(name, value)
begin_cm.__exit__.side_effect = _begin_exit_side_effect
session.begin.return_value = begin_cm
shared_query.where.return_value.first = CyclicMock()
shared_filter_by.first = CyclicMock()
# Mock create_session() context manager
cm = MagicMock()
cm.__enter__.return_value = session
def _create_session():
"""Create a new mock session for each create_session() call."""
session = MagicMock()
session.close = MagicMock()
session.commit = MagicMock()
def _exit_side_effect(*args, **kwargs):
session.close()
# Mock session.begin() context manager
begin_cm = MagicMock()
begin_cm.__enter__.return_value = session
cm.__exit__.side_effect = _exit_side_effect
mock_sf.create_session.return_value = cm
def _begin_exit_side_effect(exc_type, exc, tb):
# commit on success
if exc_type is None:
session.commit()
# return False to propagate exceptions
return False
query = MagicMock()
session.query.return_value = query
query.where.return_value = query
session.scalars.return_value = MagicMock()
yield session
begin_cm.__exit__.side_effect = _begin_exit_side_effect
session.begin.return_value = begin_cm
# Mock create_session() context manager
cm = MagicMock()
cm.__enter__.return_value = session
def _exit_side_effect(exc_type, exc, tb):
session.close()
return False
cm.__exit__.side_effect = _exit_side_effect
# All sessions use the same shared query mocks
session.query.return_value = shared_query
shared_query.where.return_value = shared_query
shared_query.filter_by.return_value = shared_filter_by
session.scalars.return_value = shared_scalars_result
sessions.append(session)
# Attach helpers on the first created session for assertions across all sessions
if len(sessions) == 1:
session.get_all_sessions = lambda: sessions
session.any_close_called = lambda: any(s.close.called for s in sessions)
session.any_commit_called = lambda: any(s.commit.called for s in sessions)
return cm
mock_sf.create_session.side_effect = _create_session
# Create first session and return it
_create_session()
yield sessions[0]
@pytest.fixture
@@ -201,8 +248,8 @@ class TestDocumentIndexingSyncTask:
# Act
document_indexing_sync_task(dataset_id, document_id)
# Assert
mock_db_session.close.assert_called_once()
# Assert - at least one session should have been closed
assert mock_db_session.any_close_called()
def test_missing_notion_workspace_id(self, mock_db_session, mock_document, dataset_id, document_id):
"""Test that task raises error when notion_workspace_id is missing."""
@@ -245,6 +292,7 @@ class TestDocumentIndexingSyncTask:
"""Test that task handles missing credentials by updating document status."""
# Arrange
mock_db_session.query.return_value.where.return_value.first.return_value = mock_document
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
mock_datasource_provider_service.get_datasource_credentials.return_value = None
# Act
@@ -254,8 +302,8 @@ class TestDocumentIndexingSyncTask:
assert mock_document.indexing_status == "error"
assert "Datasource credential not found" in mock_document.error
assert mock_document.stopped_at is not None
mock_db_session.commit.assert_called()
mock_db_session.close.assert_called()
assert mock_db_session.any_commit_called()
assert mock_db_session.any_close_called()
def test_page_not_updated(
self,
@@ -269,6 +317,7 @@ class TestDocumentIndexingSyncTask:
"""Test that task does nothing when page has not been updated."""
# Arrange
mock_db_session.query.return_value.where.return_value.first.return_value = mock_document
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
# Return same time as stored in document
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-01T00:00:00Z"
@@ -278,8 +327,8 @@ class TestDocumentIndexingSyncTask:
# Assert
# Document status should remain unchanged
assert mock_document.indexing_status == "completed"
# Session should still be closed via context manager teardown
assert mock_db_session.close.called
# At least one session should have been closed via context manager teardown
assert mock_db_session.any_close_called()
def test_successful_sync_when_page_updated(
self,
@@ -296,7 +345,20 @@ class TestDocumentIndexingSyncTask:
):
"""Test successful sync flow when Notion page has been updated."""
# Arrange
mock_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
# Set exact sequence of returns across calls to `.first()`:
# 1) document (initial fetch)
# 2) dataset (pre-check)
# 3) dataset (cleaning phase)
# 4) document (pre-indexing update)
# 5) document (indexing runner fetch)
mock_db_session.query.return_value.where.return_value.first.side_effect = [
mock_document,
mock_dataset,
mock_dataset,
mock_document,
mock_document,
]
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
mock_db_session.scalars.return_value.all.return_value = mock_document_segments
# NotionExtractor returns updated time
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
@@ -314,28 +376,40 @@ class TestDocumentIndexingSyncTask:
mock_processor.clean.assert_called_once()
# Verify segments were deleted from database in batch (DELETE FROM document_segments)
execute_sqls = [" ".join(str(c[0][0]).split()) for c in mock_db_session.execute.call_args_list]
# Aggregate execute calls across all created sessions
execute_sqls = []
for s in mock_db_session.get_all_sessions():
execute_sqls.extend([" ".join(str(c[0][0]).split()) for c in s.execute.call_args_list])
assert any("DELETE FROM document_segments" in sql for sql in execute_sqls)
# Verify indexing runner was called
mock_indexing_runner.run.assert_called_once_with([mock_document])
# Verify session operations
assert mock_db_session.commit.called
mock_db_session.close.assert_called_once()
# Verify session operations (across any created session)
assert mock_db_session.any_commit_called()
assert mock_db_session.any_close_called()
def test_dataset_not_found_during_cleaning(
self,
mock_db_session,
mock_datasource_provider_service,
mock_notion_extractor,
mock_indexing_runner,
mock_document,
dataset_id,
document_id,
):
"""Test that task handles dataset not found during cleaning phase."""
# Arrange
mock_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, None]
# Sequence: document (initial), dataset (pre-check), None (cleaning), document (update), document (indexing)
mock_db_session.query.return_value.where.return_value.first.side_effect = [
mock_document,
mock_dataset,
None,
mock_document,
mock_document,
]
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
# Act
@@ -344,8 +418,8 @@ class TestDocumentIndexingSyncTask:
# Assert
# Document should still be set to parsing
assert mock_document.indexing_status == "parsing"
# Session should be closed after error
mock_db_session.close.assert_called_once()
# At least one session should be closed after error
assert mock_db_session.any_close_called()
def test_cleaning_error_continues_to_indexing(
self,
@@ -361,8 +435,14 @@ class TestDocumentIndexingSyncTask:
):
"""Test that indexing continues even if cleaning fails."""
# Arrange
mock_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
mock_db_session.scalars.return_value.all.side_effect = Exception("Cleaning error")
from itertools import cycle
mock_db_session.query.return_value.where.return_value.first.side_effect = cycle([mock_document, mock_dataset])
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
# Make the cleaning step fail but not the segment fetch
processor = mock_index_processor_factory.return_value.init_index_processor.return_value
processor.clean.side_effect = Exception("Cleaning error")
mock_db_session.scalars.return_value.all.return_value = []
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
# Act
@@ -371,7 +451,7 @@ class TestDocumentIndexingSyncTask:
# Assert
# Indexing should still be attempted despite cleaning error
mock_indexing_runner.run.assert_called_once_with([mock_document])
mock_db_session.close.assert_called_once()
assert mock_db_session.any_close_called()
def test_indexing_runner_document_paused_error(
self,
@@ -388,7 +468,10 @@ class TestDocumentIndexingSyncTask:
):
"""Test that DocumentIsPausedError is handled gracefully."""
# Arrange
mock_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
from itertools import cycle
mock_db_session.query.return_value.where.return_value.first.side_effect = cycle([mock_document, mock_dataset])
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
mock_db_session.scalars.return_value.all.return_value = mock_document_segments
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
mock_indexing_runner.run.side_effect = DocumentIsPausedError("Document paused")
@@ -398,7 +481,7 @@ class TestDocumentIndexingSyncTask:
# Assert
# Session should be closed after handling error
mock_db_session.close.assert_called_once()
assert mock_db_session.any_close_called()
def test_indexing_runner_general_error(
self,
@@ -415,7 +498,10 @@ class TestDocumentIndexingSyncTask:
):
"""Test that general exceptions during indexing are handled."""
# Arrange
mock_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
from itertools import cycle
mock_db_session.query.return_value.where.return_value.first.side_effect = cycle([mock_document, mock_dataset])
mock_db_session.query.return_value.filter_by.return_value.first.return_value = mock_document
mock_db_session.scalars.return_value.all.return_value = mock_document_segments
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
mock_indexing_runner.run.side_effect = Exception("Indexing error")
@@ -425,7 +511,7 @@ class TestDocumentIndexingSyncTask:
# Assert
# Session should be closed after error
mock_db_session.close.assert_called_once()
assert mock_db_session.any_close_called()
def test_notion_extractor_initialized_with_correct_params(
self,
@@ -532,7 +618,14 @@ class TestDocumentIndexingSyncTask:
):
"""Test that index processor clean is called with correct parameters."""
# Arrange
mock_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
# Sequence: document (initial), dataset (pre-check), dataset (cleaning), document (update), document (indexing)
mock_db_session.query.return_value.where.return_value.first.side_effect = [
mock_document,
mock_dataset,
mock_dataset,
mock_document,
mock_document,
]
mock_db_session.scalars.return_value.all.return_value = mock_document_segments
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"

View File

@@ -6,7 +6,7 @@ const PluginList = () => {
return (
<PluginPage
plugins={<PluginsPanel />}
marketplace={<Marketplace />}
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
/>
)
}

View File

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

Before

Width:  |  Height:  |  Size: 658 B

View File

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

Before

Width:  |  Height:  |  Size: 509 B

View File

@@ -1,50 +0,0 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "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"
}

View File

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

View File

@@ -1,53 +0,0 @@
{
"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"
}

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />

View File

@@ -64,8 +64,8 @@ const InstallFromMarketplace = ({
{
!isAllPluginsLoading && !collapse && (
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={allPlugins}
showInstallButton
cardContainerClassName="grid grid-cols-2 gap-2"

View File

@@ -63,8 +63,8 @@ const InstallFromMarketplace = ({
{
!isAllPluginsLoading && !collapse && (
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={allPlugins}
showInstallButton
cardContainerClassName="grid grid-cols-2 gap-2"

View File

@@ -38,7 +38,7 @@ const DeprecationNotice: FC<DeprecationNoticeProps> = ({
iconWrapperClassName,
textClassName,
}) => {
const { t } = useTranslation()
const { t } = useTranslation('plugin')
const deprecatedReasonKey = useMemo(() => {
if (!deprecatedReason)

View File

@@ -1,4 +1,4 @@
import { useTranslation } from '#i18n'
import { RiInstallLine } from '@remixicon/react'
import * as React from 'react'
import { formatNumber } from '@/utils/format'
@@ -9,13 +9,10 @@ type Props = {
const DownloadCountComponent = ({
downloadCount,
}: Props) => {
const { t } = useTranslation('plugin')
return (
<div className="system-xs-regular text-text-tertiary">
{formatNumber(downloadCount)}
{' '}
{t('marketplace.installs')}
<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>
)
}

View File

@@ -1,14 +1,10 @@
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 = ({
@@ -16,42 +12,7 @@ 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={`/creators/${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 && (
@@ -60,11 +21,9 @@ const OrgInfo = ({
<span className="system-xs-regular shrink-0 text-text-quaternary">/</span>
</>
)}
{packageName && (
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
{packageName}
</span>
)}
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
{packageName}
</span>
</div>
)
}

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import DownloadCount from './base/download-count'
import OrgInfo from './base/org-info'
import Placeholder, { LoadingPlaceholder } from './base/placeholder'
import Title from './base/title'
import CardTags from './card-tags'
import CardMoreInfo from './card-more-info'
// ================================
// Import Components Under Test
// ================================
@@ -642,9 +642,9 @@ describe('Card', () => {
})
// ================================
// CardTags Component Tests
// CardMoreInfo Component Tests
// ================================
describe('CardTags', () => {
describe('CardMoreInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
@@ -654,24 +654,66 @@ describe('CardTags', () => {
// ================================
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CardTags tags={['tag1']} />)
render(<CardMoreInfo downloadCount={100} tags={['tag1']} />)
expect(document.body).toBeInTheDocument()
})
it('should render tags in uppercase', () => {
render(<CardTags tags={['search', 'image']} />)
it('should render download count when provided', () => {
render(<CardMoreInfo downloadCount={1000} tags={[]} />)
expect(screen.getByText('SEARCH')).toBeInTheDocument()
expect(screen.getByText('IMAGE')).toBeInTheDocument()
expect(screen.getByText('1,000')).toBeInTheDocument()
})
it('should render at most two tags', () => {
render(<CardTags tags={['one', 'two', 'three']} />)
it('should render tags when provided', () => {
render(<CardMoreInfo tags={['search', 'image']} />)
expect(screen.getByText('ONE')).toBeInTheDocument()
expect(screen.getByText('TWO')).toBeInTheDocument()
expect(screen.queryByText('THREE')).not.toBeInTheDocument()
expect(screen.getByText('search')).toBeInTheDocument()
expect(screen.getByText('image')).toBeInTheDocument()
})
it('should render both download count and tags with separator', () => {
render(<CardMoreInfo downloadCount={500} tags={['tag1']} />)
expect(screen.getByText('500')).toBeInTheDocument()
expect(screen.getByText('·')).toBeInTheDocument()
expect(screen.getByText('tag1')).toBeInTheDocument()
})
})
// ================================
// Props Testing
// ================================
describe('Props', () => {
it('should not render download count when undefined', () => {
render(<CardMoreInfo tags={['tag1']} />)
expect(screen.queryByTestId('ri-install-line')).not.toBeInTheDocument()
})
it('should not render separator when download count is undefined', () => {
render(<CardMoreInfo tags={['tag1']} />)
expect(screen.queryByText('·')).not.toBeInTheDocument()
})
it('should not render separator when tags are empty', () => {
render(<CardMoreInfo downloadCount={100} tags={[]} />)
expect(screen.queryByText('·')).not.toBeInTheDocument()
})
it('should render hash symbol before each tag', () => {
render(<CardMoreInfo tags={['search']} />)
expect(screen.getByText('#')).toBeInTheDocument()
})
it('should set title attribute with hash prefix for tags', () => {
render(<CardMoreInfo tags={['search']} />)
const tagElement = screen.getByTitle('# search')
expect(tagElement).toBeInTheDocument()
})
})
@@ -680,8 +722,54 @@ describe('CardTags', () => {
// ================================
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
expect(CardTags).toBeDefined()
expect(typeof CardTags).toBe('object')
expect(CardMoreInfo).toBeDefined()
expect(typeof CardMoreInfo).toBe('object')
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle zero download count', () => {
render(<CardMoreInfo downloadCount={0} tags={[]} />)
// 0 should still render since downloadCount is defined
expect(screen.getByText('0')).toBeInTheDocument()
})
it('should handle empty tags array', () => {
render(<CardMoreInfo downloadCount={100} tags={[]} />)
expect(screen.queryByText('#')).not.toBeInTheDocument()
})
it('should handle large download count', () => {
render(<CardMoreInfo downloadCount={1234567890} tags={[]} />)
expect(screen.getByText('1,234,567,890')).toBeInTheDocument()
})
it('should handle many tags', () => {
const tags = Array.from({ length: 10 }, (_, i) => `tag${i}`)
render(<CardMoreInfo downloadCount={100} tags={tags} />)
expect(screen.getByText('tag0')).toBeInTheDocument()
expect(screen.getByText('tag9')).toBeInTheDocument()
})
it('should handle tags with special characters', () => {
render(<CardMoreInfo tags={['tag-with-dash', 'tag_with_underscore']} />)
expect(screen.getByText('tag-with-dash')).toBeInTheDocument()
expect(screen.getByText('tag_with_underscore')).toBeInTheDocument()
})
it('should truncate long tag names', () => {
const longTag = 'a'.repeat(200)
const { container } = render(<CardMoreInfo tags={[longTag]} />)
expect(container.querySelector('.truncate')).toBeInTheDocument()
})
})
})
@@ -1600,7 +1688,7 @@ describe('Icon', () => {
render(
<Card
payload={plugin}
footer={<CardTags tags={['search', 'api']} />}
footer={<CardMoreInfo downloadCount={5000} tags={['search', 'api']} />}
/>,
)
@@ -1612,8 +1700,9 @@ describe('Icon', () => {
expect(screen.getByText('Tool')).toBeInTheDocument()
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
expect(screen.getByText('SEARCH')).toBeInTheDocument()
expect(screen.getByText('API')).toBeInTheDocument()
expect(screen.getByText('5,000')).toBeInTheDocument()
expect(screen.getByText('search')).toBeInTheDocument()
expect(screen.getByText('api')).toBeInTheDocument()
})
it('should render loading state correctly', () => {
@@ -1639,12 +1728,12 @@ describe('Icon', () => {
<Card
payload={plugin}
installed={true}
footer={<CardTags tags={['tag1']} />}
footer={<CardMoreInfo downloadCount={100} tags={['tag1']} />}
/>,
)
expect(screen.getByTestId('ri-check-line')).toBeInTheDocument()
expect(screen.getByText('TAG1')).toBeInTheDocument()
expect(screen.getByText('100')).toBeInTheDocument()
})
})
@@ -1728,9 +1817,9 @@ describe('Icon', () => {
})
it('should have title attribute on tags', () => {
render(<CardTags tags={['search']} />)
render(<CardMoreInfo downloadCount={100} tags={['search']} />)
expect(screen.getByTitle('search')).toBeInTheDocument()
expect(screen.getByTitle('# search')).toBeInTheDocument()
})
it('should have semantic structure', () => {
@@ -1775,11 +1864,11 @@ describe('Icon', () => {
expect(endTime - startTime).toBeLessThan(1000)
})
it('should handle CardTags with many tags', () => {
it('should handle CardMoreInfo with many tags', () => {
const tags = Array.from({ length: 20 }, (_, i) => `tag-${i}`)
const startTime = performance.now()
render(<CardTags tags={tags} />)
render(<CardMoreInfo downloadCount={1000} tags={tags} />)
const endTime = performance.now()
expect(endTime - startTime).toBeLessThan(100)

View File

@@ -32,7 +32,6 @@ export type Props = {
isLoading?: boolean
loadingFileName?: string
limitedInstall?: boolean
disableOrgLink?: boolean
}
const Card = ({
@@ -47,12 +46,11 @@ const Card = ({
isLoading = false,
loadingFileName,
limitedInstall = false,
disableOrgLink = false,
}: Props) => {
const locale = useGetLanguage()
const { t } = useTranslation()
const { categoriesMap } = useCategories(true)
const { category, type, org, label, brief, icon, icon_dark, verified, badges = [], install_count } = payload
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [] } = payload
const { theme } = useTheme()
const iconSrc = theme === Theme.dark && icon_dark ? icon_dark : icon
const getLocalizedText = (obj: Record<string, string> | undefined) =>
@@ -88,8 +86,7 @@ const Card = ({
<OrgInfo
className="mt-0.5"
orgName={org}
downloadCount={install_count}
linkToOrg={!disableOrgLink}
packageName={name}
/>
</div>
</div>

View File

@@ -1,10 +1,9 @@
import type { SearchTab } from './search-params'
import type { PluginsSort, SearchParamsFromCollection } from './types'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { DEFAULT_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params'
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { marketplaceSearchParamsParsers } from './search-params'
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT)
export function useMarketplaceSort() {
@@ -17,30 +16,16 @@ export function useSetMarketplaceSort() {
return useSetAtom(marketplaceSortAtom)
}
export function useSearchText() {
export function useSearchPluginText() {
return useQueryState('q', marketplaceSearchParamsParsers.q)
}
export function useActivePluginCategory() {
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
return [getValidatedPluginCategory(category), setCategory] as const
}
export function useActiveTemplateCategory() {
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
return [getValidatedTemplateCategory(category), setCategory] as const
export function useActivePluginType() {
return useQueryState('category', marketplaceSearchParamsParsers.category)
}
export function useFilterPluginTags() {
return useQueryState('tags', marketplaceSearchParamsParsers.tags)
}
export function useSearchTab() {
return useQueryState('searchTab', marketplaceSearchParamsParsers.searchTab)
}
export function useCreationType() {
return useQueryState('creationType', marketplaceSearchParamsParsers.creationType)
}
/**
* Not all categories have collections, so we need to
* force the search mode for those categories.
@@ -48,27 +33,23 @@ export function useCreationType() {
export const searchModeAtom = atom<true | null>(null)
export function useMarketplaceSearchMode() {
const [creationType] = useCreationType()
const [searchText] = useSearchText()
const [searchTab] = useSearchTab()
const [searchPluginText] = useSearchPluginText()
const [filterPluginTags] = useFilterPluginTags()
const [activePluginCategory] = useActivePluginCategory()
const isPluginsView = creationType === CREATION_TYPE.plugins
const [activePluginType] = useActivePluginType()
const searchMode = useAtomValue(searchModeAtom)
const isSearchMode = searchTab || searchText
|| (isPluginsView && filterPluginTags.length > 0)
|| (searchMode ?? (isPluginsView && !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginCategory)))
const isSearchMode = !!searchPluginText
|| filterPluginTags.length > 0
|| (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType)))
return isSearchMode
}
export function useMarketplaceMoreClick() {
const [, setQ] = useSearchText()
const [, setSearchTab] = useSearchTab()
const [,setQ] = useSearchPluginText()
const setSort = useSetAtom(marketplaceSortAtom)
const setSearchMode = useSetAtom(searchModeAtom)
return useCallback((searchParams?: SearchParamsFromCollection, searchTab?: SearchTab) => {
return useCallback((searchParams?: SearchParamsFromCollection) => {
if (!searchParams)
return
setQ(searchParams?.query || '')
@@ -77,7 +58,5 @@ export function useMarketplaceMoreClick() {
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
})
setSearchMode(true)
if (searchTab)
setSearchTab(searchTab)
}, [setQ, setSearchTab, setSort, setSearchMode])
}, [setQ, setSort, setSearchMode])
}

View File

@@ -1,64 +0,0 @@
'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>
)
}

View File

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

View File

@@ -1,86 +0,0 @@
'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-white/10',
!hasSelected && !open && 'hover:bg-white/10',
hasSelected && 'border border-white 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)

View File

@@ -1,4 +0,0 @@
'use client'
export { PluginCategorySwitch } from './plugin'
export { TemplateCategorySwitch } from './template'

View File

@@ -1,120 +0,0 @@
'use client'
import type { ActivePluginType } from '../constants'
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
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 { CommonCategorySwitch } from './common'
import HeroTagsFilter from './hero-tags-filter'
type PluginTypeSwitchProps = {
className?: string
variant?: 'default' | 'hero'
}
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 { t } = useTranslation()
const [activePluginCategory, handleActivePluginCategoryChange] = useActivePluginCategory()
const [filterPluginTags, setFilterPluginTags] = useFilterPluginTags()
const setSearchMode = useSetAtom(searchModeAtom)
const isHeroVariant = variant === 'hero'
const options = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: isHeroVariant ? t('category.allTypes', { ns: 'plugin' }) : t('category.all', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.all, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: t('category.models', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.model, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: t('category.tools', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.tool, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
text: t('category.datasources', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.datasource, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
text: t('category.triggers', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.trigger, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.agent,
text: t('category.agents', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.agent, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: t('category.extensions', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.extension, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
text: t('category.bundles', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.bundle, 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>
)
}

View File

@@ -1,75 +0,0 @@
'use client'
import { useTranslation } from '#i18n'
import { Playground } from '@/app/components/base/icons/src/vender/plugin'
import { useActiveTemplateCategory } from '../atoms'
import { CATEGORY_ALL, TEMPLATE_CATEGORY_MAP } from '../constants'
import { CommonCategorySwitch } from './common'
type TemplateCategorySwitchProps = {
className?: string
variant?: 'default' | 'hero'
}
export const TemplateCategorySwitch = ({
className,
variant = 'default',
}: TemplateCategorySwitchProps) => {
const { t } = useTranslation()
const [activeTemplateCategory, handleActiveTemplateCategoryChange] = useActiveTemplateCategory()
const isHeroVariant = variant === 'hero'
const options = [
{
value: CATEGORY_ALL,
text: t('marketplace.templateCategory.all', { ns: 'plugin' }),
icon: isHeroVariant ? <Playground className="mr-1.5 h-4 w-4" /> : null,
},
{
value: TEMPLATE_CATEGORY_MAP.marketing,
text: t('marketplace.templateCategory.marketing', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.sales,
text: t('marketplace.templateCategory.sales', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.support,
text: t('marketplace.templateCategory.support', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.operations,
text: t('marketplace.templateCategory.operations', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.it,
text: t('marketplace.templateCategory.it', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.knowledge,
text: t('marketplace.templateCategory.knowledge', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.design,
text: t('marketplace.templateCategory.design', { ns: 'plugin' }),
icon: null,
},
]
return (
<CommonCategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activeTemplateCategory}
onChange={handleActiveTemplateCategoryChange}
/>
)
}

View File

@@ -1,19 +0,0 @@
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')
})
})

View File

@@ -7,10 +7,8 @@ export const DEFAULT_SORT = {
export const SCROLL_BOTTOM_THRESHOLD = 100
export const CATEGORY_ALL = 'all'
export const PLUGIN_TYPE_SEARCH_MAP = {
all: CATEGORY_ALL,
all: 'all',
model: PluginCategoryEnum.model,
tool: PluginCategoryEnum.tool,
agent: PluginCategoryEnum.agent,
@@ -23,7 +21,6 @@ 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>(
[
@@ -31,28 +28,3 @@ export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
PLUGIN_TYPE_SEARCH_MAP.tool,
],
)
export const TEMPLATE_CATEGORY_MAP = {
all: CATEGORY_ALL,
marketing: 'marketing',
sales: 'sales',
support: 'support',
operations: 'operations',
it: 'it',
knowledge: 'knowledge',
design: 'design',
} 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]
}

View File

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

View File

@@ -1,184 +1,72 @@
'use client'
import { useLocale, useTranslation } from '#i18n'
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'
type DescriptionProps = {
className?: string
scrollContainerId?: string
marketplaceNav?: React.ReactNode
}
// 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 Description = () => {
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 titleRef = useRef<HTMLDivElement | null>(null)
const progress = useMotionValue(0)
const titleHeight = useMotionValue(0)
const smoothProgress = useSpring(progress, { stiffness: 260, damping: 34 })
const { t: tCommon } = useTranslation('common')
const locale = useLocale()
useLayoutEffect(() => {
const node = titleRef.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])
const isZhHans = locale === 'zh-Hans'
return (
<motion.div
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
ref={titleRef}
style={{
opacity: contentOpacity,
scale: contentScale,
transformOrigin: 'left top',
maxHeight: titleMaxHeight,
overflow: 'hidden',
willChange: 'opacity, transform',
marginTop: titleMarginTop,
}}
>
<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>
</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>
<>
<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>
</>
)
}
export default Description

View File

@@ -3,7 +3,7 @@ import type {
} from '../types'
import type {
CollectionsAndPluginsSearchParams,
PluginCollection,
MarketplaceCollection,
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 [pluginCollectionsOverride, setPluginCollections] = useState<PluginCollection[]>()
const [pluginCollectionPluginsMapOverride, setPluginCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
const {
data,
@@ -54,10 +54,10 @@ export const useMarketplaceCollectionsAndPlugins = () => {
const isLoading = !!queryParams && (isFetching || isPending)
return {
pluginCollections: pluginCollectionsOverride ?? data?.marketplaceCollections,
setPluginCollections,
pluginCollectionPluginsMap: pluginCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap,
setPluginCollectionPluginsMap,
marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections,
setMarketplaceCollections,
marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap,
setMarketplaceCollectionPluginsMap,
queryMarketplaceCollectionsAndPlugins,
isLoading,
isSuccess,

View File

@@ -3,9 +3,9 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { createLoader } from 'nuqs/server'
import { getQueryClientServer } from '@/context/query-client-server'
import { marketplaceQuery } from '@/service/client'
import { getValidatedPluginCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params'
import { getCollectionsParams, getMarketplaceCollectionsAndPlugins, getMarketplaceTemplateCollectionsAndTemplates } from './utils'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { marketplaceSearchParamsParsers } from './search-params'
import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils'
// The server side logic should move to marketplace's codebase so that we can get rid of Next.js
@@ -15,26 +15,16 @@ async function getDehydratedState(searchParams?: Promise<SearchParams>) {
}
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
const params = await loadSearchParams(searchParams)
const queryClient = getQueryClientServer()
if (params.creationType === CREATION_TYPE.templates) {
await queryClient.prefetchQuery({
queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query: undefined } }),
queryFn: () => getMarketplaceTemplateCollectionsAndTemplates(),
})
return dehydrate(queryClient)
}
const pluginCategory = getValidatedPluginCategory(params.category)
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(pluginCategory)) {
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) {
return
}
const collectionsParams = getCollectionsParams(pluginCategory)
const queryClient = getQueryClientServer()
await queryClient.prefetchQuery({
queryKey: marketplaceQuery.plugins.collections.queryKey({ input: { query: collectionsParams } }),
queryFn: () => getMarketplaceCollectionsAndPlugins(collectionsParams),
queryKey: marketplaceQuery.collections.queryKey({ input: { query: getCollectionsParams(params.category) } }),
queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)),
})
return dehydrate(queryClient)
}

View File

@@ -1,4 +1,4 @@
import type { PluginCollection } from './types'
import type { MarketplaceCollection } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import { act, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -12,9 +12,9 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants'
import {
getFormattedPlugin,
getPluginCondition,
getMarketplaceListCondition,
getMarketplaceListFilterType,
getPluginDetailLinkInMarketplace,
getPluginFilterType,
getPluginIconInMarketplace,
getPluginLinkInMarketplace,
} from './utils'
@@ -322,10 +322,11 @@ vi.mock('@/app/components/plugins/card', () => ({
),
}))
// Mock CardTags component
vi.mock('@/app/components/plugins/card/card-tags', () => ({
default: ({ tags }: { tags: string[] }) => (
<div data-testid="card-tags">
// Mock CardMoreInfo component
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>
<span data-testid="tags">{tags.join(',')}</span>
</div>
),
@@ -386,7 +387,7 @@ const createMockPluginList = (count: number): Plugin[] =>
install_count: 1000 - i * 10,
}))
const createMockCollection = (overrides?: Partial<PluginCollection>): PluginCollection => ({
const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({
name: 'test-collection',
label: { 'en-US': 'Test Collection' },
description: { 'en-US': 'Test collection description' },
@@ -540,57 +541,57 @@ describe('utils', () => {
})
})
describe('getPluginCondition', () => {
describe('getMarketplaceListCondition', () => {
it('should return category condition for tool', () => {
expect(getPluginCondition(PluginCategoryEnum.tool)).toBe('category=tool')
expect(getMarketplaceListCondition(PluginCategoryEnum.tool)).toBe('category=tool')
})
it('should return category condition for model', () => {
expect(getPluginCondition(PluginCategoryEnum.model)).toBe('category=model')
expect(getMarketplaceListCondition(PluginCategoryEnum.model)).toBe('category=model')
})
it('should return category condition for agent', () => {
expect(getPluginCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
expect(getMarketplaceListCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
})
it('should return category condition for datasource', () => {
expect(getPluginCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
expect(getMarketplaceListCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
})
it('should return category condition for trigger', () => {
expect(getPluginCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
expect(getMarketplaceListCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
})
it('should return endpoint category for extension', () => {
expect(getPluginCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
expect(getMarketplaceListCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
})
it('should return type condition for bundle', () => {
expect(getPluginCondition('bundle')).toBe('type=bundle')
expect(getMarketplaceListCondition('bundle')).toBe('type=bundle')
})
it('should return empty string for all', () => {
expect(getPluginCondition('all')).toBe('')
expect(getMarketplaceListCondition('all')).toBe('')
})
it('should return empty string for unknown type', () => {
expect(getPluginCondition('unknown')).toBe('')
expect(getMarketplaceListCondition('unknown')).toBe('')
})
})
describe('getPluginFilterType', () => {
describe('getMarketplaceListFilterType', () => {
it('should return undefined for all', () => {
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
})
it('should return bundle for bundle', () => {
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
})
it('should return plugin for other categories', () => {
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')
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')
})
})
})
@@ -610,8 +611,8 @@ describe('useMarketplaceCollectionsAndPlugins', () => {
expect(result.current.isLoading).toBe(false)
expect(result.current.isSuccess).toBe(false)
expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined()
expect(result.current.setPluginCollections).toBeDefined()
expect(result.current.setPluginCollectionPluginsMap).toBeDefined()
expect(result.current.setMarketplaceCollections).toBeDefined()
expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined()
})
it('should provide queryMarketplaceCollectionsAndPlugins function', async () => {
@@ -621,34 +622,34 @@ describe('useMarketplaceCollectionsAndPlugins', () => {
expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function')
})
it('should provide setPluginCollections function', async () => {
it('should provide setMarketplaceCollections function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.setPluginCollections).toBe('function')
expect(typeof result.current.setMarketplaceCollections).toBe('function')
})
it('should provide setPluginCollectionPluginsMap function', async () => {
it('should provide setMarketplaceCollectionPluginsMap function', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
expect(typeof result.current.setPluginCollectionPluginsMap).toBe('function')
expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function')
})
it('should return pluginCollections from data or override', async () => {
it('should return marketplaceCollections from data or override', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
// Initial state
expect(result.current.pluginCollections).toBeUndefined()
expect(result.current.marketplaceCollections).toBeUndefined()
})
it('should return pluginCollectionPluginsMap from data or override', async () => {
it('should return marketplaceCollectionPluginsMap from data or override', async () => {
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
// Initial state
expect(result.current.pluginCollectionPluginsMap).toBeUndefined()
expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined()
})
})

View File

@@ -1,34 +1,32 @@
import type { SearchParams } from 'nuqs'
import { TanstackQueryInitializer } from '@/context/query-client'
import { cn } from '@/utils/classnames'
import Description from './description'
import { HydrateQueryClient } from './hydration-server'
import MarketplaceContent from './marketplace-content'
import MarketplaceHeader from './marketplace-header'
import ListWrapper from './list/list-wrapper'
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
type MarketplaceProps = {
showInstallButton?: boolean
pluginTypeSwitchClassName?: string
/**
* Pass the search params from the request to prefetch data on the server.
*/
searchParams?: Promise<SearchParams>
/**
* Whether the marketplace is the platform marketplace.
*/
isMarketplacePlatform?: boolean
marketplaceNav?: React.ReactNode
}
const Marketplace = async ({
showInstallButton = true,
pluginTypeSwitchClassName,
searchParams,
isMarketplacePlatform = false,
marketplaceNav,
}: MarketplaceProps) => {
return (
<TanstackQueryInitializer>
<HydrateQueryClient searchParams={searchParams}>
<MarketplaceHeader descriptionClassName={cn('mx-12 mt-1', isMarketplacePlatform && 'top-0 mx-0 mt-0 rounded-none')} marketplaceNav={marketplaceNav} />
<MarketplaceContent
<Description />
<StickySearchAndSwitchWrapper
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
/>
<ListWrapper
showInstallButton={showInstallButton}
/>
</HydrateQueryClient>

View File

@@ -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 CardTags from '@/app/components/plugins/card/card-tags'
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import { useTags } from '@/app/components/plugins/hooks'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
@@ -43,13 +43,14 @@ 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={(
<CardTags
<CardMoreInfo
downloadCount={plugin.install_count}
tags={tagLabels}
/>
)}
@@ -87,15 +88,15 @@ const CardWrapperComponent = ({
return (
<a
className="group relative block cursor-pointer rounded-xl"
className="group relative inline-block cursor-pointer rounded-xl"
href={getPluginDetailLinkInMarketplace(plugin)}
>
<Card
key={plugin.name}
payload={plugin}
disableOrgLink
footer={(
<CardTags
<CardMoreInfo
downloadCount={plugin.install_count}
tags={tagLabels}
/>
)}

View File

@@ -1,255 +0,0 @@
'use client'
import type { RemixiconComponentType } from '@remixicon/react'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { cn } from '@/utils/classnames'
type CarouselProps = {
children: React.ReactNode
className?: string
itemWidth?: number
gap?: number
showNavigation?: boolean
showPagination?: boolean
autoPlay?: boolean
autoPlayInterval?: number
}
type ScrollState = {
canScrollLeft: boolean
canScrollRight: boolean
currentPage: number
totalPages: number
}
const SCROLL_OVERLAP_RATIO = 0.5
const defaultScrollState: ScrollState = {
canScrollLeft: false,
canScrollRight: false,
currentPage: 0,
totalPages: 0,
}
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>
)
const Carousel = ({
children,
className,
itemWidth = 280,
gap = 12,
showNavigation = true,
showPagination = true,
autoPlay = false,
autoPlayInterval = 5000,
}: CarouselProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const scrollStateRef = useRef<ScrollState>(defaultScrollState)
const [isHovered, setIsHovered] = useState(false)
const calculateScrollState = useCallback((container: HTMLDivElement): ScrollState => {
const { scrollLeft, scrollWidth, clientWidth } = container
const canScrollLeft = scrollLeft > 0
const canScrollRight = scrollLeft < scrollWidth - clientWidth - 1
// Calculate total pages based on actual scroll range
const maxScrollLeft = scrollWidth - clientWidth
const itemsPerPage = Math.floor(clientWidth / (itemWidth + gap))
const totalItems = container.children.length
const pages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
// Calculate current page based on scroll position ratio
let currentPage = 0
if (maxScrollLeft > 0) {
const scrollRatio = scrollLeft / maxScrollLeft
currentPage = Math.round(scrollRatio * (pages - 1))
}
return {
canScrollLeft,
canScrollRight,
totalPages: pages,
currentPage: Math.min(Math.max(0, currentPage), pages - 1),
}
}, [itemWidth, gap])
const subscribe = useCallback((onStoreChange: () => void) => {
const container = containerRef.current
if (!container)
return () => { }
const handleChange = () => {
scrollStateRef.current = calculateScrollState(container)
onStoreChange()
}
// Initial calculation
handleChange()
const resizeObserver = new ResizeObserver(handleChange)
resizeObserver.observe(container)
container.addEventListener('scroll', handleChange)
return () => {
resizeObserver.disconnect()
container.removeEventListener('scroll', handleChange)
}
}, [calculateScrollState])
const getSnapshot = useCallback(() => scrollStateRef.current, [])
const scrollState = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
// Re-subscribe when children change
useEffect(() => {
const container = containerRef.current
if (container)
scrollStateRef.current = calculateScrollState(container)
}, [children, calculateScrollState])
const scrollToPage = useCallback((pageIndex: number, instant = false) => {
const container = containerRef.current
if (!container)
return
const itemsPerPage = Math.floor(container.clientWidth / (itemWidth + gap))
const scrollLeft = pageIndex * itemsPerPage * (itemWidth + gap)
container.scrollTo({
left: scrollLeft,
behavior: instant ? 'instant' : 'smooth',
})
}, [itemWidth, gap])
const scroll = useCallback((direction: 'left' | 'right') => {
const container = containerRef.current
if (!container)
return
// Handle looping
if (direction === 'left' && !scrollState.canScrollLeft) {
// At first page, loop to last page
scrollToPage(scrollState.totalPages - 1, true)
return
}
if (direction === 'right' && !scrollState.canScrollRight) {
// At last page, loop to first page
scrollToPage(0, true)
return
}
const scrollAmount = container.clientWidth - (itemWidth * SCROLL_OVERLAP_RATIO)
const newScrollLeft = direction === 'left'
? container.scrollLeft - scrollAmount
: container.scrollLeft + scrollAmount
container.scrollTo({
left: newScrollLeft,
behavior: 'smooth',
})
}, [itemWidth, scrollState.canScrollLeft, scrollState.canScrollRight, scrollState.totalPages, scrollToPage])
// Auto-play functionality
useEffect(() => {
if (!autoPlay || isHovered || scrollState.totalPages <= 1)
return
const interval = setInterval(() => {
if (scrollState.canScrollRight) {
scrollToPage(scrollState.currentPage + 1)
}
else {
// Loop back to first page instantly (no animation)
scrollToPage(0, true)
}
}, autoPlayInterval)
return () => clearInterval(interval)
}, [autoPlay, autoPlayInterval, isHovered, scrollState.totalPages, scrollState.canScrollRight, scrollState.currentPage, scrollToPage])
const handleMouseEnter = useCallback(() => setIsHovered(true), [])
const handleMouseLeave = useCallback(() => setIsHovered(false), [])
return (
<div
className={cn('relative', className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Navigation arrows */}
{showNavigation && (
<div className="absolute -top-10 right-0 flex items-center gap-3">
{/* Pagination dots */}
{showPagination && scrollState.totalPages > 1 && (
<div className="flex items-center gap-1">
{Array.from({ length: scrollState.totalPages }).map((_, index) => (
<button
key={index}
className={cn(
'h-[5px] w-[5px] rounded-full transition-all',
scrollState.currentPage === index
? 'w-4 bg-components-button-primary-bg'
: 'bg-components-button-secondary-border hover:bg-components-button-secondary-border-hover',
)}
onClick={() => scrollToPage(index)}
aria-label={`Go to page ${index + 1}`}
/>
))}
</div>
)}
<div className="flex items-center gap-1">
<NavButton
direction="left"
disabled={scrollState.totalPages <= 1}
onClick={() => scroll('left')}
Icon={RiArrowLeftSLine}
/>
<NavButton
direction="right"
disabled={scrollState.totalPages <= 1}
onClick={() => scroll('right')}
Icon={RiArrowRightSLine}
/>
</div>
</div>
)}
{/* Scrollable container */}
<div
ref={containerRef}
className="no-scrollbar flex gap-3 overflow-x-auto scroll-smooth"
style={{
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch',
}}
>
{children}
</div>
</div>
)
}
export default Carousel

View File

@@ -1,213 +0,0 @@
'use client'
import type { SearchTab } from '../search-params'
import type { SearchParamsFromCollection } from '../types'
import type { Locale } from '@/i18n-config/language'
import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightSLine } from '@remixicon/react'
import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
import { useMarketplaceMoreClick } from '../atoms'
import { getItemKeyByField } from '../utils'
import Empty from '../empty'
import Carousel from './carousel'
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 = 8
export const CAROUSEL_COLUMN_CLASS = 'flex w-[calc((100%-0px)/1)] shrink-0 flex-col gap-3 sm:w-[calc((100%-12px)/2)] lg:w-[calc((100%-24px)/3)] xl:w-[calc((100%-36px)/4)]'
/** 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 }
}
type ViewMoreButtonProps = {
searchParams?: SearchParamsFromCollection
searchTab?: SearchTab
}
function ViewMoreButton({ searchParams, searchTab }: ViewMoreButtonProps) {
const { t } = useTranslation()
const onMoreClick = useMarketplaceMoreClick()
return (
<div
className="system-xs-medium flex cursor-pointer items-center text-text-accent"
onClick={() => onMoreClick(searchParams, searchTab)}
>
{t('marketplace.viewMore', { ns: 'plugin' })}
<RiArrowRightSLine className="h-4 w-4" />
</div>
)
}
export { ViewMoreButton }
type CollectionHeaderProps<TCollection extends BaseCollection> = {
collection: TCollection
itemsLength: number
locale: Locale
carouselCollectionNames: string[]
viewMore: React.ReactNode
}
function CollectionHeader<TCollection extends BaseCollection>({
collection,
itemsLength,
locale,
carouselCollectionNames,
viewMore,
}: CollectionHeaderProps<TCollection>) {
const showViewMore = collection.searchable
&& (carouselCollectionNames.includes(collection.name) || itemsLength > GRID_DISPLAY_LIMIT)
return (
<div className="mb-2 flex items-end justify-between">
<div>
<div className="title-xl-semi-bold text-text-primary">
{collection.label[getLanguage(locale)]}
</div>
<div className="system-xs-regular text-text-tertiary">
{collection.description[getLanguage(locale)]}
</div>
</div>
{showViewMore && viewMore}
</div>
)
}
export { CarouselCollection, CollectionHeader }
type CarouselCollectionProps<TItem> = {
items: TItem[]
getItemKey: (item: TItem) => string
renderCard: (item: TItem) => React.ReactNode
cardContainerClassName?: string
}
function CarouselCollection<TItem>({
items,
getItemKey,
renderCard,
cardContainerClassName,
}: CarouselCollectionProps<TItem>) {
const rows: TItem[][] = []
for (let i = 0; i < items.length; i += 2)
rows.push(items.slice(i, i + 2))
return (
<Carousel
className={cardContainerClassName}
showNavigation={items.length > 8}
showPagination={items.length > 8}
autoPlay={items.length > 8}
autoPlayInterval={5000}
>
{rows.map((columnItems, idx) => (
<div
key={columnItems[0] ? getItemKey(columnItems[0]) : idx}
className={CAROUSEL_COLUMN_CLASS}
style={{ scrollSnapAlign: 'start' }}
>
{columnItems.map(item => (
<div key={getItemKey(item)}>{renderCard(item)}</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', 'template_id'). */
itemKeyField: keyof TItem
renderCard: (item: TItem) => React.ReactNode
/** Collection names that use carousel layout (e.g. ['partners'], ['featured']). */
carouselCollectionNames: string[]
/** Search tab for ViewMoreButton (e.g. 'templates' for template collections). */
viewMoreSearchTab?: SearchTab
gridClassName?: string
cardContainerClassName?: string
emptyClassName?: string
}
function CollectionList<TItem, TCollection extends BaseCollection>({
collections,
collectionItemsMap,
itemKeyField,
renderCard,
carouselCollectionNames,
viewMoreSearchTab,
gridClassName = GRID_CLASS,
cardContainerClassName,
emptyClassName,
}: CollectionListProps<TItem, TCollection>) {
const locale = useLocale()
const collectionsWithItems = collections.filter((collection) => {
return collectionItemsMap[collection.name]?.length
})
if (collectionsWithItems.length === 0) {
return <Empty className={emptyClassName} />
}
return (
<>
{
collectionsWithItems.map((collection) => {
const items = collectionItemsMap[collection.name]
const isCarouselCollection = carouselCollectionNames.includes(collection.name)
return (
<div
key={collection.name}
className="py-3"
>
<CollectionHeader
collection={collection}
itemsLength={items.length}
locale={locale}
carouselCollectionNames={carouselCollectionNames}
viewMore={<ViewMoreButton searchParams={collection.search_params} searchTab={viewMoreSearchTab} />}
/>
{isCarouselCollection
? (
<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

View File

@@ -1,55 +0,0 @@
'use client'
import type { Template } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import Empty from '../empty'
import CardWrapper from './card-wrapper'
import { GRID_CLASS } from './collection-list'
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) => {
if (!props.items.length)
return <Empty />
if (props.variant === 'plugins') {
const { items, showInstallButton } = props
return (
<div className={GRID_CLASS}>
{items.map(plugin => (
<CardWrapper
key={`${plugin.org}/${plugin.name}`}
plugin={plugin}
showInstallButton={showInstallButton}
/>
))}
</div>
)
}
const { items } = props
return (
<div className={GRID_CLASS}>
{items.map(template => (
<TemplateCard
key={template.template_id}
template={template}
/>
))}
</div>
)
}
export default FlatList

View File

@@ -1,4 +1,4 @@
import type { PluginCollection, SearchParamsFromCollection } from '../types'
import type { MarketplaceCollection, 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'
@@ -36,8 +36,8 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => {
mockMarketplaceData: {
plugins: undefined as Plugin[] | undefined,
pluginsTotal: 0,
pluginCollections: undefined as PluginCollection[] | undefined,
pluginCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
marketplaceCollections: undefined as MarketplaceCollection[] | undefined,
marketplaceCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
isLoading: false,
page: 1,
},
@@ -131,10 +131,11 @@ vi.mock('@/app/components/plugins/card', () => ({
),
}))
// Mock CardTags component
vi.mock('@/app/components/plugins/card/card-tags', () => ({
default: ({ tags }: { tags: string[] }) => (
<div data-testid="card-tags">
// Mock CardMoreInfo component
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>
<span data-testid="tags">{tags.join(',')}</span>
</div>
),
@@ -207,7 +208,7 @@ const createMockPluginList = (count: number): Plugin[] =>
label: { 'en-US': `Plugin ${i}` },
}))
const createMockCollection = (overrides?: Partial<PluginCollection>): PluginCollection => ({
const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({
name: `collection-${Math.random().toString(36).substring(7)}`,
label: { 'en-US': 'Test Collection' },
description: { 'en-US': 'Test collection description' },
@@ -219,7 +220,7 @@ const createMockCollection = (overrides?: Partial<PluginCollection>): PluginColl
...overrides,
})
const createMockCollectionList = (count: number): PluginCollection[] =>
const createMockCollectionList = (count: number): MarketplaceCollection[] =>
Array.from({ length: count }, (_, i) =>
createMockCollection({
name: `collection-${i}`,
@@ -232,8 +233,8 @@ const createMockCollectionList = (count: number): PluginCollection[] =>
// ================================
describe('List', () => {
const defaultProps = {
pluginCollections: [] as PluginCollection[],
pluginCollectionPluginsMap: {} as Record<string, Plugin[]>,
marketplaceCollections: [] as MarketplaceCollection[],
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
plugins: undefined,
showInstallButton: false,
cardContainerClassName: '',
@@ -267,8 +268,8 @@ describe('List', () => {
render(
<List
{...defaultProps}
pluginCollections={collections}
pluginCollectionPluginsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
@@ -313,8 +314,8 @@ describe('List', () => {
render(
<List
{...defaultProps}
pluginCollections={collections}
pluginCollectionPluginsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
plugins={[]}
/>,
)
@@ -425,12 +426,12 @@ describe('List', () => {
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle empty pluginCollections', () => {
it('should handle empty marketplaceCollections', () => {
render(
<List
{...defaultProps}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
@@ -447,8 +448,8 @@ describe('List', () => {
render(
<List
{...defaultProps}
pluginCollections={collections}
pluginCollectionPluginsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
plugins={undefined}
/>,
)
@@ -495,12 +496,12 @@ describe('List', () => {
// ================================
describe('ListWithCollection', () => {
const defaultProps = {
variant: 'plugins' as const,
collections: [] as PluginCollection[],
collectionItemsMap: {} as Record<string, Plugin[]>,
marketplaceCollections: [] as MarketplaceCollection[],
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
showInstallButton: false,
cardContainerClassName: '',
cardRender: undefined,
onMoreClick: undefined,
}
beforeEach(() => {
@@ -527,8 +528,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
@@ -547,8 +548,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
@@ -567,8 +568,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
@@ -583,19 +584,19 @@ describe('ListWithCollection', () => {
describe('View More Button', () => {
it('should render View More button when collection is searchable', () => {
const collections = [createMockCollection({
name: 'partners',
name: 'collection-0',
searchable: true,
search_params: { query: 'test' },
})]
const pluginsMap: Record<string, Plugin[]> = {
partners: createMockPluginList(1),
'collection-0': createMockPluginList(1),
}
render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
@@ -614,8 +615,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
@@ -625,19 +626,19 @@ 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: 'partners',
name: 'collection-0',
searchable: true,
search_params: searchParams,
})]
const pluginsMap: Record<string, Plugin[]> = {
partners: createMockPluginList(1),
'collection-0': createMockPluginList(1),
}
render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
@@ -668,8 +669,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
cardRender={customCardRender}
/>,
)
@@ -692,8 +693,8 @@ describe('ListWithCollection', () => {
const { container } = render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
cardContainerClassName="custom-container"
/>,
)
@@ -710,8 +711,8 @@ describe('ListWithCollection', () => {
const { container } = render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
showInstallButton={true}
/>,
)
@@ -729,8 +730,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
collections={[]}
collectionItemsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
@@ -745,8 +746,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
@@ -763,8 +764,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
@@ -783,8 +784,8 @@ describe('ListWrapper', () => {
// Reset mock data
mockMarketplaceData.plugins = undefined
mockMarketplaceData.pluginsTotal = 0
mockMarketplaceData.pluginCollections = undefined
mockMarketplaceData.pluginCollectionPluginsMap = undefined
mockMarketplaceData.marketplaceCollections = undefined
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
mockMarketplaceData.isLoading = false
mockMarketplaceData.page = 1
})
@@ -861,8 +862,8 @@ describe('ListWrapper', () => {
describe('List Rendering Logic', () => {
it('should render collections when not loading', () => {
mockMarketplaceData.isLoading = false
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
mockMarketplaceData.pluginCollectionPluginsMap = {
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1),
}
@@ -874,8 +875,8 @@ describe('ListWrapper', () => {
it('should render List when loading but page > 1', () => {
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 2
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
mockMarketplaceData.pluginCollectionPluginsMap = {
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1),
}
@@ -899,13 +900,13 @@ describe('ListWrapper', () => {
})
it('should show View More button and call moreClick hook', () => {
mockMarketplaceData.pluginCollections = [createMockCollection({
name: 'partners',
mockMarketplaceData.marketplaceCollections = [createMockCollection({
name: 'collection-0',
searchable: true,
search_params: { query: 'test' },
})]
mockMarketplaceData.pluginCollectionPluginsMap = {
partners: createMockPluginList(1),
mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1),
}
render(<ListWrapper />)
@@ -973,8 +974,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
/>,
)
@@ -982,7 +983,7 @@ describe('CardWrapper (via List integration)', () => {
expect(screen.getByTestId('card-test-plugin')).toBeInTheDocument()
})
it('should render CardTags with tags', () => {
it('should render CardMoreInfo with download count and tags', () => {
const plugin = createMockPlugin({
name: 'test-plugin',
install_count: 5000,
@@ -991,13 +992,14 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
/>,
)
expect(screen.getByTestId('card-tags')).toBeInTheDocument()
expect(screen.getByTestId('card-more-info')).toBeInTheDocument()
expect(screen.getByTestId('download-count')).toHaveTextContent('5000')
})
})
@@ -1010,8 +1012,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={plugins}
/>,
)
@@ -1030,8 +1032,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
@@ -1050,8 +1052,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
@@ -1071,8 +1073,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
@@ -1089,8 +1091,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
@@ -1105,8 +1107,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
@@ -1121,8 +1123,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
@@ -1147,8 +1149,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={false}
/>,
@@ -1167,8 +1169,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={false}
/>,
@@ -1182,8 +1184,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
/>,
)
@@ -1205,8 +1207,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
/>,
)
@@ -1222,8 +1224,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
/>,
)
@@ -1239,8 +1241,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
/>,
)
@@ -1261,8 +1263,8 @@ describe('Combined Workflows', () => {
mockMarketplaceData.pluginsTotal = 0
mockMarketplaceData.isLoading = false
mockMarketplaceData.page = 1
mockMarketplaceData.pluginCollections = undefined
mockMarketplaceData.pluginCollectionPluginsMap = undefined
mockMarketplaceData.marketplaceCollections = undefined
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
})
it('should transition from loading to showing collections', async () => {
@@ -1275,8 +1277,8 @@ describe('Combined Workflows', () => {
// Simulate loading complete
mockMarketplaceData.isLoading = false
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
mockMarketplaceData.pluginCollectionPluginsMap = {
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1),
}
@@ -1287,8 +1289,8 @@ describe('Combined Workflows', () => {
})
it('should transition from collections to search results', async () => {
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
mockMarketplaceData.pluginCollectionPluginsMap = {
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1),
}
@@ -1350,9 +1352,8 @@ describe('Accessibility', () => {
const { container } = render(
<ListWithCollection
variant="plugins"
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
@@ -1361,20 +1362,19 @@ describe('Accessibility', () => {
expect(headings.length).toBeGreaterThan(0)
})
it('should have clickable View More button', () => {
it('should have clickable View More button', () => {
const collections = [createMockCollection({
name: 'partners',
name: 'collection-0',
searchable: true,
})]
const pluginsMap: Record<string, Plugin[]> = {
partners: createMockPluginList(1),
'collection-0': createMockPluginList(1),
}
render(
<ListWithCollection
variant="plugins"
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
@@ -1383,18 +1383,18 @@ describe('Accessibility', () => {
expect(viewMoreButton.closest('div')).toHaveClass('cursor-pointer')
})
it('should have proper grid layout for cards', () => {
it('should have proper grid layout for cards', () => {
const plugins = createMockPluginList(4)
const { container } = render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={plugins}
/>,
)
const grid = container.querySelector('.grid')
const grid = container.querySelector('.grid-cols-4')
expect(grid).toBeInTheDocument()
})
})
@@ -1413,8 +1413,8 @@ describe('Performance', () => {
const startTime = performance.now()
render(
<List
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={plugins}
/>,
)
@@ -1434,9 +1434,8 @@ describe('Performance', () => {
const startTime = performance.now()
render(
<ListWithCollection
variant="plugins"
collections={collections}
collectionItemsMap={pluginsMap}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
const endTime = performance.now()

View File

@@ -1,16 +1,14 @@
'use client'
import type { Plugin } from '../../types'
import type { PluginCollection } from '../types'
import type { MarketplaceCollection } from '../types'
import { cn } from '@/utils/classnames'
import Empty from '../empty'
import CardWrapper from './card-wrapper'
import { GRID_CLASS } from './collection-list'
import ListWithCollection from './list-with-collection'
type ListProps = {
pluginCollections: PluginCollection[]
pluginCollectionPluginsMap: Record<string, Plugin[]>
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
plugins?: Plugin[]
showInstallButton?: boolean
cardContainerClassName?: string
@@ -18,8 +16,8 @@ type ListProps = {
emptyClassName?: string
}
const List = ({
pluginCollections,
pluginCollectionPluginsMap,
marketplaceCollections,
marketplaceCollectionPluginsMap,
plugins,
showInstallButton,
cardContainerClassName,
@@ -31,9 +29,8 @@ const List = ({
{
!plugins && (
<ListWithCollection
variant="plugins"
collections={pluginCollections}
collectionItemsMap={pluginCollectionPluginsMap}
marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
showInstallButton={showInstallButton}
cardContainerClassName={cardContainerClassName}
cardRender={cardRender}
@@ -42,7 +39,11 @@ const List = ({
}
{
plugins && !!plugins.length && (
<div className={cn(GRID_CLASS, cardContainerClassName)}>
<div className={cn(
'grid grid-cols-4 gap-3',
cardContainerClassName,
)}
>
{
plugins.map((plugin) => {
if (cardRender)

View File

@@ -1,82 +1,83 @@
'use client'
import type { PluginCollection, Template, TemplateCollection } from '../types'
import type { MarketplaceCollection } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightSLine } from '@remixicon/react'
import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
import { useMarketplaceMoreClick } from '../atoms'
import CardWrapper from './card-wrapper'
import CollectionList, { CAROUSEL_COLLECTION_NAMES } from './collection-list'
import TemplateCard from './template-card'
type BaseProps = {
cardContainerClassName?: string
}
type PluginsVariant = BaseProps & {
variant: 'plugins'
collections: PluginCollection[]
collectionItemsMap: Record<string, Plugin[]>
type ListWithCollectionProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
showInstallButton?: boolean
cardContainerClassName?: string
cardRender?: (plugin: Plugin) => React.JSX.Element | null
}
type TemplatesVariant = BaseProps & {
variant: 'templates'
collections: TemplateCollection[]
collectionItemsMap: Record<string, Template[]>
}
type ListWithCollectionProps = PluginsVariant | TemplatesVariant
const ListWithCollection = (props: ListWithCollectionProps) => {
const { variant, cardContainerClassName } = props
if (variant === 'plugins') {
const {
collections,
collectionItemsMap,
showInstallButton,
cardRender,
} = props
const renderPluginCard = (plugin: Plugin) => {
if (cardRender)
return cardRender(plugin)
return (
<CardWrapper
plugin={plugin}
showInstallButton={showInstallButton}
/>
)
}
return (
<CollectionList
collections={collections}
collectionItemsMap={collectionItemsMap}
itemKeyField="plugin_id"
renderCard={renderPluginCard}
carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.partners]}
cardContainerClassName={cardContainerClassName}
/>
)
}
const { collections, collectionItemsMap } = props
const renderTemplateCard = (template: Template) => (
<TemplateCard template={template} />
)
const ListWithCollection = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
showInstallButton,
cardContainerClassName,
cardRender,
}: ListWithCollectionProps) => {
const { t } = useTranslation()
const locale = useLocale()
const onMoreClick = useMarketplaceMoreClick()
return (
<CollectionList
collections={collections}
collectionItemsMap={collectionItemsMap}
itemKeyField="template_id"
renderCard={renderTemplateCard}
carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.featured]}
viewMoreSearchTab="templates"
cardContainerClassName={cardContainerClassName}
/>
<>
{
marketplaceCollections.filter((collection) => {
return marketplaceCollectionPluginsMap[collection.name]?.length
}).map(collection => (
<div
key={collection.name}
className="py-3"
>
<div className="flex items-end justify-between">
<div>
<div className="title-xl-semi-bold text-text-primary">{collection.label[getLanguage(locale)]}</div>
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
</div>
{
collection.searchable && (
<div
className="system-xs-medium flex cursor-pointer items-center text-text-accent "
onClick={() => onMoreClick(collection.search_params)}
>
{t('marketplace.viewMore', { ns: 'plugin' })}
<RiArrowRightSLine className="h-4 w-4" />
</div>
)
}
</div>
<div className={cn(
'mt-2 grid grid-cols-4 gap-3',
cardContainerClassName,
)}
>
{
marketplaceCollectionPluginsMap[collection.name].map((plugin) => {
if (cardRender)
return cardRender(plugin)
return (
<CardWrapper
key={plugin.plugin_id}
plugin={plugin}
showInstallButton={showInstallButton}
/>
)
})
}
</div>
</div>
))
}
</>
)
}

View File

@@ -1,84 +0,0 @@
import type { Template } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ListWrapper from './list-wrapper'
const { mockMarketplaceData } = vi.hoisted(() => ({
mockMarketplaceData: {
creationType: 'plugins' as 'plugins' | 'templates',
isLoading: false,
page: 1,
isFetchingNextPage: false,
pluginCollections: [],
pluginCollectionPluginsMap: {},
plugins: undefined as Plugin[] | undefined,
templateCollections: [],
templateCollectionTemplatesMap: {},
templates: undefined as Template[] | undefined,
},
}))
vi.mock('../state', () => ({
useMarketplaceData: () => mockMarketplaceData,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading-component">Loading</div>,
}))
vi.mock('./flat-list', () => ({
default: ({ variant, items }: { variant: 'plugins' | 'templates', items: unknown[] }) => (
<div data-testid={`flat-list-${variant}`}>
{items.length}
</div>
),
}))
vi.mock('./list-with-collection', () => ({
default: ({ variant }: { variant: 'plugins' | 'templates' }) => (
<div data-testid={`collection-list-${variant}`}>collection</div>
),
}))
describe('ListWrapper flat rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMarketplaceData.creationType = 'plugins'
mockMarketplaceData.isLoading = false
mockMarketplaceData.page = 1
mockMarketplaceData.isFetchingNextPage = false
mockMarketplaceData.plugins = undefined
mockMarketplaceData.templates = undefined
})
it('renders plugin flat list when plugin items exist', () => {
mockMarketplaceData.creationType = 'plugins'
mockMarketplaceData.plugins = [{ org: 'o', name: 'p' } as Plugin]
render(<ListWrapper />)
expect(screen.getByTestId('flat-list-plugins')).toBeInTheDocument()
expect(screen.queryByTestId('collection-list-plugins')).not.toBeInTheDocument()
})
it('renders template flat list when template items exist', () => {
mockMarketplaceData.creationType = 'templates'
mockMarketplaceData.templates = [{ template_id: 't1' } as Template]
render(<ListWrapper />)
expect(screen.getByTestId('flat-list-templates')).toBeInTheDocument()
expect(screen.queryByTestId('collection-list-templates')).not.toBeInTheDocument()
})
it('renders template collection list when templates are undefined', () => {
mockMarketplaceData.creationType = 'templates'
mockMarketplaceData.templates = undefined
render(<ListWrapper />)
expect(screen.getByTestId('collection-list-templates')).toBeInTheDocument()
expect(screen.queryByTestId('flat-list-templates')).not.toBeInTheDocument()
})
})

View File

@@ -1,57 +1,64 @@
'use client'
import { useTranslation } from '#i18n'
import Loading from '@/app/components/base/loading'
import { isPluginsData, useMarketplaceData } from '../state'
import FlatList from './flat-list'
import ListWithCollection from './list-with-collection'
import SortDropdown from '../sort-dropdown'
import { useMarketplaceData } from '../state'
import List from './index'
type ListWrapperProps = {
showInstallButton?: boolean
}
const ListWrapper = ({
showInstallButton,
}: ListWrapperProps) => {
const { t } = useTranslation()
const ListWrapper = ({ showInstallButton }: ListWrapperProps) => {
const marketplaceData = useMarketplaceData()
const { isLoading, page, isFetchingNextPage } = marketplaceData
const renderContent = () => {
if (isPluginsData(marketplaceData)) {
const { pluginCollections, pluginCollectionPluginsMap, plugins } = marketplaceData
return plugins !== undefined
? <FlatList variant="plugins" items={plugins} showInstallButton={showInstallButton} />
: (
<ListWithCollection
variant="plugins"
collections={pluginCollections || []}
collectionItemsMap={pluginCollectionPluginsMap || {}}
showInstallButton={showInstallButton}
/>
)
}
const { templateCollections, templateCollectionTemplatesMap, templates } = marketplaceData
return templates !== undefined
? <FlatList variant="templates" items={templates} />
: (
<ListWithCollection
variant="templates"
collections={templateCollections || []}
collectionItemsMap={templateCollectionTemplatesMap || {}}
/>
)
}
const {
plugins,
pluginsTotal,
marketplaceCollections,
marketplaceCollectionPluginsMap,
isLoading,
isFetchingNextPage,
page,
} = useMarketplaceData()
return (
<div
style={{ scrollbarGutter: 'stable' }}
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
>
{isLoading && page === 1 && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Loading />
</div>
)}
{(!isLoading || page > 1) && renderContent()}
{isFetchingNextPage && <Loading className="my-3" />}
{
plugins && (
<div className="mb-4 flex items-center pt-3">
<div className="title-xl-semi-bold text-text-primary">{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}</div>
<div className="mx-3 h-3.5 w-[1px] bg-divider-regular"></div>
<SortDropdown />
</div>
)
}
{
isLoading && page === 1 && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Loading />
</div>
)
}
{
(!isLoading || page > 1) && (
<List
marketplaceCollections={marketplaceCollections || []}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
plugins={plugins}
showInstallButton={showInstallButton}
/>
)
}
{
isFetchingNextPage && (
<Loading className="my-3" />
)
}
</div>
)
}

View File

@@ -1,180 +0,0 @@
'use client'
import type { Template } from '../types'
import { useLocale } from '#i18n'
import Image from 'next/image'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import useTheme from '@/hooks/use-theme'
import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
import { formatUsedCount } from '@/utils/template'
import { getMarketplaceUrl } from '@/utils/var'
type TemplateCardProps = {
template: Template
className?: string
}
// Number of tag icons to show before showing "+X"
const MAX_VISIBLE_TAGS = 7
// Soft background color palette for avatar
const AVATAR_BG_COLORS = [
'bg-components-icon-bg-red-soft',
'bg-components-icon-bg-orange-dark-soft',
'bg-components-icon-bg-yellow-soft',
'bg-components-icon-bg-green-soft',
'bg-components-icon-bg-teal-soft',
'bg-components-icon-bg-blue-light-soft',
'bg-components-icon-bg-blue-soft',
'bg-components-icon-bg-indigo-soft',
'bg-components-icon-bg-violet-soft',
'bg-components-icon-bg-pink-soft',
]
// Simple hash function to get consistent color per template
const getAvatarBgClass = (id: string): string => {
let hash = 0
for (let i = 0; i < id.length; i++) {
const char = id.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
return AVATAR_BG_COLORS[Math.abs(hash) % AVATAR_BG_COLORS.length]
}
const TemplateCardComponent = ({
template,
className,
}: TemplateCardProps) => {
const locale = useLocale()
const { theme } = useTheme()
const { template_id, name, description, icon, tags, author, used_count, icon_background } = template as Template & { used_count?: number, icon_background?: string }
const isIconUrl = !!icon && /^(?:https?:)?\/\//.test(icon)
const avatarBgStyle = useMemo(() => {
// If icon_background is provided (hex or rgba), use it directly
if (icon_background)
return { backgroundColor: icon_background }
return undefined
}, [icon_background])
const avatarBgClass = useMemo(() => {
// Only use class-based color if no inline style
if (icon_background)
return ''
return getAvatarBgClass(template_id)
}, [icon_background, template_id])
const descriptionText = description[getLanguage(locale)] || description.en_US || ''
const handleClick = useCallback(() => {
const url = getMarketplaceUrl(`/templates/${author}/${name}`, {
theme,
language: locale,
templateId: template_id,
})
window.open(url, '_blank')
}, [author, name, theme, locale, template_id])
const visibleTags = tags?.slice(0, MAX_VISIBLE_TAGS) || []
const remainingTagsCount = tags ? Math.max(0, tags.length - MAX_VISIBLE_TAGS) : 0
const formattedUsedCount = formatUsedCount(used_count, { precision: 0, rounding: 'floor' })
return (
<div
className={cn(
'hover-bg-components-panel-on-panel-item-bg relative flex h-full cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs',
className,
)}
onClick={handleClick}
>
{/* Header */}
<div className="flex shrink-0 items-center gap-3 px-4 pb-2 pt-4">
{/* Avatar */}
<div
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-divider-regular p-1',
avatarBgClass,
)}
style={avatarBgStyle}
>
{isIconUrl
? (
<Image
src={icon}
alt={name}
width={24}
height={24}
className="h-6 w-6 object-contain"
/>
)
: (
<span className="text-2xl leading-[1.2]">{icon || '📄'}</span>
)}
</div>
{/* Title */}
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<p className="system-md-medium truncate text-text-primary">{name}</p>
<div className="system-xs-regular flex items-center gap-2 text-text-tertiary">
<span className="flex shrink-0 items-center gap-1">
<span>by</span>
<span className="truncate">{author}</span>
</span>
{formattedUsedCount && (
<>
<span className="shrink-0">·</span>
<span className="shrink-0">
{formattedUsedCount}
{' '}
used
</span>
</>
)}
</div>
</div>
</div>
{/* Description */}
<div className="shrink-0 px-4 pb-2 pt-1">
<p
className="system-xs-regular line-clamp-2 min-h-[32px] text-text-secondary"
title={descriptionText}
>
{descriptionText}
</p>
</div>
{/* Bottom Info Bar - Tags as icons */}
<div className="mt-auto flex min-h-7 shrink-0 items-center gap-1 px-4 py-1">
{tags && tags.length > 0 && (
<>
{visibleTags.map((tag, index) => (
<div
key={`${template_id}-tag-${index}`}
className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md border-[0.5px] border-effects-icon-border bg-background-default-dodge"
title={tag}
>
<span className="text-sm">{tag}</span>
</div>
))}
{remainingTagsCount > 0 && (
<div className="flex items-center justify-center p-0.5">
<span className="system-xs-regular text-text-tertiary">
+
{remainingTagsCount}
</span>
</div>
)}
</>
)}
</div>
</div>
)
}
const TemplateCard = React.memo(TemplateCardComponent)
export default TemplateCard

View File

@@ -1,19 +0,0 @@
'use client'
import { useSearchTab } from './atoms'
import ListWrapper from './list/list-wrapper'
import SearchPage from './search-page'
type MarketplaceContentProps = {
showInstallButton?: boolean
}
const MarketplaceContent = ({ showInstallButton }: MarketplaceContentProps) => {
const [searchTab] = useSearchTab()
if (searchTab)
return <SearchPage />
return <ListWrapper showInstallButton={showInstallButton} />
}
export default MarketplaceContent

View File

@@ -1,21 +0,0 @@
'use client'
import { useSearchTab } from './atoms'
import { Description } from './description'
import SearchResultsHeader from './search-results-header'
type MarketplaceHeaderProps = {
descriptionClassName?: string
marketplaceNav?: React.ReactNode
}
const MarketplaceHeader = ({ descriptionClassName, marketplaceNav }: MarketplaceHeaderProps) => {
const [searchTab] = useSearchTab()
if (searchTab)
return <SearchResultsHeader marketplaceNav={marketplaceNav} />
return <Description className={descriptionClassName} marketplaceNav={marketplaceNav} />
}
export default MarketplaceHeader

View File

@@ -1,21 +0,0 @@
import type { ComponentType } from 'react'
import {
RiBrain2Line,
RiDatabase2Line,
RiHammerLine,
RiPuzzle2Line,
RiSpeakAiLine,
} from '@remixicon/react'
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
import { PluginCategoryEnum } from '../types'
export type PluginTypeIconComponent = ComponentType<{ className?: string }>
export const MARKETPLACE_TYPE_ICON_COMPONENTS: Record<PluginCategoryEnum, PluginTypeIconComponent> = {
[PluginCategoryEnum.tool]: RiHammerLine,
[PluginCategoryEnum.model]: RiBrain2Line,
[PluginCategoryEnum.datasource]: RiDatabase2Line,
[PluginCategoryEnum.trigger]: TriggerIcon,
[PluginCategoryEnum.agent]: RiSpeakAiLine,
[PluginCategoryEnum.extension]: RiPuzzle2Line,
}

View File

@@ -0,0 +1,105 @@
'use client'
import type { ActivePluginType } from './constants'
import { useTranslation } from '#i18n'
import {
RiArchive2Line,
RiBrain2Line,
RiDatabase2Line,
RiHammerLine,
RiPuzzle2Line,
RiSpeakAiLine,
} from '@remixicon/react'
import { useSetAtom } from 'jotai'
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
import { cn } from '@/utils/classnames'
import { searchModeAtom, useActivePluginType } from './atoms'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
type PluginTypeSwitchProps = {
className?: string
}
const PluginTypeSwitch = ({
className,
}: PluginTypeSwitchProps) => {
const { t } = useTranslation()
const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
const setSearchMode = useSetAtom(searchModeAtom)
const options: Array<{
value: ActivePluginType
text: string
icon: React.ReactNode | null
}> = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: t('category.all', { ns: 'plugin' }),
icon: null,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: t('category.models', { ns: 'plugin' }),
icon: <RiBrain2Line className="mr-1.5 h-4 w-4" />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: t('category.tools', { ns: 'plugin' }),
icon: <RiHammerLine className="mr-1.5 h-4 w-4" />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
text: t('category.datasources', { ns: 'plugin' }),
icon: <RiDatabase2Line className="mr-1.5 h-4 w-4" />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
text: t('category.triggers', { ns: 'plugin' }),
icon: <TriggerIcon className="mr-1.5 h-4 w-4" />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.agent,
text: t('category.agents', { ns: 'plugin' }),
icon: <RiSpeakAiLine className="mr-1.5 h-4 w-4" />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: t('category.extensions', { ns: 'plugin' }),
icon: <RiPuzzle2Line className="mr-1.5 h-4 w-4" />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
text: t('category.bundles', { ns: 'plugin' }),
icon: <RiArchive2Line className="mr-1.5 h-4 w-4" />,
},
]
return (
<div className={cn(
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
className,
)}
>
{
options.map(option => (
<div
key={option.value}
className={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',
activePluginType === option.value && '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',
)}
onClick={() => {
handleActivePluginTypeChange(option.value)
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) {
setSearchMode(null)
}
}}
>
{option.icon}
{option.text}
</div>
))
}
</div>
)
}
export default PluginTypeSwitch

View File

@@ -1,37 +1,23 @@
import type { CreatorSearchParams, PluginsSearchParams, TemplateSearchParams, UnifiedSearchParams } from './types'
import type { PluginsSearchParams } from './types'
import type { MarketPlaceInputs } from '@/contract/router'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { marketplaceQuery } from '@/service/client'
import { getMarketplaceCollectionsAndPlugins, getMarketplaceCreators, getMarketplacePlugins, getMarketplaceTemplateCollectionsAndTemplates, getMarketplaceTemplates, getMarketplaceUnifiedSearch } from './utils'
import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils'
export function useMarketplaceCollectionsAndPlugins(
collectionsParams: MarketPlaceInputs['plugins']['collections']['query'],
options?: { enabled?: boolean },
collectionsParams: MarketPlaceInputs['collections']['query'],
) {
return useQuery({
queryKey: marketplaceQuery.plugins.collections.queryKey({ input: { query: collectionsParams } }),
queryKey: marketplaceQuery.collections.queryKey({ input: { query: collectionsParams } }),
queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }),
enabled: options?.enabled !== false,
})
}
export function useMarketplaceTemplateCollectionsAndTemplates(
query?: { page?: number, page_size?: number, condition?: string },
options?: { enabled?: boolean },
) {
return useQuery({
queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query } }),
queryFn: ({ signal }) => getMarketplaceTemplateCollectionsAndTemplates(query, { signal }),
enabled: options?.enabled !== false,
})
}
export function useMarketplacePlugins(
queryParams: PluginsSearchParams | undefined,
options?: { enabled?: boolean },
) {
return useInfiniteQuery({
queryKey: marketplaceQuery.plugins.searchAdvanced.queryKey({
queryKey: marketplaceQuery.searchAdvanced.queryKey({
input: {
body: queryParams!,
params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' },
@@ -44,59 +30,6 @@ export function useMarketplacePlugins(
return loaded < (lastPage.total || 0) ? nextPage : undefined
},
initialPageParam: 1,
enabled: options?.enabled !== false && queryParams !== undefined,
})
}
export function useMarketplaceTemplates(
queryParams: TemplateSearchParams | undefined,
options?: { enabled?: boolean },
) {
return useInfiniteQuery({
queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({
input: {
body: queryParams!,
},
}),
queryFn: ({ pageParam = 1, signal }) => getMarketplaceTemplates(queryParams, pageParam, signal),
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1
const loaded = lastPage.page * lastPage.page_size
return loaded < (lastPage.total || 0) ? nextPage : undefined
},
initialPageParam: 1,
enabled: options?.enabled !== false && queryParams !== undefined,
})
}
export function useMarketplaceCreators(
queryParams: CreatorSearchParams | undefined,
) {
return useInfiniteQuery({
queryKey: marketplaceQuery.creators.searchAdvanced.queryKey({
input: {
body: queryParams!,
},
}),
queryFn: ({ pageParam = 1, signal }) => getMarketplaceCreators(queryParams, pageParam, signal),
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1
const loaded = lastPage.page * lastPage.page_size
return loaded < (lastPage.total || 0) ? nextPage : undefined
},
initialPageParam: 1,
enabled: queryParams !== undefined,
})
}
export function useMarketplaceUnifiedSearch(
queryParams: UnifiedSearchParams | undefined,
) {
return useQuery({
queryKey: marketplaceQuery.searchUnified.queryKey({
input: { body: queryParams! },
}),
queryFn: ({ signal }) => getMarketplaceUnifiedSearch(queryParams, signal),
enabled: queryParams !== undefined,
})
}

View File

@@ -1,11 +1,8 @@
import type { Tag } from '@/app/components/plugins/hooks'
import type { Plugin } from '@/app/components/plugins/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../types'
import SearchBox from './index'
import SearchBoxWrapper from './search-box-wrapper'
import SearchDropdown from './search-dropdown'
import MarketplaceTrigger from './trigger/marketplace'
import ToolSelectorTrigger from './trigger/tool-selector'
@@ -16,85 +13,34 @@ import ToolSelectorTrigger from './trigger/tool-selector'
// Mock i18n translation hook
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string, num?: number, author?: string }) => {
t: (key: string, options?: { ns?: string }) => {
// Build full key with namespace prefix if provided
const fullKey = options?.ns ? `${options.ns}.${key}` : key
const translations: Record<string, string> = {
'pluginTags.allTags': 'All Tags',
'pluginTags.searchTags': 'Search tags',
'plugin.searchPlugins': 'Search plugins',
'plugin.install': `${options?.num || 0} installs`,
'plugin.marketplace.searchDropdown.plugins': 'Plugins',
'plugin.marketplace.searchDropdown.showAllResults': 'Show all search results',
'plugin.marketplace.searchDropdown.enter': 'Enter',
'plugin.marketplace.searchDropdown.byAuthor': `by ${options?.author || ''}`,
}
return translations[fullKey] || key
},
}),
useLocale: () => 'en-US',
}))
vi.mock('ahooks', () => ({
useDebounce: (value: string) => value,
}))
vi.mock('jotai', async () => {
const actual = await vi.importActual<typeof import('jotai')>('jotai')
return {
...actual,
useSetAtom: () => vi.fn(),
}
})
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: Record<string, string> | string) => {
if (typeof value === 'string')
return value
return value.en_US || Object.values(value)[0] || ''
},
}))
// Mock marketplace state hooks
const {
mockSearchText,
mockHandleSearchTextChange,
mockFilterPluginTags,
mockHandleFilterPluginTagsChange,
mockActivePluginCategory,
mockSortValue,
} = vi.hoisted(() => {
const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => {
return {
mockSearchText: '',
mockHandleSearchTextChange: vi.fn(),
mockSearchPluginText: '',
mockHandleSearchPluginTextChange: vi.fn(),
mockFilterPluginTags: [] as string[],
mockHandleFilterPluginTagsChange: vi.fn(),
mockActivePluginCategory: 'all',
mockSortValue: {
sortBy: 'install_count',
sortOrder: 'DESC',
},
}
})
vi.mock('../atoms', () => ({
useSearchText: () => [mockSearchText, mockHandleSearchTextChange],
useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange],
useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
useActivePluginCategory: () => [mockActivePluginCategory, vi.fn()],
useMarketplaceSortValue: () => mockSortValue,
searchModeAtom: {},
}))
vi.mock('../utils', async () => {
const actual = await vi.importActual<typeof import('../utils')>('../utils')
return {
...actual,
mapUnifiedPluginToPlugin: (item: Plugin) => item,
mapUnifiedTemplateToTemplate: (item: unknown) => item,
mapUnifiedCreatorToCreator: (item: unknown) => item,
}
})
// Mock useTags hook
const mockTags: Tag[] = [
{ name: 'agent', label: 'Agent' },
@@ -114,64 +60,8 @@ vi.mock('@/app/components/plugins/hooks', () => ({
tags: mockTags,
tagsMap: mockTagsMap,
}),
useCategories: () => ({
categoriesMap: {
'tool': { name: 'tool', label: 'Tool' },
'model': { name: 'model', label: 'Model' },
'datasource': { name: 'datasource', label: 'Data Source' },
'trigger': { name: 'trigger', label: 'Trigger' },
'agent-strategy': { name: 'agent-strategy', label: 'Agent Strategy' },
'extension': { name: 'extension', label: 'Extension' },
'bundle': { name: 'bundle', label: 'Bundle' },
},
}),
}))
let mockDropdownPlugins: Plugin[] = []
vi.mock('../query', () => ({
useMarketplaceUnifiedSearch: () => ({
data: {
plugins: { items: mockDropdownPlugins, total: mockDropdownPlugins.length },
templates: { items: [], total: 0 },
creators: { items: [], total: 0 },
organizations: { items: [], total: 0 },
page: 1,
page_size: 5,
},
isLoading: false,
}),
}))
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'dropbox',
author: 'dropbox',
name: 'dropbox-search',
plugin_id: 'plugin-1',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'pkg-1',
icon: 'https://example.com/icon.png',
verified: false,
label: { en_US: 'Dropbox search' },
brief: { en_US: 'Interact with Dropbox files.' },
description: { en_US: 'Interact with Dropbox files.' },
introduction: '',
repository: '',
category: PluginCategoryEnum.tool,
install_count: 206,
endpoint: {
settings: [],
},
tags: [],
badges: [],
verification: {
authorized_category: 'community',
},
from: 'marketplace',
...overrides,
})
// Mock portal-to-follow-elem with shared open state
let mockPortalOpenState = false
@@ -225,7 +115,6 @@ describe('SearchBox', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
mockDropdownPlugins = []
})
// ================================
@@ -535,68 +424,6 @@ describe('SearchBox', () => {
expect(onSearchChange).toHaveBeenCalledWith(' ')
})
})
// ================================
// Submission Tests
// ================================
describe('Submission', () => {
it('should call onSearchSubmit when pressing Enter', () => {
const onSearchSubmit = vi.fn()
render(<SearchBox {...defaultProps} onSearchSubmit={onSearchSubmit} />)
const input = screen.getByRole('textbox')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSearchSubmit).toHaveBeenCalledTimes(1)
})
})
})
// ================================
// SearchDropdown Component Tests
// ================================
describe('SearchDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render plugin items and metadata', () => {
render(
<SearchDropdown
query="dropbox"
plugins={[createPlugin()]}
templates={[]}
creators={[]}
onShowAll={vi.fn()}
/>,
)
expect(screen.getByText('Plugins')).toBeInTheDocument()
expect(screen.getByText('Dropbox search')).toBeInTheDocument()
expect(screen.getByText('Tool')).toBeInTheDocument()
expect(screen.getByText('206 installs')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should call onShowAll when clicking show all results', () => {
const onShowAll = vi.fn()
render(
<SearchDropdown
query="dropbox"
plugins={[createPlugin()]}
templates={[]}
creators={[]}
onShowAll={onShowAll}
/>,
)
fireEvent.click(screen.getByText('Show all search results'))
expect(onShowAll).toHaveBeenCalledTimes(1)
})
})
})
// ================================
@@ -606,7 +433,6 @@ describe('SearchBoxWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
mockDropdownPlugins = []
})
describe('Rendering', () => {
@@ -631,47 +457,13 @@ describe('SearchBoxWrapper', () => {
})
describe('Hook Integration', () => {
it('should not commit search when input changes', () => {
it('should call handleSearchPluginTextChange when search changes', () => {
render(<SearchBoxWrapper />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new search' } })
expect(mockHandleSearchTextChange).not.toHaveBeenCalled()
})
it('should commit search when pressing Enter', () => {
render(<SearchBoxWrapper />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new search' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(mockHandleSearchTextChange).toHaveBeenCalledWith('new search')
})
it('should clear committed search when input is emptied and blurred', () => {
render(<SearchBoxWrapper />)
const input = screen.getByRole('textbox')
// Focus, type something, then clear and blur
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'test query' } })
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
expect(mockHandleSearchTextChange).toHaveBeenCalledWith('')
})
it('should not clear committed search when input has content and blurred', () => {
render(<SearchBoxWrapper />)
const input = screen.getByRole('textbox')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'still has text' } })
fireEvent.blur(input)
expect(mockHandleSearchTextChange).not.toHaveBeenCalled()
expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search')
})
})

View File

@@ -8,9 +8,6 @@ import TagsFilter from './tags-filter'
type SearchBoxProps = {
search: string
onSearchChange: (search: string) => void
onSearchSubmit?: () => void
onSearchFocus?: () => void
onSearchBlur?: () => void
wrapperClassName?: string
inputClassName?: string
tags: string[]
@@ -25,9 +22,6 @@ type SearchBoxProps = {
const SearchBox = ({
search,
onSearchChange,
onSearchSubmit,
onSearchFocus,
onSearchBlur,
wrapperClassName,
inputClassName,
tags,
@@ -64,12 +58,6 @@ const SearchBox = ({
onChange={(e) => {
onSearchChange(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === 'Enter')
onSearchSubmit?.()
}}
onFocus={onSearchFocus}
onBlur={onSearchBlur}
placeholder={placeholder}
/>
{
@@ -101,12 +89,6 @@ const SearchBox = ({
onChange={(e) => {
onSearchChange(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === 'Enter')
onSearchSubmit?.()
}}
onFocus={onSearchFocus}
onBlur={onSearchBlur}
placeholder={placeholder}
/>
{

View File

@@ -1,135 +1,25 @@
'use client'
import type { UnifiedSearchParams } from '../types'
import { useTranslation } from '#i18n'
import { useDebounce } from 'ahooks'
import { useSetAtom } from 'jotai'
import { useMemo, useState } from 'react'
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 {
searchModeAtom,
useSearchText,
} from '../atoms'
import { useMarketplaceUnifiedSearch } from '../query'
import { mapUnifiedCreatorToCreator, mapUnifiedPluginToPlugin, mapUnifiedTemplateToTemplate } from '../utils'
import SearchDropdown from './search-dropdown'
import { useFilterPluginTags, useSearchPluginText } from '../atoms'
import SearchBox from './index'
type SearchBoxWrapperProps = {
wrapperClassName?: string
inputClassName?: string
}
const SearchBoxWrapper = ({
wrapperClassName,
inputClassName,
}: SearchBoxWrapperProps) => {
const SearchBoxWrapper = () => {
const { t } = useTranslation()
const [searchText, handleSearchTextChange] = useSearchText()
const setSearchMode = useSetAtom(searchModeAtom)
const committedSearch = searchText || ''
const [draftSearch, setDraftSearch] = useState(committedSearch)
const [isFocused, setIsFocused] = useState(false)
const [isHoveringDropdown, setIsHoveringDropdown] = useState(false)
const debouncedDraft = useDebounce(draftSearch, { wait: 300 })
const hasDraft = !!debouncedDraft.trim()
const dropdownQueryParams = useMemo((): UnifiedSearchParams | undefined => {
if (!hasDraft)
return undefined
return {
query: debouncedDraft.trim(),
scope: ['plugins', 'templates', 'creators'],
page_size: 5,
}
}, [debouncedDraft, hasDraft])
const dropdownQuery = useMarketplaceUnifiedSearch(dropdownQueryParams)
const dropdownPlugins = useMemo(
() => (dropdownQuery.data?.plugins.items || []).map(mapUnifiedPluginToPlugin),
[dropdownQuery.data?.plugins.items],
)
const dropdownTemplates = useMemo(
() => (dropdownQuery.data?.templates.items || []).map(mapUnifiedTemplateToTemplate),
[dropdownQuery.data?.templates.items],
)
const dropdownCreators = useMemo(
() => (dropdownQuery.data?.creators.items || []).map(mapUnifiedCreatorToCreator),
[dropdownQuery.data?.creators.items],
)
const handleSubmit = () => {
const trimmed = draftSearch.trim()
if (!trimmed)
return
handleSearchTextChange(trimmed)
setSearchMode(true)
setIsFocused(false)
}
const inputValue = isFocused ? draftSearch : committedSearch
const isDropdownOpen = hasDraft && (isFocused || isHoveringDropdown)
const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText()
const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
return (
<PortalToFollowElem
placement="bottom-start"
offset={8}
open={isDropdownOpen}
onOpenChange={setIsFocused}
>
<PortalToFollowElemTrigger asChild>
<div>
<Input
wrapperClassName={cn('w-[200px] rounded-lg lg:w-[300px]', wrapperClassName)}
className={cn('h-9 bg-components-input-bg-normal', inputClassName)}
showLeftIcon
value={inputValue}
placeholder={t('searchPlugins', { ns: 'plugin' })}
onChange={(e) => {
setDraftSearch(e.target.value)
}}
onFocus={() => {
setDraftSearch(committedSearch)
setIsFocused(true)
}}
onBlur={() => {
if (!isHoveringDropdown) {
if (!draftSearch.trim()) {
handleSearchTextChange('')
setSearchMode(null)
}
setIsFocused(false)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter')
handleSubmit()
}}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent
className="z-[1001]"
onMouseEnter={() => setIsHoveringDropdown(true)}
onMouseLeave={() => setIsHoveringDropdown(false)}
onMouseDown={(event) => {
event.preventDefault()
}}
>
<SearchDropdown
query={debouncedDraft.trim()}
plugins={dropdownPlugins}
templates={dropdownTemplates}
creators={dropdownCreators}
onShowAll={handleSubmit}
isLoading={dropdownQuery.isLoading}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
<SearchBox
wrapperClassName="z-[11] mx-auto w-[640px] shrink-0"
inputClassName="w-full"
search={searchPluginText}
onSearchChange={handleSearchPluginTextChange}
tags={filterPluginTags}
onTagsChange={handleFilterPluginTagsChange}
placeholder={t('searchPlugins', { ns: 'plugin' })}
usedInMarketplace
/>
)
}

View File

@@ -1,301 +0,0 @@
import type { Creator, Template } from '../../types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config/language'
import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightLine } from '@remixicon/react'
import { Fragment } from 'react'
import Loading from '@/app/components/base/loading'
import { useCategories } from '@/app/components/plugins/hooks'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
import { getMarketplaceUrl } from '@/utils/var'
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons'
import { getCreatorAvatarUrl, getPluginDetailLinkInMarketplace } from '../../utils'
const DROPDOWN_PANEL = 'w-[472px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-sm'
const ICON_BOX_BASE = 'flex shrink-0 items-center justify-center overflow-hidden border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'
const SectionDivider = () => (
<div className="border-t border-divider-subtle" />
)
const DropdownSection = ({ title, children }: { title: string, children: React.ReactNode }) => (
<div className="p-1">
<div className="system-xs-semibold-uppercase px-3 pb-2 pt-3 text-text-primary">{title}</div>
<div className="flex flex-col">{children}</div>
</div>
)
const DropdownItem = ({ href, icon, children }: {
href: string
icon: React.ReactNode
children: React.ReactNode
}) => (
<a className="flex gap-1 rounded-lg py-1 pl-3 pr-1 hover:bg-state-base-hover" href={href}>
{icon}
<div className="flex min-w-0 flex-1 flex-col gap-0.5 p-1">{children}</div>
</a>
)
const IconBox = ({ shape, size = 'sm', className, style, children }: {
shape: 'rounded-lg' | 'rounded-full'
size?: 'sm' | 'md'
className?: string
style?: React.CSSProperties
children: React.ReactNode
}) => (
<div
className={cn(
ICON_BOX_BASE,
shape,
size === 'sm' ? 'h-7 w-7' : 'h-8 w-8',
className,
)}
style={style}
>
{children}
</div>
)
const ItemMeta = ({ items }: { items: (React.ReactNode | string)[] }) => (
<div className="flex items-center gap-1.5 pt-1 text-text-tertiary">
{items.filter(Boolean).map((item, i) => (
<Fragment key={i}>
{i > 0 && <span className="system-xs-regular">·</span>}
{typeof item === 'string' ? <span className="system-xs-regular">{item}</span> : item}
</Fragment>
))}
</div>
)
type SearchDropdownProps = {
query: string
plugins: Plugin[]
templates: Template[]
creators: Creator[]
onShowAll: () => void
isLoading?: boolean
}
const SearchDropdown = ({
query,
plugins,
templates,
creators,
onShowAll,
isLoading = false,
}: SearchDropdownProps) => {
const { t } = useTranslation()
const locale = useLocale()
const getValueFromI18nObject = useRenderI18nObject()
const { categoriesMap } = useCategories(true)
const hasResults = plugins.length > 0 || templates.length > 0 || creators.length > 0
// Collect rendered sections with dividers between them
const sections: React.ReactNode[] = []
if (templates.length > 0) {
sections.push(
<TemplatesSection
key="templates"
templates={templates}
locale={locale}
t={t}
/>,
)
}
if (plugins.length > 0) {
sections.push(
<PluginsSection
key="plugins"
plugins={plugins}
getValueFromI18nObject={getValueFromI18nObject}
categoriesMap={categoriesMap}
t={t}
/>,
)
}
if (creators.length > 0) {
sections.push(
<CreatorsSection
key="creators"
creators={creators}
t={t}
/>,
)
}
return (
<div className={DROPDOWN_PANEL}>
<div className="flex flex-col">
{isLoading && !hasResults && (
<div className="flex items-center justify-center py-6">
<Loading />
</div>
)}
{sections.map((section, i) => (
<Fragment key={i}>
{i > 0 && <SectionDivider />}
{section}
</Fragment>
))}
</div>
<div className="border-t border-divider-subtle p-1">
<button
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left hover:bg-state-base-hover"
onClick={onShowAll}
type="button"
>
<span className="system-sm-medium text-text-accent">
{t('marketplace.searchDropdown.showAllResults', { ns: 'plugin', query })}
</span>
<span className="flex items-center">
<span className="system-2xs-medium-uppercase rounded-[5px] border border-divider-deep px-1.5 py-0.5 text-text-tertiary group-hover:hidden">
{t('marketplace.searchDropdown.enter', { ns: 'plugin' })}
</span>
<RiArrowRightLine className="hidden h-[18px] w-[18px] text-text-accent group-hover:block" />
</span>
</button>
</div>
</div>
)
}
/* ---------- Templates Section ---------- */
function TemplatesSection({ templates, locale, t }: {
templates: Template[]
locale: Locale
t: ReturnType<typeof useTranslation>['t']
}) {
return (
<DropdownSection title={t('templates', { ns: 'plugin' })}>
{templates.map((template) => {
const descriptionText = template.description[getLanguage(locale)] || template.description.en_US || ''
const iconBgStyle = template.icon_background
? { backgroundColor: template.icon_background }
: undefined
return (
<DropdownItem
key={template.template_id}
href={getMarketplaceUrl(`/templates/${template.template_id}`)}
icon={(
<div className="flex shrink-0 items-start py-1">
<IconBox shape="rounded-lg" style={iconBgStyle}>
<span className="text-xl leading-[1.2]">{template.icon || '📄'}</span>
</IconBox>
</div>
)}
>
<div className="system-md-medium truncate text-text-primary">{template.name}</div>
{!!descriptionText && (
<div className="system-xs-regular line-clamp-2 text-text-tertiary">{descriptionText}</div>
)}
<ItemMeta
items={[
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author: template.author }),
...(template.tags.length > 0
? [<span key="tags" className="system-xs-regular truncate">{template.tags.join(', ')}</span>]
: []),
]}
/>
</DropdownItem>
)
})}
</DropdownSection>
)
}
/* ---------- Plugins Section ---------- */
function PluginsSection({ plugins, getValueFromI18nObject, categoriesMap, t }: {
plugins: Plugin[]
getValueFromI18nObject: ReturnType<typeof useRenderI18nObject>
categoriesMap: Record<string, { label: string }>
t: ReturnType<typeof useTranslation>['t']
}) {
return (
<DropdownSection title={t('marketplace.searchDropdown.plugins', { ns: 'plugin' })}>
{plugins.map((plugin) => {
const title = getValueFromI18nObject(plugin.label) || plugin.name
const description = getValueFromI18nObject(plugin.brief) || ''
const categoryLabel = categoriesMap[plugin.category]?.label || plugin.category
const installLabel = t('install', { ns: 'plugin', num: plugin.install_count || 0 })
const author = plugin.org || plugin.author || ''
const TypeIcon = MARKETPLACE_TYPE_ICON_COMPONENTS[plugin.category]
const categoryNode = (
<div className="flex items-center gap-1">
{TypeIcon && <TypeIcon className="h-[14px] w-[14px] text-text-tertiary" />}
<span className="system-xs-regular">{categoryLabel}</span>
</div>
)
return (
<DropdownItem
key={`${plugin.org}/${plugin.name}`}
href={getPluginDetailLinkInMarketplace(plugin)}
icon={(
<div className="flex shrink-0 items-start py-1">
<IconBox shape="rounded-lg">
<img className="h-full w-full object-cover" src={plugin.icon} alt={title} />
</IconBox>
</div>
)}
>
<div className="system-md-medium truncate text-text-primary">{title}</div>
{!!description && (
<div className="system-xs-regular line-clamp-2 text-text-tertiary">{description}</div>
)}
<ItemMeta
items={[
categoryNode,
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author }),
installLabel,
]}
/>
</DropdownItem>
)
})}
</DropdownSection>
)
}
/* ---------- Creators Section ---------- */
function CreatorsSection({ creators, t }: {
creators: Creator[]
t: ReturnType<typeof useTranslation>['t']
}) {
return (
<DropdownSection title={t('marketplace.searchFilterCreators', { ns: 'plugin' })}>
{creators.map(creator => (
<a
key={creator.unique_handle}
className="flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-state-base-hover"
href={getMarketplaceUrl(`/creators/${creator.unique_handle}`)}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border-[0.5px] border-divider-regular">
<img
className="h-full w-full object-cover"
src={getCreatorAvatarUrl(creator.unique_handle)}
alt={creator.display_name}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-px">
<div className="system-md-medium truncate text-text-primary">{creator.display_name}</div>
<div className="system-xs-regular truncate text-text-tertiary">
@
{creator.unique_handle}
</div>
</div>
</a>
))}
</DropdownSection>
)
}
export default SearchDropdown

View File

@@ -1,2 +0,0 @@
export { default as MarketplaceTrigger } from './marketplace'
export { default as ToolSelectorTrigger } from './tool-selector'

View File

@@ -1,60 +0,0 @@
'use client'
import type { Creator } from '../types'
import { getCreatorAvatarUrl } from '../utils'
import { useTranslation } from '#i18n'
import { getMarketplaceUrl } from '@/utils/var'
type CreatorCardProps = {
creator: Creator
}
const CreatorCard = ({ creator }: CreatorCardProps) => {
const { t } = useTranslation()
const href = getMarketplaceUrl(`/creators/${creator.unique_handle}`)
const displayName = creator.display_name || creator.name
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col gap-2 rounded-xl border border-components-panel-border-subtle bg-components-panel-bg p-4 transition-colors hover:bg-state-base-hover"
>
<div className="flex items-center gap-3">
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-full border border-components-panel-border-subtle bg-background-default-dodge">
<img
src={getCreatorAvatarUrl(creator.unique_handle)}
alt={displayName}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="system-md-medium truncate text-text-primary">{displayName}</div>
<div className="system-sm-regular text-text-tertiary">
@
{creator.unique_handle}
</div>
</div>
</div>
{!!creator.description && (
<div className="system-sm-regular line-clamp-2 text-text-secondary">
{creator.description}
</div>
)}
{(creator.plugin_count !== undefined || creator.template_count !== undefined) && (
<div className="system-xs-regular text-text-tertiary">
{creator.plugin_count || 0}
{' '}
{t('plugins', { ns: 'plugin' }).toLowerCase()}
{' · '}
{creator.template_count || 0}
{' '}
{t('templates', { ns: 'plugin' }).toLowerCase()}
</div>
)}
</a>
)
}
export default CreatorCard

View File

@@ -1,266 +0,0 @@
'use client'
import type { SearchTab } from '../search-params'
import type { Creator, PluginsSearchParams, Template } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
import { useDebounce } from 'ahooks'
import { useCallback, useMemo } from 'react'
import Loading from '@/app/components/base/loading'
import SegmentedControl from '@/app/components/base/segmented-control'
import { useMarketplaceSortValue, useSearchTab, useSearchText } from '../atoms'
import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import Empty from '../empty'
import { useMarketplaceContainerScroll } from '../hooks'
import CardWrapper from '../list/card-wrapper'
import TemplateCard from '../list/template-card'
import { useMarketplaceCreators, useMarketplacePlugins, useMarketplaceTemplates } from '../query'
import SortDropdown from '../sort-dropdown'
import { getPluginFilterType, mapTemplateDetailToTemplate } from '../utils'
import CreatorCard from './creator-card'
const PAGE_SIZE = 40
const ZERO_WIDTH_SPACE = '\u200B'
type SortValue = { sortBy: string, sortOrder: string }
function mapSortForTemplates(sort: SortValue): { sort_by: string, sort_order: string } {
const sortBy = sort.sortBy === 'install_count' ? 'usage_count' : sort.sortBy === 'version_updated_at' ? 'updated_at' : sort.sortBy
return { sort_by: sortBy, sort_order: sort.sortOrder }
}
function mapSortForCreators(sort: SortValue): { sort_by: string, sort_order: string } {
const sortBy = sort.sortBy === 'install_count' ? 'created_at' : sort.sortBy === 'version_updated_at' ? 'updated_at' : sort.sortBy
return { sort_by: sortBy, sort_order: sort.sortOrder }
}
const SearchPage = () => {
const { t } = useTranslation()
const [searchText] = useSearchText()
const debouncedQuery = useDebounce(searchText, { wait: 500 })
const [searchTabParam, setSearchTab] = useSearchTab()
const searchTab = (searchTabParam || 'all') as SearchTab
const sort = useMarketplaceSortValue()
const query = debouncedQuery === ZERO_WIDTH_SPACE ? '' : debouncedQuery.trim()
const hasQuery = !!searchText && (!!query || searchText === ZERO_WIDTH_SPACE)
const pluginsParams = useMemo(() => {
if (!hasQuery)
return undefined
return {
query,
page_size: searchTab === 'all' ? 6 : PAGE_SIZE,
sort_by: sort.sortBy,
sort_order: sort.sortOrder,
type: getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.all),
} as PluginsSearchParams
}, [hasQuery, query, searchTab, sort])
const templatesParams = useMemo(() => {
if (!hasQuery)
return undefined
const { sort_by, sort_order } = mapSortForTemplates(sort)
return {
query,
page_size: searchTab === 'all' ? 6 : PAGE_SIZE,
sort_by,
sort_order,
}
}, [hasQuery, query, searchTab, sort])
const creatorsParams = useMemo(() => {
if (!hasQuery)
return undefined
const { sort_by, sort_order } = mapSortForCreators(sort)
return {
query,
page_size: searchTab === 'all' ? 6 : PAGE_SIZE,
sort_by,
sort_order,
}
}, [hasQuery, query, searchTab, sort])
const fetchPlugins = searchTab === 'all' || searchTab === 'plugins'
const fetchTemplates = searchTab === 'all' || searchTab === 'templates'
const fetchCreators = searchTab === 'all' || searchTab === 'creators'
const pluginsQuery = useMarketplacePlugins(fetchPlugins ? pluginsParams : undefined)
const templatesQuery = useMarketplaceTemplates(fetchTemplates ? templatesParams : undefined)
const creatorsQuery = useMarketplaceCreators(fetchCreators ? creatorsParams : undefined)
const plugins = pluginsQuery.data?.pages.flatMap(p => p.plugins) ?? []
const pluginsTotal = pluginsQuery.data?.pages[0]?.total ?? 0
const templates = useMemo(
() => (templatesQuery.data?.pages.flatMap(p => p.templates) ?? []).map(mapTemplateDetailToTemplate),
[templatesQuery.data],
)
const templatesTotal = templatesQuery.data?.pages[0]?.total ?? 0
const creators = creatorsQuery.data?.pages.flatMap(p => p.creators) ?? []
const creatorsTotal = creatorsQuery.data?.pages[0]?.total ?? 0
const handleScrollLoadMore = useCallback(() => {
if (searchTab === 'plugins' && pluginsQuery.hasNextPage && !pluginsQuery.isFetching)
pluginsQuery.fetchNextPage()
else if (searchTab === 'templates' && templatesQuery.hasNextPage && !templatesQuery.isFetching)
templatesQuery.fetchNextPage()
else if (searchTab === 'creators' && creatorsQuery.hasNextPage && !creatorsQuery.isFetching)
creatorsQuery.fetchNextPage()
}, [searchTab, pluginsQuery, templatesQuery, creatorsQuery])
useMarketplaceContainerScroll(handleScrollLoadMore)
const tabOptions = [
{ value: 'all', text: t('marketplace.searchFilterAll', { ns: 'plugin' }), count: pluginsTotal + templatesTotal + creatorsTotal },
{ value: 'templates', text: t('templates', { ns: 'plugin' }), count: templatesTotal },
{ value: 'plugins', text: t('plugins', { ns: 'plugin' }), count: pluginsTotal },
{ value: 'creators', text: t('marketplace.searchFilterCreators', { ns: 'plugin' }), count: creatorsTotal },
]
const isLoading = (fetchPlugins && pluginsQuery.isLoading)
|| (fetchTemplates && templatesQuery.isLoading)
|| (fetchCreators && creatorsQuery.isLoading)
const isFetchingNextPage = pluginsQuery.isFetchingNextPage
|| templatesQuery.isFetchingNextPage
|| creatorsQuery.isFetchingNextPage
const renderPluginsSection = (items: Plugin[], limit?: number) => {
const toShow = limit ? items.slice(0, limit) : items
if (toShow.length === 0)
return null
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{toShow.map(plugin => (
<CardWrapper key={`${plugin.org}/${plugin.name}`} plugin={plugin} showInstallButton={false} />
))}
</div>
)
}
const renderTemplatesSection = (items: Template[], limit?: number) => {
const toShow = limit ? items.slice(0, limit) : items
if (toShow.length === 0)
return null
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{toShow.map(template => (
<div key={template.template_id}>
<TemplateCard template={template} />
</div>
))}
</div>
)
}
const renderCreatorsSection = (items: Creator[], limit?: number) => {
const toShow = limit ? items.slice(0, limit) : items
if (toShow.length === 0)
return null
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{toShow.map(creator => (
<CreatorCard key={creator.unique_handle} creator={creator} />
))}
</div>
)
}
const renderAllTab = () => (
<div className="flex flex-col gap-8 py-4">
{templates.length > 0 && (
<section>
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
{t('templates', { ns: 'plugin' })}
</h3>
{renderTemplatesSection(templates, 6)}
</section>
)}
{plugins.length > 0 && (
<section>
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
{t('plugins', { ns: 'plugin' })}
</h3>
{renderPluginsSection(plugins, 6)}
</section>
)}
{creators.length > 0 && (
<section>
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
{t('marketplace.searchFilterCreators', { ns: 'plugin' })}
</h3>
{renderCreatorsSection(creators, 6)}
</section>
)}
{!isLoading && plugins.length === 0 && templates.length === 0 && creators.length === 0 && (
<Empty />
)}
</div>
)
const renderPluginsTab = () => {
if (plugins.length === 0 && !pluginsQuery.isLoading)
return <Empty />
return (
<div className="py-4">
{renderPluginsSection(plugins)}
</div>
)
}
const renderTemplatesTab = () => {
if (templates.length === 0 && !templatesQuery.isLoading)
return <Empty />
return (
<div className="py-4">
{renderTemplatesSection(templates)}
</div>
)
}
const renderCreatorsTab = () => {
if (creators.length === 0 && !creatorsQuery.isLoading)
return <Empty />
return (
<div className="py-4">
{renderCreatorsSection(creators)}
</div>
)
}
return (
<div
style={{ scrollbarGutter: 'stable' }}
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
>
<div className="mb-4 flex items-center justify-between pt-3">
<SegmentedControl
size="large"
activeState="accentLight"
value={searchTab}
onChange={v => setSearchTab(v as SearchTab)}
options={tabOptions}
/>
<SortDropdown />
</div>
{isLoading && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Loading />
</div>
)}
{!isLoading && (
<>
{searchTab === 'all' && renderAllTab()}
{searchTab === 'plugins' && renderPluginsTab()}
{searchTab === 'templates' && renderTemplatesTab()}
{searchTab === 'creators' && renderCreatorsTab()}
</>
)}
{isFetchingNextPage && <Loading className="my-3" />}
</div>
)
}
export default SearchPage

View File

@@ -1,18 +1,9 @@
import type { ActivePluginType } from './constants'
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
export const CREATION_TYPE = {
plugins: 'plugins',
templates: 'templates',
} as const
export type CreationType = typeof CREATION_TYPE[keyof typeof CREATION_TYPE]
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
export const marketplaceSearchParamsParsers = {
category: parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
category: parseAsStringEnum<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
creationType: parseAsStringEnum<CreationType>([CREATION_TYPE.plugins, CREATION_TYPE.templates]).withDefault(CREATION_TYPE.plugins).withOptions({ history: 'replace' }),
searchTab: parseAsStringEnum<SearchTab>(['all', 'plugins', 'templates', 'creators']).withDefault('').withOptions({ history: 'replace' }),
}
export type SearchTab = 'all' | 'plugins' | 'templates' | 'creators' | ''

View File

@@ -1,34 +0,0 @@
'use client'
import { useTranslation } from '#i18n'
import { useSearchText } from './atoms'
type SearchResultsHeaderProps = {
marketplaceNav?: React.ReactNode
}
const SearchResultsHeader = ({ marketplaceNav }: SearchResultsHeaderProps) => {
const { t } = useTranslation('plugin')
const [searchText] = useSearchText()
return (
<div className="relative px-7 py-4">
{marketplaceNav}
<div className="system-xs-regular mt-8 flex items-center gap-1 px-5 text-text-tertiary ">
<span>{t('marketplace.searchBreadcrumbMarketplace')}</span>
<span className="text-text-quaternary">/</span>
<span>{t('marketplace.searchBreadcrumbSearch')}</span>
</div>
<div className="mt-2 flex items-end gap-2 px-5 ">
<div className="title-4xl-semi-bold text-text-primary">
{t('marketplace.searchResultsFor')}
</div>
<div className="title-4xl-semi-bold relative text-saas-dify-blue-accessible">
<span className="relative z-10">{searchText || ''}</span>
<span className="absolute bottom-0 left-0 right-0 h-3 bg-saas-dify-blue-accessible opacity-10" />
</div>
</div>
</div>
)
}
export default SearchResultsHeader

View File

@@ -1,32 +1,20 @@
import type { PluginsSearchParams, TemplateSearchParams } from './types'
import type { PluginsSearchParams } from './types'
import { useDebounce } from 'ahooks'
import { useCallback, useMemo } from 'react'
import { useActivePluginCategory, useActiveTemplateCategory, useCreationType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchText } from './atoms'
import { CATEGORY_ALL } from './constants'
import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms'
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
import { useMarketplaceContainerScroll } from './hooks'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query'
import { CREATION_TYPE } from './search-params'
import { getCollectionsParams, getPluginFilterType, mapTemplateDetailToTemplate } from './utils'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query'
import { getCollectionsParams, getMarketplaceListFilterType } from './utils'
const getCategory = (category: string) => {
if (category === CATEGORY_ALL)
return undefined
return category
}
/**
* Hook for plugins marketplace data
* Only fetches plugins-related data
*/
export function usePluginsMarketplaceData(enabled = true) {
const [searchTextOriginal] = useSearchText()
const searchText = useDebounce(searchTextOriginal, { wait: 500 })
export function useMarketplaceData() {
const [searchPluginTextOriginal] = useSearchPluginText()
const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 })
const [filterPluginTags] = useFilterPluginTags()
const [activePluginCategory] = useActivePluginCategory()
const [activePluginType] = useActivePluginType()
const pluginsCollectionsQuery = useMarketplaceCollectionsAndPlugins(
getCollectionsParams(activePluginCategory),
{ enabled },
const collectionsQuery = useMarketplaceCollectionsAndPlugins(
getCollectionsParams(activePluginType),
)
const sort = useMarketplaceSortValue()
@@ -35,16 +23,16 @@ export function usePluginsMarketplaceData(enabled = true) {
if (!isSearchMode)
return undefined
return {
query: searchText,
category: getCategory(activePluginCategory),
query: searchPluginText,
category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType,
tags: filterPluginTags,
sort_by: sort.sortBy,
sort_order: sort.sortOrder,
type: getPluginFilterType(activePluginCategory),
type: getMarketplaceListFilterType(activePluginType),
}
}, [isSearchMode, searchText, activePluginCategory, filterPluginTags, sort])
}, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort])
const pluginsQuery = useMarketplacePlugins(queryParams, { enabled })
const pluginsQuery = useMarketplacePlugins(queryParams)
const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = pluginsQuery
const handlePageChange = useCallback(() => {
@@ -56,87 +44,12 @@ export function usePluginsMarketplaceData(enabled = true) {
useMarketplaceContainerScroll(handlePageChange)
return {
pluginCollections: pluginsCollectionsQuery.data?.marketplaceCollections,
pluginCollectionPluginsMap: pluginsCollectionsQuery.data?.marketplaceCollectionPluginsMap,
marketplaceCollections: collectionsQuery.data?.marketplaceCollections,
marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap,
plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins),
pluginsTotal: pluginsQuery.data?.pages[0]?.total,
page: pluginsQuery.data?.pages.length || 1,
isLoading: pluginsCollectionsQuery.isLoading || pluginsQuery.isLoading,
isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading,
isFetchingNextPage,
}
}
/**
* Hook for templates marketplace data
* Only fetches templates-related data
*/
export function useTemplatesMarketplaceData(enabled = true) {
// Reuse existing atoms for search and sort
const [searchTextOriginal] = useSearchText()
const searchText = useDebounce(searchTextOriginal, { wait: 500 })
const [activeTemplateCategory] = useActiveTemplateCategory()
// Template collections query (for non-search mode)
const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates(undefined, { enabled })
// Sort value
const sort = useMarketplaceSortValue()
// Search mode: when there's search text or non-default category
const isSearchMode = useMarketplaceSearchMode()
// Build query params for search mode
const queryParams = useMemo((): TemplateSearchParams | undefined => {
if (!isSearchMode)
return undefined
return {
query: searchText,
categories: activeTemplateCategory === CATEGORY_ALL ? undefined : [activeTemplateCategory],
sort_by: sort.sortBy,
sort_order: sort.sortOrder,
}
}, [isSearchMode, searchText, activeTemplateCategory, sort])
// Templates search query (for search mode)
const templatesQuery = useMarketplaceTemplates(queryParams, { enabled })
const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = templatesQuery
// Pagination handler
const handlePageChange = useCallback(() => {
if (hasNextPage && !isFetching)
fetchNextPage()
}, [fetchNextPage, hasNextPage, isFetching])
// Scroll pagination
useMarketplaceContainerScroll(handlePageChange)
return {
templateCollections: templateCollectionsQuery.data?.templateCollections,
templateCollectionTemplatesMap: templateCollectionsQuery.data?.templateCollectionTemplatesMap,
templates: templatesQuery.data?.pages.flatMap(page => page.templates).map(mapTemplateDetailToTemplate),
templatesTotal: templatesQuery.data?.pages[0]?.total,
page: templatesQuery.data?.pages.length || 1,
isLoading: templateCollectionsQuery.isLoading || templatesQuery.isLoading,
isFetchingNextPage,
}
}
export type PluginsMarketplaceData = ReturnType<typeof usePluginsMarketplaceData>
export type TemplatesMarketplaceData = ReturnType<typeof useTemplatesMarketplaceData>
export type MarketplaceData = PluginsMarketplaceData | TemplatesMarketplaceData
export function isPluginsData(data: MarketplaceData): data is PluginsMarketplaceData {
return 'pluginCollections' in data
}
/**
* Main hook that routes to appropriate data based on creationType
* Returns either plugins or templates data based on URL parameter
*/
export function useMarketplaceData(): MarketplaceData {
const [creationType] = useCreationType()
const pluginsData = usePluginsMarketplaceData(creationType === CREATION_TYPE.plugins)
const templatesData = useTemplatesMarketplaceData(creationType === CREATION_TYPE.templates)
return creationType === CREATION_TYPE.templates ? templatesData : pluginsData
}

View File

@@ -0,0 +1,30 @@
'use client'
import { cn } from '@/utils/classnames'
import PluginTypeSwitch from './plugin-type-switch'
import SearchBoxWrapper from './search-box/search-box-wrapper'
type StickySearchAndSwitchWrapperProps = {
pluginTypeSwitchClassName?: string
}
const StickySearchAndSwitchWrapper = ({
pluginTypeSwitchClassName,
}: StickySearchAndSwitchWrapperProps) => {
const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-')
return (
<div
className={cn(
'mt-4 bg-background-body',
hasCustomTopClass && 'sticky z-10',
pluginTypeSwitchClassName,
)}
>
<SearchBoxWrapper />
<PluginTypeSwitch />
</div>
)
}
export default StickySearchAndSwitchWrapper

View File

@@ -6,7 +6,7 @@ export type SearchParamsFromCollection = {
sort_order?: string
}
export type PluginCollection = {
export type MarketplaceCollection = {
name: string
label: Record<string, string>
description: Record<string, string>
@@ -18,7 +18,7 @@ export type PluginCollection = {
}
export type MarketplaceCollectionsResponse = {
collections: PluginCollection[]
collections: MarketplaceCollection[]
total: number
}
@@ -56,182 +56,4 @@ export type SearchParams = {
q?: string
tags?: string
category?: string
creationType?: string
}
export type TemplateCollection = {
id: string
name: string
label: Record<string, string>
description: Record<string, string>
conditions: string[]
searchable: boolean
search_params?: SearchParamsFromCollection
created_at?: string
updated_at?: string
}
export type Template = {
template_id: string
name: string
description: Record<string, string>
icon: string
icon_background?: string
tags: string[]
author: string
created_at: string
updated_at: string
}
export type CreateTemplateCollectionRequest = {
name: string
description: Record<string, string>
label: Record<string, string>
conditions: string[]
searchable: boolean
search_params: SearchParamsFromCollection
}
export type GetCollectionTemplatesRequest = {
categories?: string[]
exclude?: string[]
limit?: number
}
export type AddTemplateToCollectionRequest = {
template_id: string
}
export type BatchAddTemplatesToCollectionRequest = {
template_id: string
}[]
// Creator types
export type Creator = {
email: string
name: string
display_name: string
unique_handle: string
display_email: string
description: string
avatar: string
social_links: string[]
status: 'active' | 'inactive'
public?: boolean
plugin_count?: number
template_count?: number
created_at: string
updated_at: string
}
export type CreatorSearchParams = {
query?: string
page?: number
page_size?: number
categories?: string[]
sort_by?: string
sort_order?: string
}
export type CreatorSearchResponse = {
creators: Creator[]
total: number
}
export type SyncCreatorProfileRequest = {
email: string
name?: string
display_name?: string
unique_handle: string
display_email?: string
description?: string
avatar?: string
social_links?: string[]
status?: 'active' | 'inactive'
}
// Template Detail (full template info from API)
export type TemplateDetail = {
id: string
publisher_type: 'individual' | 'organization'
publisher_unique_handle: string
creator_email: string
template_name: string
icon: string
icon_background: string
icon_file_key: string
dsl_file_key: string
categories: string[]
overview: string
readme: string
partner_link: string
status: 'published' | 'draft' | 'pending' | 'rejected'
review_comment: string
created_at: string
updated_at: string
}
export type TemplatesListResponse = {
templates: TemplateDetail[]
total: number
}
export type TemplateSearchParams = {
query?: string
page?: number
page_size?: number
categories?: string[]
sort_by?: string
sort_order?: string
languages?: string[]
}
// Unified search types
export type UnifiedSearchScope = 'creators' | 'organizations' | 'plugins' | 'templates'
export type UnifiedSearchParams = {
query: string
scope?: UnifiedSearchScope[]
page?: number
page_size?: number
}
// Plugin item shape from /search/unified (superset of Plugin with index_id)
export type UnifiedPluginItem = Plugin & {
index_id: string
}
// Template item shape from /search/unified (differs from TemplateDetail)
export type UnifiedTemplateItem = {
id: string
index_id: string
template_name: string
icon: string
icon_background?: string
icon_file_key: string
categories: string[]
overview: string
readme: string
partner_link: string
publisher_handle: string
publisher_type: 'individual' | 'organization'
status: string
usage_count: number
created_at: string
updated_at: string
}
// Creator item shape from /search/unified (superset of Creator with index_id)
export type UnifiedCreatorItem = Creator & {
index_id: string
}
export type UnifiedSearchResponse = {
data: {
creators: { items: UnifiedCreatorItem[], total: number }
organizations: { items: unknown[], total: number }
plugins: { items: UnifiedPluginItem[], total: number }
templates: { items: UnifiedTemplateItem[], total: number }
}
}

View File

@@ -1,19 +1,8 @@
import type { ActivePluginType } from './constants'
import type {
CollectionsAndPluginsSearchParams,
Creator,
CreatorSearchParams,
PluginCollection,
MarketplaceCollection,
PluginsSearchParams,
Template,
TemplateCollection,
TemplateDetail,
TemplateSearchParams,
UnifiedCreatorItem,
UnifiedPluginItem,
UnifiedSearchParams,
UnifiedSearchResponse,
UnifiedTemplateItem,
} from '@/app/components/plugins/marketplace/types'
import type { Plugin } from '@/app/components/plugins/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
@@ -28,21 +17,12 @@ type MarketplaceFetchOptions = {
signal?: AbortSignal
}
/** Get a string key from an item by field name (e.g. plugin_id, template_id). */
export function getItemKeyByField<T>(item: T, field: keyof T): string {
return String((item as Record<string, unknown>)[field as string])
}
export const getPluginIconInMarketplace = (plugin: Plugin) => {
if (plugin.type === 'bundle')
return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon`
return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`
}
export const getCreatorAvatarUrl = (uniqueHandle: string) => {
return `${MARKETPLACE_API_PREFIX}/creators/${uniqueHandle}/avatar`
}
export const getFormattedPlugin = (bundle: Plugin): Plugin => {
if (bundle.type === 'bundle') {
return {
@@ -79,7 +59,7 @@ export const getMarketplacePluginsByCollectionId = async (
let plugins: Plugin[] = []
try {
const marketplaceCollectionPluginsDataJson = await marketplaceClient.plugins.collectionPlugins({
const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins({
params: {
collectionId,
},
@@ -101,10 +81,10 @@ export const getMarketplaceCollectionsAndPlugins = async (
query?: CollectionsAndPluginsSearchParams,
options?: MarketplaceFetchOptions,
) => {
let pluginCollections: PluginCollection[] = []
let pluginCollectionPluginsMap: Record<string, Plugin[]> = {}
let marketplaceCollections: MarketplaceCollection[] = []
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
try {
const collectionsDataJson = await marketplaceClient.plugins.collections({
const marketplaceCollectionsDataJson = await marketplaceClient.collections({
query: {
...query,
page: 1,
@@ -113,84 +93,22 @@ export const getMarketplaceCollectionsAndPlugins = async (
}, {
signal: options?.signal,
})
pluginCollections = collectionsDataJson.data?.collections || []
await Promise.all(pluginCollections.map(async (collection: PluginCollection) => {
marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || []
await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => {
const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options)
pluginCollectionPluginsMap[collection.name] = plugins
marketplaceCollectionPluginsMap[collection.name] = plugins
}))
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
pluginCollections = []
pluginCollectionPluginsMap = {}
marketplaceCollections = []
marketplaceCollectionPluginsMap = {}
}
return {
marketplaceCollections: pluginCollections,
marketplaceCollectionPluginsMap: pluginCollectionPluginsMap,
}
}
export function mapTemplateDetailToTemplate(template: TemplateDetail): Template {
const descriptionText = template.overview || template.readme || ''
return {
template_id: template.id,
name: template.template_name,
description: {
en_US: descriptionText,
zh_Hans: descriptionText,
},
icon: template.icon || '',
icon_background: template.icon_background || undefined,
tags: template.categories || [],
author: template.publisher_unique_handle || template.creator_email || '',
created_at: template.created_at,
updated_at: template.updated_at,
}
}
export const getMarketplaceTemplateCollectionsAndTemplates = async (
query?: { page?: number, page_size?: number, condition?: string },
options?: MarketplaceFetchOptions,
) => {
let templateCollections: TemplateCollection[] = []
let templateCollectionTemplatesMap: Record<string, Template[]> = {}
try {
const res = await marketplaceClient.templateCollections.list({
query: {
...query,
page: 1,
page_size: 100,
},
}, {
signal: options?.signal,
})
templateCollections = res.data?.collections || []
await Promise.all(templateCollections.map(async (collection) => {
try {
const templatesRes = await marketplaceClient.templateCollections.getTemplates({
params: { collectionName: collection.name },
body: { limit: 20 },
}, { signal: options?.signal })
const templatesData = templatesRes.data?.templates || []
templateCollectionTemplatesMap[collection.name] = templatesData.map(mapTemplateDetailToTemplate)
}
catch {
templateCollectionTemplatesMap[collection.name] = []
}
}))
}
catch {
templateCollections = []
templateCollectionTemplatesMap = {}
}
return {
templateCollections,
templateCollectionTemplatesMap,
marketplaceCollections,
marketplaceCollectionPluginsMap,
}
}
@@ -219,7 +137,7 @@ export const getMarketplacePlugins = async (
} = queryParams
try {
const res = await marketplaceClient.plugins.searchAdvanced({
const res = await marketplaceClient.searchAdvanced({
params: {
kind: type === 'bundle' ? 'bundles' : 'plugins',
},
@@ -252,7 +170,7 @@ export const getMarketplacePlugins = async (
}
}
export const getPluginCondition = (pluginType: string) => {
export const getMarketplaceListCondition = (pluginType: string) => {
if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum))
return `category=${pluginType}`
@@ -265,7 +183,7 @@ export const getPluginCondition = (pluginType: string) => {
return ''
}
export const getPluginFilterType = (category: ActivePluginType) => {
export const getMarketplaceListFilterType = (category: ActivePluginType) => {
if (category === PLUGIN_TYPE_SEARCH_MAP.all)
return undefined
@@ -281,255 +199,7 @@ export function getCollectionsParams(category: ActivePluginType): CollectionsAnd
}
return {
category,
condition: getPluginCondition(category),
type: getPluginFilterType(category),
}
}
export const getMarketplaceTemplates = async (
queryParams: TemplateSearchParams | undefined,
pageParam: number,
signal?: AbortSignal,
): Promise<{
templates: TemplateDetail[]
total: number
page: number
page_size: number
}> => {
if (!queryParams) {
return {
templates: [] as TemplateDetail[],
total: 0,
page: 1,
page_size: 40,
}
}
const {
query,
sort_by,
sort_order,
categories,
languages,
page_size = 40,
} = queryParams
try {
const res = await marketplaceClient.templates.searchAdvanced({
body: {
page: pageParam,
page_size,
query,
sort_by,
sort_order,
categories,
languages,
},
}, { signal })
return {
templates: res.data?.templates || [],
total: res.data?.total || 0,
page: pageParam,
page_size,
}
}
catch {
return {
templates: [],
total: 0,
page: pageParam,
page_size,
}
}
}
export const getMarketplaceCreators = async (
queryParams: CreatorSearchParams | undefined,
pageParam: number,
signal?: AbortSignal,
): Promise<{
creators: Creator[]
total: number
page: number
page_size: number
}> => {
if (!queryParams) {
return {
creators: [],
total: 0,
page: 1,
page_size: 40,
}
}
const {
query,
sort_by,
sort_order,
categories,
page_size = 40,
} = queryParams
try {
const res = await marketplaceClient.creators.searchAdvanced({
body: {
page: pageParam,
page_size,
query,
sort_by,
sort_order,
categories,
},
}, { signal })
const creators = (res.data?.creators || []).map((c: Creator) => ({
...c,
display_name: c.display_name || c.name,
display_email: c.display_email ?? '',
social_links: c.social_links ?? [],
}))
return {
creators,
total: res.data?.total || 0,
page: pageParam,
page_size,
}
}
catch {
return {
creators: [],
total: 0,
page: pageParam,
page_size,
}
}
}
/**
* Map unified search plugin item to Plugin type
*/
export function mapUnifiedPluginToPlugin(item: UnifiedPluginItem): Plugin {
return {
type: item.type,
org: item.org,
name: item.name,
plugin_id: item.plugin_id,
version: item.latest_version,
latest_version: item.latest_version,
latest_package_identifier: item.latest_package_identifier,
icon: `${MARKETPLACE_API_PREFIX}/plugins/${item.org}/${item.name}/icon`,
verified: item.verification?.authorized_category === 'langgenius',
label: item.label,
brief: item.brief,
description: item.brief,
introduction: '',
repository: item.repository || '',
category: item.category as PluginCategoryEnum,
install_count: item.install_count,
endpoint: { settings: [] },
tags: item.tags || [],
badges: item.badges || [],
verification: item.verification,
from: 'marketplace',
}
}
/**
* Map unified search template item to Template type
*/
export function mapUnifiedTemplateToTemplate(item: UnifiedTemplateItem): Template {
const descriptionText = item.overview || item.readme || ''
return {
template_id: item.id,
name: item.template_name,
description: {
en_US: descriptionText,
zh_Hans: descriptionText,
},
icon: item.icon || '',
icon_background: item.icon_background || undefined,
tags: item.categories || [],
author: item.publisher_handle || '',
created_at: item.created_at,
updated_at: item.updated_at,
}
}
/**
* Map unified search creator item to Creator type
*/
export function mapUnifiedCreatorToCreator(item: UnifiedCreatorItem): Creator {
return {
email: item.email || '',
name: item.name || '',
display_name: item.display_name || item.name || '',
unique_handle: item.unique_handle || '',
display_email: '',
description: item.description || '',
avatar: item.avatar || '',
social_links: [],
status: item.status || 'active',
plugin_count: item.plugin_count,
template_count: item.template_count,
created_at: '',
updated_at: '',
}
}
/**
* Fetch unified search results
*/
export const getMarketplaceUnifiedSearch = async (
queryParams: UnifiedSearchParams | undefined,
signal?: AbortSignal,
): Promise<UnifiedSearchResponse['data'] & { page: number, page_size: number }> => {
if (!queryParams || !queryParams.query.trim()) {
return {
creators: { items: [], total: 0 },
organizations: { items: [], total: 0 },
plugins: { items: [], total: 0 },
templates: { items: [], total: 0 },
page: 1,
page_size: queryParams?.page_size || 10,
}
}
const {
query,
scope,
page = 1,
page_size = 10,
} = queryParams
try {
const res = await marketplaceClient.searchUnified({
body: {
query,
scope,
page,
page_size,
},
}, { signal })
return {
creators: res.data?.creators || { items: [], total: 0 },
organizations: res.data?.organizations || { items: [], total: 0 },
plugins: res.data?.plugins || { items: [], total: 0 },
templates: res.data?.templates || { items: [], total: 0 },
page,
page_size,
}
}
catch {
return {
creators: { items: [], total: 0 },
organizations: { items: [], total: 0 },
plugins: { items: [], total: 0 },
templates: { items: [], total: 0 },
page,
page_size,
}
condition: getMarketplaceListCondition(category),
type: getMarketplaceListFilterType(category),
}
}

View File

@@ -2,11 +2,13 @@
import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../types'
import {
RiBookOpenLine,
RiDragDropLine,
RiEqualizer2Line,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@@ -15,22 +17,22 @@ import Tooltip from '@/app/components/base/tooltip'
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePluginInstallation } from '@/hooks/use-query-params'
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
import { sleep } from '@/utils'
import { cn } from '@/utils/classnames'
import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
import InstallFromLocalPackage from '../install-plugin/install-from-local-package'
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants'
import SearchBoxWrapper from '../marketplace/search-box/search-box-wrapper'
import {
PluginPageContextProvider,
usePluginPageContext,
} from './context'
import DebugInfo from './debug-info'
import InstallPluginDropdown from './install-plugin-dropdown'
import { SubmitRequestDropdown } from './nav-operations'
import PluginTasks from './plugin-tasks'
import useReferenceSetting from './use-reference-setting'
import { useUploader } from './use-uploader'
@@ -44,6 +46,7 @@ const PluginPage = ({
marketplace,
}: PluginPageProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
useDocumentTitle(t('metadata.title', { ns: 'plugin' }))
// Use nuqs hook for installation state
@@ -137,20 +140,55 @@ const PluginPage = ({
id="marketplace-container"
ref={containerRef}
style={{ scrollbarGutter: 'stable' }}
className="relative flex grow flex-col overflow-y-auto rounded-t-xl border-t border-divider-subtle bg-components-panel-bg"
className={cn('relative flex grow flex-col overflow-y-auto border-t border-divider-subtle', isPluginsTab
? 'rounded-t-xl bg-components-panel-bg'
: 'bg-background-body')}
>
<div className="sticky top-0 z-10 flex min-h-[60px] items-center gap-1 self-stretch bg-components-panel-bg px-12 pb-2 pt-4">
<div
className={cn(
'sticky top-0 z-10 flex min-h-[60px] items-center gap-1 self-stretch bg-components-panel-bg px-12 pb-2 pt-4',
isExploringMarketplace && 'bg-background-body',
)}
>
<div className="flex w-full items-center justify-between">
<div className="flex flex-1 items-center justify-start gap-2">
<div className="flex-1">
<TabSlider
value={isPluginsTab ? PLUGIN_PAGE_TABS_MAP.plugins : PLUGIN_PAGE_TABS_MAP.marketplace}
onChange={setActiveTab}
options={options}
/>
{!isPluginsTab && <SearchBoxWrapper />}
</div>
<div className="flex shrink-0 items-center gap-1">
{isExploringMarketplace && <SubmitRequestDropdown />}
{
isExploringMarketplace && (
<>
<Link
href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml"
target="_blank"
>
<Button
variant="ghost"
className="text-text-tertiary"
>
{t('requestAPlugin', { ns: 'plugin' })}
</Button>
</Link>
<Link
href={docLink('/develop-plugin/publishing/marketplace-listing/release-to-dify-marketplace')}
target="_blank"
>
<Button
className="px-3"
variant="secondary-accent"
>
<RiBookOpenLine className="mr-1 h-4 w-4" />
{t('publishPlugins', { ns: 'plugin' })}
</Button>
</Link>
<div className="mx-1 h-3.5 w-[1px] shrink-0 bg-divider-regular"></div>
</>
)
}
<PluginTasks />
{canManagement && (
<InstallPluginDropdown

View File

@@ -1,121 +0,0 @@
'use client'
import { RiAddLine, RiBookOpenLine, RiGithubLine } from '@remixicon/react'
import Link from 'next/link'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Button, { buttonVariants } from '@/app/components/base/button'
import { Playground, Plugin } from '@/app/components/base/icons/src/vender/plugin'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { CREATION_TYPE } from '@/app/components/plugins/marketplace/search-params'
import { useDocLink } from '@/context/i18n'
import { cn } from '@/utils/classnames'
import { useCreationType } from '../marketplace/atoms'
type DropdownItemProps = {
href: string
icon: React.ReactNode
text: string
onClick: () => void
}
const DropdownItem = ({ href, icon, text, onClick }: DropdownItemProps) => (
<Link
href={href}
target="_blank"
className="flex items-center gap-2 rounded-lg px-3 py-2 text-text-secondary hover:bg-state-base-hover hover:text-text-primary"
onClick={onClick}
>
{icon}
<span className="system-sm-medium">{text}</span>
</Link>
)
export const SubmitRequestDropdown = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const docLink = useDocLink()
return (
<PortalToFollowElem
placement="bottom-start"
offset={4}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Button
variant="ghost"
className={cn(
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
open && 'bg-state-base-hover text-text-secondary',
)}
>
<RiAddLine className="h-4 w-4 shrink-0 lg:hidden" />
<span className="system-sm-medium hidden lg:inline">
{t('requestSubmitPlugin', { ns: 'plugin' })}
</span>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<div className="min-w-[200px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm">
<DropdownItem
href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml"
icon={<RiGithubLine className="h-4 w-4 shrink-0" />}
text={t('requestAPlugin', { ns: 'plugin' })}
onClick={() => setOpen(false)}
/>
<DropdownItem
href={docLink('/develop-plugin/publishing/marketplace-listing/release-to-dify-marketplace')}
icon={<RiBookOpenLine className="h-4 w-4 shrink-0" />}
text={t('publishPlugins', { ns: 'plugin' })}
onClick={() => setOpen(false)}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export const CreationTypeTabs = () => {
const { t } = useTranslation()
const [creationType] = useCreationType()
return (
<div className="flex items-center gap-1">
<Link
href={`/?creationType=${CREATION_TYPE.plugins}`}
className={cn(
buttonVariants({ variant: 'ghost' }),
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
creationType === CREATION_TYPE.plugins && 'bg-state-base-hover text-text-secondary',
)}
>
<Plugin className="h-4 w-4 shrink-0" />
<span className="system-sm-medium hidden md:inline">
{t('plugins', { ns: 'plugin' })}
</span>
</Link>
<Link
href={`/?creationType=${CREATION_TYPE.templates}`}
className={cn(
buttonVariants({ variant: 'ghost' }),
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
creationType === CREATION_TYPE.templates && 'bg-state-base-hover text-text-secondary',
)}
>
<Playground className="h-4 w-4 shrink-0" />
<span className="system-sm-medium hidden md:inline">
{t('templates', { ns: 'plugin' })}
</span>
<Badge className="ml-1 hidden h-4 rounded-[4px] border-none bg-saas-dify-blue-accessible px-1 text-[10px] font-bold leading-[14px] text-text-primary-on-surface md:inline-flex">
NEW
</Badge>
</Link>
</div>
)
}

View File

@@ -9,11 +9,11 @@ import {
useMarketplaceCollectionsAndPlugins,
useMarketplacePlugins,
} from '@/app/components/plugins/marketplace/hooks'
import { getPluginCondition } from '@/app/components/plugins/marketplace/utils'
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useAllToolProviders } from '@/service/use-tools'
export const useMarketplace = (searchText: string, filterPluginTags: string[]) => {
export const useMarketplace = (searchPluginText: string, filterPluginTags: string[]) => {
const { data: toolProvidersData, isSuccess } = useAllToolProviders()
const exclude = useMemo(() => {
if (isSuccess)
@@ -21,8 +21,8 @@ export const useMarketplace = (searchText: string, filterPluginTags: string[]) =
}, [isSuccess, toolProvidersData])
const {
isLoading,
pluginCollections,
pluginCollectionPluginsMap,
marketplaceCollections,
marketplaceCollectionPluginsMap,
queryMarketplaceCollectionsAndPlugins,
} = useMarketplaceCollectionsAndPlugins()
const {
@@ -35,19 +35,19 @@ export const useMarketplace = (searchText: string, filterPluginTags: string[]) =
hasNextPage,
page: pluginsPage,
} = useMarketplacePlugins()
const searchTextRef = useRef(searchText)
const searchPluginTextRef = useRef(searchPluginText)
const filterPluginTagsRef = useRef(filterPluginTags)
useEffect(() => {
searchTextRef.current = searchText
searchPluginTextRef.current = searchPluginText
filterPluginTagsRef.current = filterPluginTags
}, [searchText, filterPluginTags])
}, [searchPluginText, filterPluginTags])
useEffect(() => {
if ((searchText || filterPluginTags.length) && isSuccess) {
if (searchText) {
if ((searchPluginText || filterPluginTags.length) && isSuccess) {
if (searchPluginText) {
queryPluginsWithDebounced({
category: PluginCategoryEnum.tool,
query: searchText,
query: searchPluginText,
tags: filterPluginTags,
exclude,
type: 'plugin',
@@ -56,7 +56,7 @@ export const useMarketplace = (searchText: string, filterPluginTags: string[]) =
}
queryPlugins({
category: PluginCategoryEnum.tool,
query: searchText,
query: searchPluginText,
tags: filterPluginTags,
exclude,
type: 'plugin',
@@ -66,14 +66,14 @@ export const useMarketplace = (searchText: string, filterPluginTags: string[]) =
if (isSuccess) {
queryMarketplaceCollectionsAndPlugins({
category: PluginCategoryEnum.tool,
condition: getPluginCondition(PluginCategoryEnum.tool),
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
exclude,
type: 'plugin',
})
resetPlugins()
}
}
}, [searchText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess])
}, [searchPluginText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess])
const handleScroll = useCallback((e: Event) => {
const target = e.target as HTMLDivElement
@@ -83,17 +83,17 @@ export const useMarketplace = (searchText: string, filterPluginTags: string[]) =
clientHeight,
} = target
if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) {
const searchText = searchTextRef.current
const searchPluginText = searchPluginTextRef.current
const filterPluginTags = filterPluginTagsRef.current
if (hasNextPage && (!!searchText || !!filterPluginTags.length))
if (hasNextPage && (!!searchPluginText || !!filterPluginTags.length))
fetchNextPage()
}
}, [exclude, fetchNextPage, hasNextPage, plugins, queryPlugins])
return {
isLoading: isLoading || isPluginsLoading,
pluginCollections,
pluginCollectionPluginsMap,
marketplaceCollections,
marketplaceCollectionPluginsMap,
plugins,
handleScroll,
page: Math.max(pluginsPage || 0, 1),

View File

@@ -4,7 +4,7 @@ import { act, render, renderHook, screen, waitFor } from '@testing-library/react
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
import { getPluginCondition } from '@/app/components/plugins/marketplace/utils'
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { getMarketplaceUrl } from '@/utils/var'
@@ -15,8 +15,8 @@ import Marketplace from './index'
const listRenderSpy = vi.fn()
vi.mock('@/app/components/plugins/marketplace/list', () => ({
default: (props: {
pluginCollections: unknown[]
pluginCollectionPluginsMap: Record<string, unknown[]>
marketplaceCollections: unknown[]
marketplaceCollectionPluginsMap: Record<string, unknown[]>
plugins?: unknown[]
showInstallButton?: boolean
}) => {
@@ -90,8 +90,8 @@ const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
const createMarketplaceContext = (overrides: Partial<ReturnType<typeof useMarketplace>> = {}) => ({
isLoading: false,
pluginCollections: [],
pluginCollectionPluginsMap: {},
marketplaceCollections: [],
marketplaceCollectionPluginsMap: {},
plugins: [],
handleScroll: vi.fn(),
page: 1,
@@ -110,7 +110,7 @@ describe('Marketplace', () => {
const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 })
render(
<Marketplace
searchText=""
searchPluginText=""
filterPluginTags={[]}
isMarketplaceArrowVisible={false}
showMarketplacePanel={vi.fn()}
@@ -131,7 +131,7 @@ describe('Marketplace', () => {
})
render(
<Marketplace
searchText=""
searchPluginText=""
filterPluginTags={[]}
isMarketplaceArrowVisible={false}
showMarketplacePanel={vi.fn()}
@@ -156,7 +156,7 @@ describe('Marketplace', () => {
const showMarketplacePanel = vi.fn()
const { container } = render(
<Marketplace
searchText="vector"
searchPluginText="vector"
filterPluginTags={['tag-a', 'tag-b']}
isMarketplaceArrowVisible
showMarketplacePanel={showMarketplacePanel}
@@ -199,8 +199,8 @@ describe('useMarketplace', () => {
}) => {
mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({
isLoading: overrides?.isLoading ?? false,
pluginCollections: [],
pluginCollectionPluginsMap: {},
marketplaceCollections: [],
marketplaceCollectionPluginsMap: {},
queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins,
})
mockUseMarketplacePlugins.mockReturnValue({
@@ -289,7 +289,7 @@ describe('useMarketplace', () => {
await waitFor(() => {
expect(mockQueryMarketplaceCollectionsAndPlugins).toHaveBeenCalledWith({
category: PluginCategoryEnum.tool,
condition: getPluginCondition(PluginCategoryEnum.tool),
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
exclude: ['plugin-c'],
type: 'plugin',
})

View File

@@ -11,14 +11,14 @@ import List from '@/app/components/plugins/marketplace/list'
import { getMarketplaceUrl } from '@/utils/var'
type MarketplaceProps = {
searchText: string
searchPluginText: string
filterPluginTags: string[]
isMarketplaceArrowVisible: boolean
showMarketplacePanel: () => void
marketplaceContext: ReturnType<typeof useMarketplace>
}
const Marketplace = ({
searchText,
searchPluginText,
filterPluginTags,
isMarketplaceArrowVisible,
showMarketplacePanel,
@@ -29,8 +29,8 @@ const Marketplace = ({
const { theme } = useTheme()
const {
isLoading,
pluginCollections,
pluginCollectionPluginsMap,
marketplaceCollections,
marketplaceCollectionPluginsMap,
plugins,
page,
} = marketplaceContext
@@ -79,7 +79,7 @@ const Marketplace = ({
</span>
{t('operation.in', { ns: 'common' })}
<a
href={getMarketplaceUrl('', { language: locale, q: searchText, tags: filterPluginTags.join(','), theme })}
href={getMarketplaceUrl('', { language: locale, q: searchPluginText, tags: filterPluginTags.join(','), theme })}
className="system-sm-medium ml-1 flex items-center text-text-accent"
target="_blank"
>
@@ -100,8 +100,8 @@ const Marketplace = ({
{
(!isLoading || page > 1) && (
<List
pluginCollections={pluginCollections || []}
pluginCollectionPluginsMap={pluginCollectionPluginsMap || {}}
marketplaceCollections={marketplaceCollections || []}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
plugins={plugins}
showInstallButton
/>

View File

@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import Card from '@/app/components/plugins/card'
import CardTags from '@/app/components/plugins/card/card-tags'
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import { useTags } from '@/app/components/plugins/hooks'
import Empty from '@/app/components/plugins/marketplace/empty'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
@@ -183,7 +183,7 @@ const ProviderList = () => {
name: collection.plugin_id ? collection.plugin_id.split('/')[1] : collection.name,
} as any}
footer={(
<CardTags
<CardMoreInfo
tags={collection.labels?.map(label => getTagLabel(label)) || []}
/>
)}
@@ -199,7 +199,7 @@ const ProviderList = () => {
<div ref={toolListTailRef} />
{enable_marketplace && activeTab === 'builtin' && (
<Marketplace
searchText={keywords}
searchPluginText={keywords}
filterPluginTags={tagFilterValue}
isMarketplaceArrowVisible={isMarketplaceArrowVisible}
showMarketplacePanel={showMarketplacePanel}

View File

@@ -1,27 +1,9 @@
import type {
AddTemplateToCollectionRequest,
BatchAddTemplatesToCollectionRequest,
CollectionsAndPluginsSearchParams,
CreateTemplateCollectionRequest,
Creator,
CreatorSearchParams,
CreatorSearchResponse,
GetCollectionTemplatesRequest,
PluginCollection,
PluginsSearchParams,
SyncCreatorProfileRequest,
TemplateCollection,
TemplateDetail,
TemplateSearchParams,
TemplatesListResponse,
UnifiedSearchParams,
UnifiedSearchResponse,
} from '@/app/components/plugins/marketplace/types'
import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, PluginsSearchParams } from '@/app/components/plugins/marketplace/types'
import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
import { type } from '@orpc/contract'
import { base } from './base'
export const pluginCollectionsContract = base
export const collectionsContract = base
.route({
path: '/collections',
method: 'GET',
@@ -34,7 +16,7 @@ export const pluginCollectionsContract = base
.output(
type<{
data?: {
collections?: PluginCollection[]
collections?: MarketplaceCollection[]
}
}>(),
)
@@ -72,356 +54,3 @@ export const searchAdvancedContract = base
body: Omit<PluginsSearchParams, 'type'>
}>())
.output(type<{ data: PluginsFromMarketplaceResponse }>())
export const templateCollectionsContract = base
.route({
path: '/template-collections',
method: 'GET',
})
.input(
type<{
query?: {
page?: number
page_size?: number
condition?: string
}
}>(),
)
.output(
type<{
data?: {
collections?: TemplateCollection[]
has_more?: boolean
limit?: number
page?: number
total?: number
}
}>(),
)
export const createTemplateCollectionContract = base
.route({
path: '/template-collections',
method: 'POST',
})
.input(
type<{
body: CreateTemplateCollectionRequest
}>(),
)
.output(type<TemplateCollection>())
export const getTemplateCollectionContract = base
.route({
path: '/template-collections/{collectionName}',
method: 'GET',
})
.input(
type<{
params: {
collectionName: string
}
}>(),
)
.output(type<TemplateCollection>())
export const deleteTemplateCollectionContract = base
.route({
path: '/template-collections/{collectionName}',
method: 'DELETE',
})
.input(
type<{
params: {
collectionName: string
}
}>(),
)
.output(type<void>())
export const getCollectionTemplatesContract = base
.route({
path: '/template-collections/{collectionName}/templates',
method: 'POST',
})
.input(
type<{
params: {
collectionName: string
}
body?: GetCollectionTemplatesRequest
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)
export const addTemplateToCollectionContract = base
.route({
path: '/template-collections/{collectionName}/templates',
method: 'PUT',
})
.input(
type<{
params: {
collectionName: string
}
body: AddTemplateToCollectionRequest
}>(),
)
.output(type<void>())
export const batchAddTemplatesToCollectionContract = base
.route({
path: '/template-collections/{collectionName}/templates/batch-add',
method: 'POST',
})
.input(
type<{
params: {
collectionName: string
}
body: BatchAddTemplatesToCollectionRequest
}>(),
)
.output(type<void>())
export const clearCollectionTemplatesContract = base
.route({
path: '/template-collections/{collectionName}/templates/clear',
method: 'PUT',
})
.input(
type<{
params: {
collectionName: string
}
}>(),
)
.output(type<void>())
// Creators contracts
export const getCreatorByHandleContract = base
.route({
path: '/creators/{uniqueHandle}',
method: 'GET',
})
.input(
type<{
params: {
uniqueHandle: string
}
}>(),
)
.output(
type<{
data?: {
creator?: Creator
}
}>(),
)
export const getCreatorAvatarContract = base
.route({
path: '/creators/{uniqueHandle}/avatar',
method: 'GET',
})
.input(
type<{
params: {
uniqueHandle: string
}
}>(),
)
.output(type<Blob>())
export const syncCreatorProfileContract = base
.route({
path: '/creators/sync/profile',
method: 'POST',
})
.input(
type<{
body: SyncCreatorProfileRequest
}>(),
)
.output(
type<{
data?: {
creator?: Creator
}
}>(),
)
export const syncCreatorAvatarContract = base
.route({
path: '/creators/sync/avatar',
method: 'POST',
})
.input(
type<{
body: FormData
}>(),
)
.output(type<void>())
export const searchCreatorsAdvancedContract = base
.route({
path: '/creators/search/advanced',
method: 'POST',
})
.input(
type<{
body: CreatorSearchParams
}>(),
)
.output(
type<{
data?: CreatorSearchResponse
}>(),
)
// Templates public endpoints
export const getTemplatesListContract = base
.route({
path: '/templates',
method: 'GET',
})
.input(
type<{
query?: {
page?: number
page_size?: number
categories?: string
}
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)
export const getTemplateByIdContract = base
.route({
path: '/templates/{templateId}',
method: 'GET',
})
.input(
type<{
params: {
templateId: string
}
}>(),
)
.output(
type<{
data?: TemplateDetail
}>(),
)
export const getTemplateDslFileContract = base
.route({
path: '/templates/{templateId}/file',
method: 'GET',
})
.input(
type<{
params: {
templateId: string
}
}>(),
)
.output(type<Blob>())
export const searchTemplatesBasicContract = base
.route({
path: '/templates/search/basic',
method: 'POST',
})
.input(
type<{
body: TemplateSearchParams
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)
export const searchTemplatesAdvancedContract = base
.route({
path: '/templates/search/advanced',
method: 'POST',
})
.input(
type<{
body: TemplateSearchParams
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)
export const searchUnifiedContract = base
.route({
path: '/search/unified',
method: 'POST',
})
.input(
type<{
body: UnifiedSearchParams
}>(),
)
.output(
type<UnifiedSearchResponse>(),
)
export const getPublisherTemplatesContract = base
.route({
path: '/templates/publisher/{uniqueHandle}',
method: 'GET',
})
.input(
type<{
params: {
uniqueHandle: string
}
query?: {
page?: number
page_size?: number
}
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)
export const getPublisherPluginsContract = base
.route({
path: '/plugins/publisher/{uniqueHandle}',
method: 'GET',
})
.input(
type<{
params: {
uniqueHandle: string
}
query?: {
page?: number
page_size?: number
}
}>(),
)
.output(
type<{
data?: PluginsFromMarketplaceResponse
}>(),
)

View File

@@ -19,66 +19,12 @@ import {
triggerSubscriptionVerifyContract,
} from './console/trigger'
import { trialAppDatasetsContract, trialAppInfoContract, trialAppParametersContract, trialAppWorkflowsContract } from './console/try-app'
import {
addTemplateToCollectionContract,
batchAddTemplatesToCollectionContract,
clearCollectionTemplatesContract,
collectionPluginsContract,
createTemplateCollectionContract,
deleteTemplateCollectionContract,
getCollectionTemplatesContract,
getCreatorAvatarContract,
getCreatorByHandleContract,
getPublisherPluginsContract,
getPublisherTemplatesContract,
getTemplateByIdContract,
getTemplateCollectionContract,
getTemplateDslFileContract,
getTemplatesListContract,
pluginCollectionsContract,
searchAdvancedContract,
searchCreatorsAdvancedContract,
searchTemplatesAdvancedContract,
searchTemplatesBasicContract,
searchUnifiedContract,
syncCreatorAvatarContract,
syncCreatorProfileContract,
templateCollectionsContract,
} from './marketplace'
import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace'
export const marketplaceRouterContract = {
plugins: {
collections: pluginCollectionsContract,
collectionPlugins: collectionPluginsContract,
searchAdvanced: searchAdvancedContract,
getPublisherPlugins: getPublisherPluginsContract,
},
searchUnified: searchUnifiedContract,
templateCollections: {
list: templateCollectionsContract,
create: createTemplateCollectionContract,
get: getTemplateCollectionContract,
delete: deleteTemplateCollectionContract,
getTemplates: getCollectionTemplatesContract,
addTemplate: addTemplateToCollectionContract,
batchAddTemplates: batchAddTemplatesToCollectionContract,
clearTemplates: clearCollectionTemplatesContract,
},
creators: {
getByHandle: getCreatorByHandleContract,
getAvatar: getCreatorAvatarContract,
syncProfile: syncCreatorProfileContract,
syncAvatar: syncCreatorAvatarContract,
searchAdvanced: searchCreatorsAdvancedContract,
},
templates: {
list: getTemplatesListContract,
getById: getTemplateByIdContract,
getDslFile: getTemplateDslFileContract,
searchBasic: searchTemplatesBasicContract,
searchAdvanced: searchTemplatesAdvancedContract,
getPublisherTemplates: getPublisherTemplatesContract,
},
collections: collectionsContract,
collectionPlugins: collectionPluginsContract,
searchAdvanced: searchAdvancedContract,
}
export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract>

View File

@@ -1,7 +1,7 @@
'use client'
import type { Resource } from 'i18next'
import type { Locale } from '.'
import type { NamespaceCamelCase, NamespaceKebabCase } from './resources'
import type { Namespace, NamespaceInFileName } from './resources'
import { kebabCase } from 'es-toolkit/string'
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
@@ -14,7 +14,7 @@ export function createI18nextInstance(lng: Locale, resources: Resource) {
.use(initReactI18next)
.use(resourcesToBackend((
language: Locale,
namespace: NamespaceKebabCase | NamespaceCamelCase,
namespace: NamespaceInFileName | Namespace,
) => {
const namespaceKebab = kebabCase(namespace)
return import(`../i18n/${language}/${namespaceKebab}.json`)

View File

@@ -1,9 +1,9 @@
'use client'
import type { NamespaceCamelCase } from './resources'
import type { Namespace } from './resources'
import { useTranslation as useTranslationOriginal } from 'react-i18next'
export function useTranslation(ns?: NamespaceCamelCase) {
export function useTranslation<T extends Namespace | undefined = undefined>(ns?: T) {
return useTranslationOriginal(ns)
}

View File

@@ -1,13 +1,13 @@
import type { NamespaceCamelCase } from './resources'
import type { Namespace } from './resources'
import { use } from 'react'
import { getLocaleOnServer, getTranslation } from './server'
async function getI18nConfig(ns?: NamespaceCamelCase) {
async function getI18nConfig<T extends Namespace | undefined = undefined>(ns?: T) {
const lang = await getLocaleOnServer()
return getTranslation(lang, ns)
}
export function useTranslation(ns?: NamespaceCamelCase) {
export function useTranslation<T extends Namespace | undefined = undefined>(ns?: T) {
return use(getI18nConfig(ns))
}

View File

@@ -1,4 +1,5 @@
import { kebabCase } from 'es-toolkit/string'
import { kebabCase } from 'string-ts'
import { ObjectKeys } from '@/utils/object'
import appAnnotation from '../i18n/en-US/app-annotation.json'
import appApi from '../i18n/en-US/app-api.json'
import appDebug from '../i18n/en-US/app-debug.json'
@@ -64,19 +65,10 @@ const resources = {
workflow,
}
export type KebabCase<S extends string> = S extends `${infer T}${infer U}`
? T extends Lowercase<T>
? `${T}${KebabCase<U>}`
: `-${Lowercase<T>}${KebabCase<U>}`
: S
export type CamelCase<S extends string> = S extends `${infer T}-${infer U}`
? `${T}${Capitalize<CamelCase<U>>}`
: S
export type Resources = typeof resources
export type NamespaceCamelCase = keyof Resources
export type NamespaceKebabCase = KebabCase<NamespaceCamelCase>
export const namespacesCamelCase = Object.keys(resources) as NamespaceCamelCase[]
export const namespacesKebabCase = namespacesCamelCase.map(ns => kebabCase(ns)) as NamespaceKebabCase[]
export const namespaces = ObjectKeys(resources)
export type Namespace = typeof namespaces[number]
export const namespacesInFileName = namespaces.map(ns => kebabCase(ns))
export type NamespaceInFileName = typeof namespacesInFileName[number]

View File

@@ -1,6 +1,6 @@
import type { i18n as I18nInstance, Resource, ResourceLanguage } from 'i18next'
import type { Locale } from '.'
import type { NamespaceCamelCase, NamespaceKebabCase } from './resources'
import type { Namespace, NamespaceInFileName } from './resources'
import { match } from '@formatjs/intl-localematcher'
import { kebabCase } from 'es-toolkit/compat'
import { camelCase } from 'es-toolkit/string'
@@ -12,7 +12,7 @@ import { cache } from 'react'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { serverOnlyContext } from '@/utils/server-only-context'
import { i18n } from '.'
import { namespacesKebabCase } from './resources'
import { namespacesInFileName } from './resources'
import { getInitOptions } from './settings'
const [getLocaleCache, setLocaleCache] = serverOnlyContext<Locale | null>(null)
@@ -26,8 +26,8 @@ const getOrCreateI18next = async (lng: Locale) => {
instance = createInstance()
await instance
.use(initReactI18next)
.use(resourcesToBackend((language: Locale, namespace: NamespaceCamelCase | NamespaceKebabCase) => {
const fileNamespace = kebabCase(namespace) as NamespaceKebabCase
.use(resourcesToBackend((language: Locale, namespace: Namespace | NamespaceInFileName) => {
const fileNamespace = kebabCase(namespace)
return import(`../i18n/${language}/${fileNamespace}.json`)
}))
.init({
@@ -38,7 +38,7 @@ const getOrCreateI18next = async (lng: Locale) => {
return instance
}
export async function getTranslation(lng: Locale, ns?: NamespaceCamelCase) {
export async function getTranslation<T extends Namespace>(lng: Locale, ns?: T) {
const i18nextInstance = await getOrCreateI18next(lng)
if (ns && !i18nextInstance.hasLoadedNamespace(ns))
@@ -84,7 +84,7 @@ export const getResources = cache(async (lng: Locale): Promise<Resource> => {
const messages = {} as ResourceLanguage
await Promise.all(
(namespacesKebabCase).map(async (ns) => {
(namespacesInFileName).map(async (ns) => {
const mod = await import(`../i18n/${lng}/${ns}.json`)
messages[camelCase(ns)] = mod.default
}),

View File

@@ -1,5 +1,5 @@
import type { InitOptions } from 'i18next'
import { namespacesCamelCase } from './resources'
import { namespaces } from './resources'
export function getInitOptions(): InitOptions {
return {
@@ -8,7 +8,7 @@ export function getInitOptions(): InitOptions {
fallbackLng: 'en-US',
partialBundledLanguages: true,
keySeparator: false,
ns: namespacesCamelCase,
ns: namespaces,
interpolation: {
escapeValue: false,
},

View File

@@ -65,7 +65,6 @@
"autoUpdate.upgradeModePlaceholder.partial": "Only selected plugins will auto-update. No plugins are currently selected, so no plugins will auto-update.",
"category.agents": "Agent Strategies",
"category.all": "All",
"category.allTypes": "All types",
"category.bundles": "Bundles",
"category.datasources": "Data Sources",
"category.extensions": "Extensions",
@@ -195,42 +194,15 @@
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Discover",
"marketplace.empower": "Empower your AI development",
"marketplace.featured": "Featured",
"marketplace.installs": "installs",
"marketplace.moreFrom": "More from Marketplace",
"marketplace.noPluginFound": "No plugin found",
"marketplace.ourTopPicks": "Our top picks to get you started",
"marketplace.partnerTip": "Verified by a Dify partner",
"marketplace.pluginsHeroSubtitle": "Use community-built plugins to power your AI development.",
"marketplace.pluginsHeroTitle": "Discover. Extend. Build.",
"marketplace.pluginsResult": "{{num}} results",
"marketplace.searchBreadcrumbMarketplace": "Marketplace",
"marketplace.searchBreadcrumbSearch": "Search",
"marketplace.searchDropdown.byAuthor": "by {{author}}",
"marketplace.searchDropdown.enter": "Enter",
"marketplace.searchDropdown.plugins": "Plugins",
"marketplace.searchDropdown.showAllResults": "Show all search results",
"marketplace.searchFilterAll": "All",
"marketplace.searchFilterCreators": "Creators",
"marketplace.searchFilterPlugins": "Plugins",
"marketplace.searchFilterTags": "Tags",
"marketplace.searchFilterTypes": "Types",
"marketplace.searchResultsFor": "Results for",
"marketplace.sortBy": "Sort by",
"marketplace.sortOption.firstReleased": "First Released",
"marketplace.sortOption.mostPopular": "Most Popular",
"marketplace.sortOption.newlyReleased": "Newly Released",
"marketplace.sortOption.recentlyUpdated": "Recently Updated",
"marketplace.templatesHeroSubtitle": "Community-built workflow templates — ready to use, remix, and deploy.",
"marketplace.templatesHeroTitle": "Create. Remix. Deploy.",
"marketplace.templateCategory.all": "All",
"marketplace.templateCategory.marketing": "Marketing",
"marketplace.templateCategory.sales": "Sales",
"marketplace.templateCategory.support": "Support",
"marketplace.templateCategory.operations": "Operations",
"marketplace.templateCategory.it": "IT",
"marketplace.templateCategory.knowledge": "Knowledge",
"marketplace.templateCategory.design": "Design",
"marketplace.verifiedTip": "Verified by Dify",
"marketplace.viewMore": "View more",
"metadata.title": "Plugins",
@@ -238,7 +210,6 @@
"pluginInfoModal.release": "Release",
"pluginInfoModal.repository": "Repository",
"pluginInfoModal.title": "Plugin info",
"plugins": "Plugins",
"privilege.admins": "Admins",
"privilege.everyone": "Everyone",
"privilege.noone": "No one",
@@ -251,7 +222,6 @@
"readmeInfo.noReadmeAvailable": "No README available",
"readmeInfo.title": "README",
"requestAPlugin": "Request a plugin",
"requestSubmitPlugin": "Request / Submit",
"search": "Search",
"searchCategories": "Search Categories",
"searchInMarketplace": "Search in Marketplace",
@@ -271,7 +241,6 @@
"task.installingWithSuccess": "Installing {{installingLength}} plugins, {{successLength}} success.",
"task.runningPlugins": "Installing Plugins",
"task.successPlugins": "Successfully Installed Plugins",
"templates": "Templates",
"upgrade.close": "Close",
"upgrade.description": "About to install the following plugin",
"upgrade.successfulTitle": "Install successful",

View File

@@ -65,7 +65,6 @@
"autoUpdate.upgradeModePlaceholder.partial": "仅选定的插件将自动更新。目前未选择任何插件,因此不会自动更新任何插件。",
"category.agents": "Agent 策略",
"category.all": "全部",
"category.allTypes": "所有类型",
"category.bundles": "插件集",
"category.datasources": "数据源",
"category.extensions": "扩展",
@@ -195,42 +194,15 @@
"marketplace.difyMarketplace": "Dify 市场",
"marketplace.discover": "探索",
"marketplace.empower": "助力您的 AI 开发",
"marketplace.featured": "精选",
"marketplace.installs": "次安装",
"marketplace.moreFrom": "更多来自市场",
"marketplace.noPluginFound": "未找到插件",
"marketplace.ourTopPicks": "我们精选推荐",
"marketplace.partnerTip": "此插件由 Dify 合作伙伴认证",
"marketplace.pluginsHeroSubtitle": "使用社区构建的插件为您的 AI 开发提供动力。",
"marketplace.pluginsHeroTitle": "探索。扩展。构建。",
"marketplace.pluginsResult": "{{num}} 个插件结果",
"marketplace.searchBreadcrumbMarketplace": "市场",
"marketplace.searchBreadcrumbSearch": "搜索",
"marketplace.searchDropdown.byAuthor": "由 {{author}} 提供",
"marketplace.searchDropdown.enter": "输入",
"marketplace.searchDropdown.plugins": "插件",
"marketplace.searchDropdown.showAllResults": "显示所有搜索结果",
"marketplace.searchFilterAll": "全部",
"marketplace.searchFilterCreators": "创作者",
"marketplace.searchFilterPlugins": "插件",
"marketplace.searchFilterTags": "标签",
"marketplace.searchFilterTypes": "类型",
"marketplace.searchResultsFor": "搜索结果",
"marketplace.sortBy": "排序方式",
"marketplace.sortOption.firstReleased": "首次发布",
"marketplace.sortOption.mostPopular": "最受欢迎",
"marketplace.sortOption.newlyReleased": "最新发布",
"marketplace.sortOption.recentlyUpdated": "最近更新",
"marketplace.templatesHeroSubtitle": "社区构建的工作流模板 —— 随时可使用、复刻和部署。",
"marketplace.templatesHeroTitle": "创建。复刻。部署。",
"marketplace.templateCategory.all": "全部",
"marketplace.templateCategory.marketing": "营销",
"marketplace.templateCategory.sales": "销售",
"marketplace.templateCategory.support": "支持",
"marketplace.templateCategory.operations": "运营",
"marketplace.templateCategory.it": "IT",
"marketplace.templateCategory.knowledge": "知识",
"marketplace.templateCategory.design": "设计",
"marketplace.verifiedTip": "此插件由 Dify 认证",
"marketplace.viewMore": "查看更多",
"metadata.title": "插件",
@@ -238,7 +210,6 @@
"pluginInfoModal.release": "发布版本",
"pluginInfoModal.repository": "仓库",
"pluginInfoModal.title": "插件信息",
"plugins": "插件",
"privilege.admins": "管理员",
"privilege.everyone": "所有人",
"privilege.noone": "无人",
@@ -251,7 +222,6 @@
"readmeInfo.noReadmeAvailable": "README 文档不可用",
"readmeInfo.title": "README",
"requestAPlugin": "申请插件",
"requestSubmitPlugin": "申请并发布插件",
"search": "搜索",
"searchCategories": "搜索类别",
"searchInMarketplace": "在 Marketplace 中搜索",
@@ -271,7 +241,6 @@
"task.installingWithSuccess": "{{installingLength}} 个插件安装中,{{successLength}} 安装成功",
"task.runningPlugins": "正在安装的插件",
"task.successPlugins": "安装成功的插件",
"templates": "模板",
"upgrade.close": "关闭",
"upgrade.description": "即将安装以下插件",
"upgrade.successfulTitle": "安装成功",

View File

@@ -121,7 +121,6 @@
"mermaid": "11.11.0",
"mime": "4.1.0",
"mitt": "3.0.1",
"motion": "12.31.0",
"negotiator": "1.0.0",
"next": "16.1.5",
"next-themes": "0.4.6",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

View File

@@ -1,27 +0,0 @@
<svg width="1416" height="200" viewBox="0 0 1416 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_n_21362_44659)">
<rect width="1416" height="200" fill="url(#paint0_linear_21362_44659)"/>
</g>
<defs>
<filter id="filter0_n_21362_44659" x="0" y="0" width="1416" height="200" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="0.83333331346511841 0.83333331346511841" stitchTiles="stitch" numOctaves="3" result="noise" seed="3192" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(0, 0, 0, 0.18)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_21362_44659">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<linearGradient id="paint0_linear_21362_44659" x1="708" y1="0" x2="708" y2="200" gradientUnits="userSpaceOnUse">
<stop stop-opacity="0.3"/>
<stop offset="0.661631" stop-color="#0033FF" stop-opacity="0.3"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

7
web/types/i18n.d.ts vendored
View File

@@ -1,17 +1,16 @@
import type { NamespaceCamelCase, Resources } from '../i18n-config/resources'
import type { Namespace, Resources } from '../i18n-config/resources'
import 'i18next'
declare module 'i18next' {
// eslint-disable-next-line ts/consistent-type-definitions
interface CustomTypeOptions {
defaultNS: 'common'
resources: Resources
keySeparator: false
}
}
export type I18nKeysByPrefix<
NS extends NamespaceCamelCase,
NS extends Namespace,
Prefix extends string = '',
> = Prefix extends ''
? keyof Resources[NS]
@@ -22,7 +21,7 @@ export type I18nKeysByPrefix<
: never
export type I18nKeysWithPrefix<
NS extends NamespaceCamelCase,
NS extends Namespace,
Prefix extends string = '',
> = Prefix extends ''
? keyof Resources[NS]

7
web/utils/object.ts Normal file
View File

@@ -0,0 +1,7 @@
export function ObjectFromEntries<const T extends ReadonlyArray<readonly [PropertyKey, unknown]>>(entries: T): { [K in T[number]as K[0]]: K[1] } {
return Object.fromEntries(entries) as { [K in T[number]as K[0]]: K[1] }
}
export function ObjectKeys<const T extends Record<string, unknown>>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[]
}

View File

@@ -1,88 +0,0 @@
import type { Viewport } from 'reactflow'
import type { Edge, Node } from '@/app/components/workflow/types'
import { load as yamlLoad } from 'js-yaml'
type GraphPayload = {
nodes?: Node[]
edges?: Edge[]
viewport?: Viewport
}
type DslPayload = {
workflow?: {
graph?: GraphPayload
}
graph?: GraphPayload
} | null
export type ParsedGraph = {
nodes: Node[]
edges: Edge[]
viewport: Viewport
} | null
export const parseGraphFromDsl = (dslContent: string): ParsedGraph => {
if (!dslContent)
return null
try {
const data = yamlLoad(dslContent) as DslPayload
const graph = data?.workflow?.graph ?? data?.graph
if (!graph || !graph.nodes || !graph.edges)
return null
return {
nodes: graph.nodes || [],
edges: graph.edges || [],
viewport: graph.viewport || { x: 0, y: 0, zoom: 0.5 },
}
}
catch {
return null
}
}
type UsedCountFormatOptions = {
precision?: number
rounding?: 'round' | 'floor'
}
export const formatUsedCount = (count?: number, options: UsedCountFormatOptions = {}) => {
if (!count)
return null
if (count < 1000)
return String(count)
const precision = options.precision ?? 1
const rounding = options.rounding ?? 'round'
const base = count / 1000
const factor = 10 ** precision
const rounded = rounding === 'floor'
? Math.floor(base * factor) / factor
: Math.round(base * factor) / factor
const display = precision <= 0
? String(rounded)
: (rounded % 1 === 0 ? String(rounded) : rounded.toFixed(precision))
return `${display}k`
}
type TranslationFn = (key: string, options?: Record<string, unknown>) => string
export const formatRelativeTime = (dateStr: string, t: TranslationFn) => {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays < 1)
return t('detail.today')
if (diffDays < 7)
return t('detail.daysAgo', { count: diffDays })
if (diffDays < 30)
return t('detail.weeksAgo', { count: Math.floor(diffDays / 7) })
if (diffDays < 365)
return t('detail.monthsAgo', { count: Math.floor(diffDays / 30) })
return t('detail.yearsAgo', { count: Math.floor(diffDays / 365) })
}