mirror of
https://github.com/langgenius/dify.git
synced 2026-02-13 23:05:21 +08:00
Compare commits
48 Commits
refactor/t
...
feat/marke
| Author | SHA1 | Date | |
|---|---|---|---|
| 0645eaeef9 | |||
| f49e8954d0 | |||
| a6fd5994ad | |||
| c3f52f8fa0 | |||
| b9ddd7047c | |||
| 600c373ef2 | |||
| 90c734cc93 | |||
| 5f827be44f | |||
| eff202834c | |||
| 0f4b578462 | |||
| e4f5c8f710 | |||
| 7ca4b7f3f9 | |||
| b1722ba53c | |||
| 594516da25 | |||
| 2cc86ae8cd | |||
| 27933ed4ae | |||
| ad1ebd9bbc | |||
| 791289dcf5 | |||
| 5d5a842f37 | |||
| 2d3e244a1f | |||
| 0db446b8ea | |||
| 8108c21d5b | |||
| 26fe8c1cc5 | |||
| 1f724d0f33 | |||
| 8e788714e4 | |||
| 5f6f9ed517 | |||
| 7b41fc4d64 | |||
| 36f42ec0a9 | |||
| 2b62862467 | |||
| 72e92be0cb | |||
| 4961242a8a | |||
| f348258a45 | |||
| 6ef87550e6 | |||
| 5c6da34539 | |||
| 9f8289b185 | |||
| 56c5739e4e | |||
| b241122cf7 | |||
| 984992d0fd | |||
| b9cd625c53 | |||
| f96fbbe03a | |||
| 9a698eaad9 | |||
| 6329647f3b | |||
| 063459599c | |||
| a59023f75b | |||
| 41c1d981a1 | |||
| 3f5037f911 | |||
| cbbb05c189 | |||
| 1ce8c43e2c |
@ -34,7 +34,6 @@ from .dataset import (
|
||||
metadata,
|
||||
segment,
|
||||
)
|
||||
from .dataset.rag_pipeline import rag_pipeline_workflow
|
||||
from .end_user import end_user
|
||||
from .workspace import models
|
||||
|
||||
@ -54,7 +53,6 @@ __all__ = [
|
||||
"message",
|
||||
"metadata",
|
||||
"models",
|
||||
"rag_pipeline_workflow",
|
||||
"segment",
|
||||
"site",
|
||||
"workflow",
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import string
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
@ -10,7 +12,6 @@ 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
|
||||
@ -40,7 +41,7 @@ register_schema_model(service_api_ns, DatasourceNodeRunPayload)
|
||||
register_schema_model(service_api_ns, PipelineRunApiEntity)
|
||||
|
||||
|
||||
@service_api_ns.route("/datasets/<uuid:dataset_id>/pipeline/datasource-plugins")
|
||||
@service_api_ns.route(f"/datasets/{uuid:dataset_id}/pipeline/datasource-plugins")
|
||||
class DatasourcePluginsApi(DatasetApiResource):
|
||||
"""Resource for datasource plugins."""
|
||||
|
||||
@ -75,7 +76,7 @@ class DatasourcePluginsApi(DatasetApiResource):
|
||||
return datasource_plugins, 200
|
||||
|
||||
|
||||
@service_api_ns.route("/datasets/<uuid:dataset_id>/pipeline/datasource/nodes/<string:node_id>/run")
|
||||
@service_api_ns.route(f"/datasets/{uuid:dataset_id}/pipeline/datasource/nodes/{string:node_id}/run")
|
||||
class DatasourceNodeRunApi(DatasetApiResource):
|
||||
"""Resource for datasource node run."""
|
||||
|
||||
@ -130,7 +131,7 @@ class DatasourceNodeRunApi(DatasetApiResource):
|
||||
)
|
||||
|
||||
|
||||
@service_api_ns.route("/datasets/<uuid:dataset_id>/pipeline/run")
|
||||
@service_api_ns.route(f"/datasets/{uuid:dataset_id}/pipeline/run")
|
||||
class PipelineRunApi(DatasetApiResource):
|
||||
"""Resource for datasource node run."""
|
||||
|
||||
@ -231,4 +232,12 @@ class KnowledgebasePipelineFileUploadApi(DatasetApiResource):
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
|
||||
return serialize_upload_file(upload_file), 201
|
||||
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
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
@ -217,8 +217,6 @@ 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
|
||||
@ -255,18 +253,12 @@ 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,
|
||||
Dataset.tenant_id == api_token.tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
dataset = db.session.query(Dataset).where(Dataset.id == dataset_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)
|
||||
|
||||
@ -1329,24 +1329,10 @@ class RagPipelineService:
|
||||
"""
|
||||
Get datasource plugins
|
||||
"""
|
||||
dataset: Dataset | None = (
|
||||
db.session.query(Dataset)
|
||||
.where(
|
||||
Dataset.id == dataset_id,
|
||||
Dataset.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
dataset: Dataset | None = db.session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
if not dataset:
|
||||
raise ValueError("Dataset not found")
|
||||
pipeline: Pipeline | None = (
|
||||
db.session.query(Pipeline)
|
||||
.where(
|
||||
Pipeline.id == dataset.pipeline_id,
|
||||
Pipeline.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
pipeline: Pipeline | None = db.session.query(Pipeline).where(Pipeline.id == dataset.pipeline_id).first()
|
||||
if not pipeline:
|
||||
raise ValueError("Pipeline not found")
|
||||
|
||||
@ -1427,24 +1413,10 @@ class RagPipelineService:
|
||||
"""
|
||||
Get pipeline
|
||||
"""
|
||||
dataset: Dataset | None = (
|
||||
db.session.query(Dataset)
|
||||
.where(
|
||||
Dataset.id == dataset_id,
|
||||
Dataset.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
dataset: Dataset | None = db.session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
if not dataset:
|
||||
raise ValueError("Dataset not found")
|
||||
pipeline: Pipeline | None = (
|
||||
db.session.query(Pipeline)
|
||||
.where(
|
||||
Pipeline.id == dataset.pipeline_id,
|
||||
Pipeline.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
pipeline: Pipeline | None = db.session.query(Pipeline).where(Pipeline.id == dataset.pipeline_id).first()
|
||||
if not pipeline:
|
||||
raise ValueError("Pipeline not found")
|
||||
return pipeline
|
||||
|
||||
@ -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:
|
||||
dataset = session.query(Dataset).where(Dataset.id == dataset_id).first()
|
||||
try:
|
||||
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()
|
||||
total_index_node_ids.extend([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()
|
||||
index_node_ids = [segment.index_node_id for segment in segments]
|
||||
|
||||
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
|
||||
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, 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",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Cleaned document when import form notion document deleted failed")
|
||||
|
||||
@ -27,7 +27,6 @@ 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()
|
||||
@ -36,120 +35,94 @@ 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":
|
||||
logger.info(click.style(f"Document {document_id} is not a notion_import, skipping", fg="yellow"))
|
||||
return
|
||||
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 (
|
||||
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")
|
||||
# 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",
|
||||
)
|
||||
|
||||
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:
|
||||
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,
|
||||
)
|
||||
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=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=document.tenant_id,
|
||||
)
|
||||
|
||||
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
|
||||
last_edited_time = loader.get_notion_last_edited_time()
|
||||
|
||||
logger.info(click.style(f"Document {document_id} content changed, starting sync", fg="green"))
|
||||
# check the page is updated
|
||||
if last_edited_time != page_edited_time:
|
||||
document.indexing_status = "parsing"
|
||||
document.processing_started_at = naive_utc_now()
|
||||
|
||||
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)
|
||||
# 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()
|
||||
|
||||
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
|
||||
segments = session.scalars(
|
||||
select(DocumentSegment).where(DocumentSegment.document_id == document_id)
|
||||
).all()
|
||||
index_node_ids = [segment.index_node_id for segment in segments]
|
||||
|
||||
data_source_info = document.data_source_info_dict
|
||||
data_source_info["last_edited_time"] = last_edited_time
|
||||
document.data_source_info = data_source_info
|
||||
# delete from vector index
|
||||
index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True)
|
||||
|
||||
document.indexing_status = "parsing"
|
||||
document.processing_started_at = naive_utc_now()
|
||||
segment_ids = [segment.id for segment in segments]
|
||||
segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.id.in_(segment_ids))
|
||||
session.execute(segment_delete_stmt)
|
||||
|
||||
segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.document_id == document_id)
|
||||
session.execute(segment_delete_stmt)
|
||||
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")
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
@ -153,7 +153,8 @@ class TestCleanNotionDocumentTask:
|
||||
# Execute cleanup task
|
||||
clean_notion_document_task(document_ids, dataset.id)
|
||||
|
||||
# Verify segments are deleted
|
||||
# Verify documents and segments are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.id.in_(document_ids)).count() == 0
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment)
|
||||
.filter(DocumentSegment.document_id.in_(document_ids))
|
||||
@ -161,9 +162,9 @@ class TestCleanNotionDocumentTask:
|
||||
== 0
|
||||
)
|
||||
|
||||
# Verify index processor was called
|
||||
# Verify index processor was called for each document
|
||||
mock_processor = mock_index_processor_factory.return_value.init_index_processor.return_value
|
||||
mock_processor.clean.assert_called_once()
|
||||
assert mock_processor.clean.call_count == len(document_ids)
|
||||
|
||||
# This test successfully verifies:
|
||||
# 1. Document records are properly deleted from the database
|
||||
@ -185,12 +186,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 - expect exception
|
||||
with pytest.raises(Exception, match="Document has no dataset"):
|
||||
clean_notion_document_task(document_ids, non_existent_dataset_id)
|
||||
# Execute cleanup task with non-existent dataset
|
||||
clean_notion_document_task(document_ids, non_existent_dataset_id)
|
||||
|
||||
# Verify that the index processor factory was not used
|
||||
mock_index_processor_factory.return_value.init_index_processor.assert_not_called()
|
||||
# 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()
|
||||
|
||||
def test_clean_notion_document_task_empty_document_list(
|
||||
self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies
|
||||
@ -228,13 +229,9 @@ class TestCleanNotionDocumentTask:
|
||||
# Execute cleanup task with empty document list
|
||||
clean_notion_document_task([], dataset.id)
|
||||
|
||||
# Verify that the index processor was called once with empty node list
|
||||
# Verify that the index processor was not called
|
||||
mock_processor = mock_index_processor_factory.return_value.init_index_processor.return_value
|
||||
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] == []
|
||||
mock_processor.clean.assert_not_called()
|
||||
|
||||
def test_clean_notion_document_task_with_different_index_types(
|
||||
self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies
|
||||
@ -318,7 +315,8 @@ class TestCleanNotionDocumentTask:
|
||||
# Note: This test successfully verifies cleanup with different document types.
|
||||
# The task properly handles various index types and document configurations.
|
||||
|
||||
# Verify segments are deleted
|
||||
# Verify documents and segments are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.id == document.id).count() == 0
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment)
|
||||
.filter(DocumentSegment.document_id == document.id)
|
||||
@ -406,7 +404,8 @@ class TestCleanNotionDocumentTask:
|
||||
# Execute cleanup task
|
||||
clean_notion_document_task([document.id], dataset.id)
|
||||
|
||||
# Verify segments are deleted
|
||||
# Verify documents and segments are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.id == document.id).count() == 0
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment).filter(DocumentSegment.document_id == document.id).count()
|
||||
== 0
|
||||
@ -509,7 +508,8 @@ class TestCleanNotionDocumentTask:
|
||||
|
||||
clean_notion_document_task(documents_to_clean, dataset.id)
|
||||
|
||||
# Verify only specified documents' segments are deleted
|
||||
# Verify only specified documents and segments are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.id.in_(documents_to_clean)).count() == 0
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment)
|
||||
.filter(DocumentSegment.document_id.in_(documents_to_clean))
|
||||
@ -697,12 +697,11 @@ class TestCleanNotionDocumentTask:
|
||||
db_session_with_containers.commit()
|
||||
|
||||
# Mock index processor to raise an exception
|
||||
mock_index_processor = mock_index_processor_factory.return_value.init_index_processor.return_value
|
||||
mock_index_processor = mock_index_processor_factory.init_index_processor.return_value
|
||||
mock_index_processor.clean.side_effect = Exception("Index processor error")
|
||||
|
||||
# Execute cleanup task - current implementation propagates the exception
|
||||
with pytest.raises(Exception, match="Index processor error"):
|
||||
clean_notion_document_task([document.id], dataset.id)
|
||||
# Execute cleanup task - it should handle the exception gracefully
|
||||
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.
|
||||
@ -804,7 +803,8 @@ class TestCleanNotionDocumentTask:
|
||||
all_document_ids = [doc.id for doc in documents]
|
||||
clean_notion_document_task(all_document_ids, dataset.id)
|
||||
|
||||
# Verify all segments are deleted
|
||||
# Verify all documents and segments are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.dataset_id == dataset.id).count() == 0
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment).filter(DocumentSegment.dataset_id == dataset.id).count()
|
||||
== 0
|
||||
@ -914,7 +914,8 @@ class TestCleanNotionDocumentTask:
|
||||
|
||||
clean_notion_document_task([target_document.id], target_dataset.id)
|
||||
|
||||
# Verify only documents' segments from target dataset are deleted
|
||||
# Verify only documents from target dataset are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.id == target_document.id).count() == 0
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment)
|
||||
.filter(DocumentSegment.document_id == target_document.id)
|
||||
@ -1029,7 +1030,8 @@ class TestCleanNotionDocumentTask:
|
||||
all_document_ids = [doc.id for doc in documents]
|
||||
clean_notion_document_task(all_document_ids, dataset.id)
|
||||
|
||||
# Verify all segments are deleted regardless of status
|
||||
# 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
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment).filter(DocumentSegment.dataset_id == dataset.id).count()
|
||||
== 0
|
||||
@ -1140,7 +1142,8 @@ class TestCleanNotionDocumentTask:
|
||||
# Execute cleanup task
|
||||
clean_notion_document_task([document.id], dataset.id)
|
||||
|
||||
# Verify segments are deleted
|
||||
# Verify documents and segments are deleted
|
||||
assert db_session_with_containers.query(Document).filter(Document.id == document.id).count() == 0
|
||||
assert (
|
||||
db_session_with_containers.query(DocumentSegment).filter(DocumentSegment.document_id == document.id).count()
|
||||
== 0
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@ -1,54 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@ -4,7 +4,7 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import 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=30, suppress_health_check=[HealthCheck.too_slow, HealthCheck.filter_too_much], deadline=None)
|
||||
@settings(max_examples=50)
|
||||
@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=30, suppress_health_check=[HealthCheck.too_slow, HealthCheck.filter_too_much], deadline=None)
|
||||
@settings(max_examples=50)
|
||||
@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)
|
||||
|
||||
@ -109,87 +109,40 @@ def mock_document_segments(document_id):
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_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
|
||||
|
||||
"""Mock database session via session_factory.create_session()."""
|
||||
with patch("tasks.document_indexing_sync_task.session_factory") as mock_sf:
|
||||
sessions = []
|
||||
session = MagicMock()
|
||||
# Ensure tests can observe session.close() via context manager teardown
|
||||
session.close = MagicMock()
|
||||
session.commit = MagicMock()
|
||||
|
||||
# Shared query mocks - all sessions use these
|
||||
shared_query = MagicMock()
|
||||
shared_filter_by = MagicMock()
|
||||
shared_scalars_result = MagicMock()
|
||||
# Mock session.begin() context manager to auto-commit on exit
|
||||
begin_cm = MagicMock()
|
||||
begin_cm.__enter__.return_value = session
|
||||
|
||||
# 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)
|
||||
def _begin_exit_side_effect(*args, **kwargs):
|
||||
# session.begin().__exit__() should commit if no exception
|
||||
if args[0] is None: # No exception
|
||||
session.commit()
|
||||
|
||||
shared_query.where.return_value.first = CyclicMock()
|
||||
shared_filter_by.first = CyclicMock()
|
||||
begin_cm.__exit__.side_effect = _begin_exit_side_effect
|
||||
session.begin.return_value = begin_cm
|
||||
|
||||
def _create_session():
|
||||
"""Create a new mock session for each create_session() call."""
|
||||
session = MagicMock()
|
||||
session.close = MagicMock()
|
||||
session.commit = MagicMock()
|
||||
# Mock create_session() context manager
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = session
|
||||
|
||||
# Mock session.begin() context manager
|
||||
begin_cm = MagicMock()
|
||||
begin_cm.__enter__.return_value = session
|
||||
def _exit_side_effect(*args, **kwargs):
|
||||
session.close()
|
||||
|
||||
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
|
||||
cm.__exit__.side_effect = _exit_side_effect
|
||||
mock_sf.create_session.return_value = cm
|
||||
|
||||
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]
|
||||
query = MagicMock()
|
||||
session.query.return_value = query
|
||||
query.where.return_value = query
|
||||
session.scalars.return_value = MagicMock()
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -248,8 +201,8 @@ class TestDocumentIndexingSyncTask:
|
||||
# Act
|
||||
document_indexing_sync_task(dataset_id, document_id)
|
||||
|
||||
# Assert - at least one session should have been closed
|
||||
assert mock_db_session.any_close_called()
|
||||
# Assert
|
||||
mock_db_session.close.assert_called_once()
|
||||
|
||||
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."""
|
||||
@ -292,7 +245,6 @@ 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
|
||||
@ -302,8 +254,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
|
||||
assert mock_db_session.any_commit_called()
|
||||
assert mock_db_session.any_close_called()
|
||||
mock_db_session.commit.assert_called()
|
||||
mock_db_session.close.assert_called()
|
||||
|
||||
def test_page_not_updated(
|
||||
self,
|
||||
@ -317,7 +269,6 @@ 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"
|
||||
|
||||
@ -327,8 +278,8 @@ class TestDocumentIndexingSyncTask:
|
||||
# Assert
|
||||
# Document status should remain unchanged
|
||||
assert mock_document.indexing_status == "completed"
|
||||
# At least one session should have been closed via context manager teardown
|
||||
assert mock_db_session.any_close_called()
|
||||
# Session should still be closed via context manager teardown
|
||||
assert mock_db_session.close.called
|
||||
|
||||
def test_successful_sync_when_page_updated(
|
||||
self,
|
||||
@ -345,20 +296,7 @@ class TestDocumentIndexingSyncTask:
|
||||
):
|
||||
"""Test successful sync flow when Notion page has been updated."""
|
||||
# Arrange
|
||||
# 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.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
|
||||
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"
|
||||
@ -376,40 +314,28 @@ class TestDocumentIndexingSyncTask:
|
||||
mock_processor.clean.assert_called_once()
|
||||
|
||||
# Verify segments were deleted from database in batch (DELETE FROM document_segments)
|
||||
# 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])
|
||||
execute_sqls = [" ".join(str(c[0][0]).split()) for c in mock_db_session.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 (across any created session)
|
||||
assert mock_db_session.any_commit_called()
|
||||
assert mock_db_session.any_close_called()
|
||||
# Verify session operations
|
||||
assert mock_db_session.commit.called
|
||||
mock_db_session.close.assert_called_once()
|
||||
|
||||
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
|
||||
# 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_db_session.query.return_value.where.return_value.first.side_effect = [mock_document, None]
|
||||
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
|
||||
|
||||
# Act
|
||||
@ -418,8 +344,8 @@ class TestDocumentIndexingSyncTask:
|
||||
# Assert
|
||||
# Document should still be set to parsing
|
||||
assert mock_document.indexing_status == "parsing"
|
||||
# At least one session should be closed after error
|
||||
assert mock_db_session.any_close_called()
|
||||
# Session should be closed after error
|
||||
mock_db_session.close.assert_called_once()
|
||||
|
||||
def test_cleaning_error_continues_to_indexing(
|
||||
self,
|
||||
@ -435,14 +361,8 @@ class TestDocumentIndexingSyncTask:
|
||||
):
|
||||
"""Test that indexing continues even if cleaning fails."""
|
||||
# Arrange
|
||||
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_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")
|
||||
mock_notion_extractor.get_notion_last_edited_time.return_value = "2024-01-02T00:00:00Z"
|
||||
|
||||
# Act
|
||||
@ -451,7 +371,7 @@ class TestDocumentIndexingSyncTask:
|
||||
# Assert
|
||||
# Indexing should still be attempted despite cleaning error
|
||||
mock_indexing_runner.run.assert_called_once_with([mock_document])
|
||||
assert mock_db_session.any_close_called()
|
||||
mock_db_session.close.assert_called_once()
|
||||
|
||||
def test_indexing_runner_document_paused_error(
|
||||
self,
|
||||
@ -468,10 +388,7 @@ class TestDocumentIndexingSyncTask:
|
||||
):
|
||||
"""Test that DocumentIsPausedError is handled gracefully."""
|
||||
# Arrange
|
||||
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.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
|
||||
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")
|
||||
@ -481,7 +398,7 @@ class TestDocumentIndexingSyncTask:
|
||||
|
||||
# Assert
|
||||
# Session should be closed after handling error
|
||||
assert mock_db_session.any_close_called()
|
||||
mock_db_session.close.assert_called_once()
|
||||
|
||||
def test_indexing_runner_general_error(
|
||||
self,
|
||||
@ -498,10 +415,7 @@ class TestDocumentIndexingSyncTask:
|
||||
):
|
||||
"""Test that general exceptions during indexing are handled."""
|
||||
# Arrange
|
||||
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.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
|
||||
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")
|
||||
@ -511,7 +425,7 @@ class TestDocumentIndexingSyncTask:
|
||||
|
||||
# Assert
|
||||
# Session should be closed after error
|
||||
assert mock_db_session.any_close_called()
|
||||
mock_db_session.close.assert_called_once()
|
||||
|
||||
def test_notion_extractor_initialized_with_correct_params(
|
||||
self,
|
||||
@ -618,14 +532,7 @@ class TestDocumentIndexingSyncTask:
|
||||
):
|
||||
"""Test that index processor clean is called with correct parameters."""
|
||||
# Arrange
|
||||
# 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.query.return_value.where.return_value.first.side_effect = [mock_document, mock_dataset]
|
||||
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"
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ const PluginList = () => {
|
||||
return (
|
||||
<PluginPage
|
||||
plugins={<PluginsPanel />}
|
||||
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
|
||||
marketplace={<Marketplace />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
<svg width="588" height="588" viewBox="0 0 588 588" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.2" clip-path="url(#clip0_20862_53031)">
|
||||
<g filter="url(#filter0_d_20862_53031)">
|
||||
<path d="M204.231 152.332L201.643 142.673C194.496 115.999 210.326 88.5823 236.999 81.4353C263.672 74.2882 291.089 90.1173 298.236 116.791L300.824 126.45L407.076 97.9798C417.745 95.1209 428.712 101.453 431.571 112.122L452.276 189.396C453.706 194.731 450.539 200.214 445.205 201.643C418.532 208.79 402.703 236.208 409.85 262.881C416.997 289.554 444.414 305.383 471.087 298.236C476.421 296.807 481.905 299.973 483.335 305.307L504.04 382.581C506.899 393.251 500.568 404.217 489.898 407.076L180.802 489.898C170.132 492.757 159.166 486.426 156.307 475.756L83.8375 205.297C80.9787 194.628 87.3104 183.661 97.9796 180.802L204.231 152.332Z" fill="#F2F4F7"/>
|
||||
<path d="M237.257 82.4012C263.397 75.3971 290.266 90.9096 297.27 117.049L300.117 127.675L407.335 98.9457C417.471 96.2297 427.889 102.245 430.605 112.381L451.31 189.655C452.597 194.456 449.747 199.391 444.946 200.677C417.74 207.967 401.594 235.933 408.884 263.139C416.174 290.346 444.139 306.492 471.346 299.202C476.146 297.916 481.082 300.766 482.369 305.566L503.074 382.84C505.79 392.976 499.775 403.394 489.639 406.11L180.543 488.932C170.407 491.648 159.989 485.633 157.273 475.497L84.8034 205.038C82.0875 194.902 88.1027 184.484 98.2384 181.768L205.456 153.039L202.609 142.414C195.605 116.274 211.118 89.4053 237.257 82.4012Z" stroke="white" stroke-width="2"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_20862_53031" x="31.151" y="59.719" width="525.576" height="514.866" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="12" operator="erode" in="SourceAlpha" result="effect1_dropShadow_20862_53031"/>
|
||||
<feOffset dy="32"/>
|
||||
<feGaussianBlur stdDeviation="32"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.14 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_20862_53031"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_20862_53031" result="shape"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_20862_53031">
|
||||
<rect width="480" height="480" fill="white" transform="translate(0 124.233) rotate(-15)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@ -0,0 +1,26 @@
|
||||
<svg width="588" height="588" viewBox="0 0 588 588" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.2" clip-path="url(#clip0_21509_19682)">
|
||||
<g filter="url(#filter0_d_21509_19682)">
|
||||
<path d="M346.36 300.589C389.034 289.159 432.902 314.489 444.34 357.157C455.774 399.831 430.445 443.698 387.771 455.137C345.095 466.572 301.227 441.245 289.792 398.568C278.362 355.895 303.687 312.023 346.36 300.589Z" fill="#F2F4F7"/>
|
||||
<path d="M116.295 221.279C122.148 217.181 129.755 216.517 136.23 219.537L261.798 278.096C268.274 281.114 272.666 287.369 273.288 294.489C273.908 301.604 270.669 308.514 264.818 312.611L151.323 392.076C145.47 396.175 137.869 396.858 131.393 393.838C124.917 390.819 120.544 384.556 119.922 377.439L107.85 239.415C107.227 232.299 110.444 225.378 116.295 221.279Z" fill="#F2F4F7"/>
|
||||
<path d="M283.278 160.623C283.279 127.486 310.148 100.606 343.285 100.606L383.281 100.606C416.416 100.607 443.279 127.482 443.279 160.618L443.279 200.613C443.28 233.747 416.42 260.604 383.286 260.607L343.272 260.612C310.138 260.609 283.28 233.752 283.278 200.619L283.278 160.623Z" fill="#F2F4F7"/>
|
||||
<path d="M346.619 301.555C388.759 290.268 432.079 315.281 443.374 357.416C454.666 399.557 429.653 442.875 387.513 454.171C345.37 465.464 302.05 440.453 290.758 398.31C279.471 356.169 304.479 312.846 346.619 301.555ZM116.869 222.099C122.429 218.205 129.656 217.574 135.808 220.443L261.376 279.002C267.529 281.87 271.701 287.814 272.291 294.576C272.88 301.333 269.804 307.898 264.245 311.791L150.75 391.257C145.361 395.03 138.416 395.757 132.395 393.19L131.815 392.931C125.665 390.064 121.51 384.115 120.918 377.353L108.846 239.329C108.254 232.567 111.311 225.992 116.869 222.099ZM284.278 160.623C284.28 128.038 310.701 101.606 343.285 101.606L383.281 101.606C415.864 101.608 442.279 128.034 442.28 160.618L442.28 200.614C442.28 233.195 415.868 259.604 383.286 259.607L343.272 259.612C310.691 259.61 284.281 233.2 284.278 200.619L284.278 160.623Z" stroke="white" stroke-width="2"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_21509_19682" x="55.7732" y="80.6057" width="443.312" height="461.277" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="12" operator="erode" in="SourceAlpha" result="effect1_dropShadow_21509_19682"/>
|
||||
<feOffset dy="32"/>
|
||||
<feGaussianBlur stdDeviation="32"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.14 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_21509_19682"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_21509_19682" result="shape"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_21509_19682">
|
||||
<rect width="480" height="480" fill="white" transform="translate(0 124.233) rotate(-15)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.6301 11.3333C11.6301 12.4379 10.7347 13.3333 9.63013 13.3333C8.52559 13.3333 7.63013 12.4379 7.63013 11.3333C7.63013 10.2287 8.52559 9.33325 9.63013 9.33325C10.7347 9.33325 11.6301 10.2287 11.6301 11.3333Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M3.1353 4.75464L6.67352 7.72353L2.33325 9.30327L3.1353 4.75464Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M9.79576 2.5L13.6595 3.53527L12.6242 7.399L8.7605 6.36371L9.79576 2.5Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 658 B |
@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 6.66675H17.5V15.8334H2.5V6.66675Z" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.16675 6.66659V3.33325H8.33341V6.66659" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.6667 6.66659V3.33325H15.8334V6.66659" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 509 B |
@ -0,0 +1,183 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "588",
|
||||
"height": "588",
|
||||
"viewBox": "0 0 588 588",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"opacity": "0.2",
|
||||
"clip-path": "url(#clip0_20862_53031)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter0_d_20862_53031)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M204.231 152.332L201.643 142.673C194.496 115.999 210.326 88.5823 236.999 81.4353C263.672 74.2882 291.089 90.1173 298.236 116.791L300.824 126.45L407.076 97.9798C417.745 95.1209 428.712 101.453 431.571 112.122L452.276 189.396C453.706 194.731 450.539 200.214 445.205 201.643C418.532 208.79 402.703 236.208 409.85 262.881C416.997 289.554 444.414 305.383 471.087 298.236C476.421 296.807 481.905 299.973 483.335 305.307L504.04 382.581C506.899 393.251 500.568 404.217 489.898 407.076L180.802 489.898C170.132 492.757 159.166 486.426 156.307 475.756L83.8375 205.297C80.9787 194.628 87.3104 183.661 97.9796 180.802L204.231 152.332Z",
|
||||
"fill": "#F2F4F7"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M237.257 82.4012C263.397 75.3971 290.266 90.9096 297.27 117.049L300.117 127.675L407.335 98.9457C417.471 96.2297 427.889 102.245 430.605 112.381L451.31 189.655C452.597 194.456 449.747 199.391 444.946 200.677C417.74 207.967 401.594 235.933 408.884 263.139C416.174 290.346 444.139 306.492 471.346 299.202C476.146 297.916 481.082 300.766 482.369 305.566L503.074 382.84C505.79 392.976 499.775 403.394 489.639 406.11L180.543 488.932C170.407 491.648 159.989 485.633 157.273 475.497L84.8034 205.038C82.0875 194.902 88.1027 184.484 98.2384 181.768L205.456 153.039L202.609 142.414C195.605 116.274 211.118 89.4053 237.257 82.4012Z",
|
||||
"stroke": "white",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter0_d_20862_53031",
|
||||
"x": "31.151",
|
||||
"y": "59.719",
|
||||
"width": "525.576",
|
||||
"height": "514.866",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feColorMatrix",
|
||||
"attributes": {
|
||||
"in": "SourceAlpha",
|
||||
"type": "matrix",
|
||||
"values": "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0",
|
||||
"result": "hardAlpha"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feMorphology",
|
||||
"attributes": {
|
||||
"radius": "12",
|
||||
"operator": "erode",
|
||||
"in": "SourceAlpha",
|
||||
"result": "effect1_dropShadow_20862_53031"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feOffset",
|
||||
"attributes": {
|
||||
"dy": "32"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "32"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feComposite",
|
||||
"attributes": {
|
||||
"in2": "hardAlpha",
|
||||
"operator": "out"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feColorMatrix",
|
||||
"attributes": {
|
||||
"type": "matrix",
|
||||
"values": "0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.14 0"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "effect1_dropShadow_20862_53031"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "effect1_dropShadow_20862_53031",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "clipPath",
|
||||
"attributes": {
|
||||
"id": "clip0_20862_53031"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "480",
|
||||
"height": "480",
|
||||
"fill": "white",
|
||||
"transform": "translate(0 124.233) rotate(-15)"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "PluginHeaderBg"
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './PluginHeaderBg.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'PluginHeaderBg'
|
||||
|
||||
export default Icon
|
||||
@ -0,0 +1,201 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "588",
|
||||
"height": "588",
|
||||
"viewBox": "0 0 588 588",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"opacity": "0.2",
|
||||
"clip-path": "url(#clip0_21509_19682)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"filter": "url(#filter0_d_21509_19682)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M346.36 300.589C389.034 289.159 432.902 314.489 444.34 357.157C455.774 399.831 430.445 443.698 387.771 455.137C345.095 466.572 301.227 441.245 289.792 398.568C278.362 355.895 303.687 312.023 346.36 300.589Z",
|
||||
"fill": "#F2F4F7"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M116.295 221.279C122.148 217.181 129.755 216.517 136.23 219.537L261.798 278.096C268.274 281.114 272.666 287.369 273.288 294.489C273.908 301.604 270.669 308.514 264.818 312.611L151.323 392.076C145.47 396.175 137.869 396.858 131.393 393.838C124.917 390.819 120.544 384.556 119.922 377.439L107.85 239.415C107.227 232.299 110.444 225.378 116.295 221.279Z",
|
||||
"fill": "#F2F4F7"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M283.278 160.623C283.279 127.486 310.148 100.606 343.285 100.606L383.281 100.606C416.416 100.607 443.279 127.482 443.279 160.618L443.279 200.613C443.28 233.747 416.42 260.604 383.286 260.607L343.272 260.612C310.138 260.609 283.28 233.752 283.278 200.619L283.278 160.623Z",
|
||||
"fill": "#F2F4F7"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M346.619 301.555C388.759 290.268 432.079 315.281 443.374 357.416C454.666 399.557 429.653 442.875 387.513 454.171C345.37 465.464 302.05 440.453 290.758 398.31C279.471 356.169 304.479 312.846 346.619 301.555ZM116.869 222.099C122.429 218.205 129.656 217.574 135.808 220.443L261.376 279.002C267.529 281.87 271.701 287.814 272.291 294.576C272.88 301.333 269.804 307.898 264.245 311.791L150.75 391.257C145.361 395.03 138.416 395.757 132.395 393.19L131.815 392.931C125.665 390.064 121.51 384.115 120.918 377.353L108.846 239.329C108.254 232.567 111.311 225.992 116.869 222.099ZM284.278 160.623C284.28 128.038 310.701 101.606 343.285 101.606L383.281 101.606C415.864 101.608 442.279 128.034 442.28 160.618L442.28 200.614C442.28 233.195 415.868 259.604 383.286 259.607L343.272 259.612C310.691 259.61 284.281 233.2 284.278 200.619L284.278 160.623Z",
|
||||
"stroke": "white",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "filter",
|
||||
"attributes": {
|
||||
"id": "filter0_d_21509_19682",
|
||||
"x": "55.7732",
|
||||
"y": "80.6057",
|
||||
"width": "443.312",
|
||||
"height": "461.277",
|
||||
"filterUnits": "userSpaceOnUse",
|
||||
"color-interpolation-filters": "sRGB"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feFlood",
|
||||
"attributes": {
|
||||
"flood-opacity": "0",
|
||||
"result": "BackgroundImageFix"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feColorMatrix",
|
||||
"attributes": {
|
||||
"in": "SourceAlpha",
|
||||
"type": "matrix",
|
||||
"values": "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0",
|
||||
"result": "hardAlpha"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feMorphology",
|
||||
"attributes": {
|
||||
"radius": "12",
|
||||
"operator": "erode",
|
||||
"in": "SourceAlpha",
|
||||
"result": "effect1_dropShadow_21509_19682"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feOffset",
|
||||
"attributes": {
|
||||
"dy": "32"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feGaussianBlur",
|
||||
"attributes": {
|
||||
"stdDeviation": "32"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feComposite",
|
||||
"attributes": {
|
||||
"in2": "hardAlpha",
|
||||
"operator": "out"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feColorMatrix",
|
||||
"attributes": {
|
||||
"type": "matrix",
|
||||
"values": "0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.14 0"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in2": "BackgroundImageFix",
|
||||
"result": "effect1_dropShadow_21509_19682"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "feBlend",
|
||||
"attributes": {
|
||||
"mode": "normal",
|
||||
"in": "SourceGraphic",
|
||||
"in2": "effect1_dropShadow_21509_19682",
|
||||
"result": "shape"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "clipPath",
|
||||
"attributes": {
|
||||
"id": "clip0_21509_19682"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "480",
|
||||
"height": "480",
|
||||
"fill": "white",
|
||||
"transform": "translate(0 124.233) rotate(-15)"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "TemplateHeaderBg"
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './TemplateHeaderBg.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'TemplateHeaderBg'
|
||||
|
||||
export default Icon
|
||||
@ -1,6 +1,8 @@
|
||||
export { default as Google } from './Google'
|
||||
export { default as PartnerDark } from './PartnerDark'
|
||||
export { default as PartnerLight } from './PartnerLight'
|
||||
export { default as PluginHeaderBg } from './PluginHeaderBg'
|
||||
export { default as TemplateHeaderBg } from './TemplateHeaderBg'
|
||||
export { default as VerifiedDark } from './VerifiedDark'
|
||||
export { default as VerifiedLight } from './VerifiedLight'
|
||||
export { default as WebReader } from './WebReader'
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "16",
|
||||
"viewBox": "0 0 16 16",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M11.6301 11.3333C11.6301 12.4379 10.7347 13.3333 9.63013 13.3333C8.52559 13.3333 7.63013 12.4379 7.63013 11.3333C7.63013 10.2287 8.52559 9.33325 9.63013 9.33325C10.7347 9.33325 11.6301 10.2287 11.6301 11.3333Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M3.1353 4.75464L6.67352 7.72353L2.33325 9.30327L3.1353 4.75464Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M9.79576 2.5L13.6595 3.53527L12.6242 7.399L8.7605 6.36371L9.79576 2.5Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Playground"
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Playground.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Playground'
|
||||
|
||||
export default Icon
|
||||
53
web/app/components/base/icons/src/vender/plugin/Plugin.json
Normal file
53
web/app/components/base/icons/src/vender/plugin/Plugin.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "20",
|
||||
"height": "20",
|
||||
"viewBox": "0 0 20 20",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M2.5 6.66675H17.5V15.8334H2.5V6.66675Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M4.16675 6.66659V3.33325H8.33341V6.66659",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M11.6667 6.66659V3.33325H15.8334V6.66659",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Plugin"
|
||||
}
|
||||
20
web/app/components/base/icons/src/vender/plugin/Plugin.tsx
Normal file
20
web/app/components/base/icons/src/vender/plugin/Plugin.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Plugin.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Plugin'
|
||||
|
||||
export default Icon
|
||||
@ -1,3 +1,5 @@
|
||||
export { default as BoxSparkleFill } from './BoxSparkleFill'
|
||||
export { default as LeftCorner } from './LeftCorner'
|
||||
export { default as Playground } from './Playground'
|
||||
export { default as Plugin } from './Plugin'
|
||||
export { default as Trigger } from './Trigger'
|
||||
|
||||
@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@ -64,8 +64,8 @@ const InstallFromMarketplace = ({
|
||||
{
|
||||
!isAllPluginsLoading && !collapse && (
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton
|
||||
cardContainerClassName="grid grid-cols-2 gap-2"
|
||||
|
||||
@ -63,8 +63,8 @@ const InstallFromMarketplace = ({
|
||||
{
|
||||
!isAllPluginsLoading && !collapse && (
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton
|
||||
cardContainerClassName="grid grid-cols-2 gap-2"
|
||||
|
||||
@ -38,7 +38,7 @@ const DeprecationNotice: FC<DeprecationNoticeProps> = ({
|
||||
iconWrapperClassName,
|
||||
textClassName,
|
||||
}) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const { t } = useTranslation()
|
||||
|
||||
const deprecatedReasonKey = useMemo(() => {
|
||||
if (!deprecatedReason)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { RiInstallLine } from '@remixicon/react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import * as React from 'react'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
@ -9,10 +9,13 @@ type Props = {
|
||||
const DownloadCountComponent = ({
|
||||
downloadCount,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1 text-text-tertiary">
|
||||
<RiInstallLine className="h-3 w-3 shrink-0" />
|
||||
<div className="system-xs-regular">{formatNumber(downloadCount)}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{formatNumber(downloadCount)}
|
||||
{' '}
|
||||
{t('marketplace.installs')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import DownloadCount from './download-count'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
orgName?: string
|
||||
packageName: string
|
||||
packageName?: string
|
||||
packageNameClassName?: string
|
||||
downloadCount?: number
|
||||
linkToOrg?: boolean
|
||||
}
|
||||
|
||||
const OrgInfo = ({
|
||||
@ -12,7 +16,42 @@ const OrgInfo = ({
|
||||
orgName,
|
||||
packageName,
|
||||
packageNameClassName,
|
||||
downloadCount,
|
||||
linkToOrg = true,
|
||||
}: Props) => {
|
||||
// New format: "by {orgName} · {downloadCount} installs" (for marketplace cards)
|
||||
if (downloadCount !== undefined) {
|
||||
return (
|
||||
<div className={cn('system-xs-regular flex h-4 items-center gap-2 text-text-tertiary', className)}>
|
||||
{orgName && (
|
||||
<span className="shrink-0">
|
||||
<span className="mr-1 text-text-tertiary">by</span>
|
||||
{linkToOrg
|
||||
? (
|
||||
<Link
|
||||
href={`/creator/${orgName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-text-secondary hover:underline"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{orgName}
|
||||
</Link>
|
||||
)
|
||||
: (
|
||||
<span className="text-text-tertiary">
|
||||
{orgName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0">·</span>
|
||||
<DownloadCount downloadCount={downloadCount} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Legacy format: "{orgName} / {packageName}" (for plugin detail panels)
|
||||
return (
|
||||
<div className={cn('flex h-4 items-center space-x-0.5', className)}>
|
||||
{orgName && (
|
||||
@ -21,9 +60,11 @@ const OrgInfo = ({
|
||||
<span className="system-xs-regular shrink-0 text-text-quaternary">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
|
||||
{packageName}
|
||||
</span>
|
||||
{packageName && (
|
||||
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
|
||||
{packageName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import DownloadCount from './base/download-count'
|
||||
|
||||
type Props = {
|
||||
downloadCount?: number
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const CardMoreInfoComponent = ({
|
||||
downloadCount,
|
||||
tags,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex h-5 items-center">
|
||||
{downloadCount !== undefined && <DownloadCount downloadCount={downloadCount} />}
|
||||
{downloadCount !== undefined && tags && tags.length > 0 && <div className="system-xs-regular mx-2 text-text-quaternary">·</div>}
|
||||
{tags && tags.length > 0 && (
|
||||
<>
|
||||
<div className="flex h-4 flex-wrap space-x-2 overflow-hidden">
|
||||
{tags.map(tag => (
|
||||
<div
|
||||
key={tag}
|
||||
className="system-xs-regular flex max-w-[120px] space-x-1 overflow-hidden"
|
||||
title={`# ${tag}`}
|
||||
>
|
||||
<span className="text-text-quaternary">#</span>
|
||||
<span className="truncate text-text-tertiary">{tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Memoize to prevent unnecessary re-renders when tags array hasn't changed
|
||||
const CardMoreInfo = React.memo(CardMoreInfoComponent)
|
||||
|
||||
export default CardMoreInfo
|
||||
34
web/app/components/plugins/card/card-tags.tsx
Normal file
34
web/app/components/plugins/card/card-tags.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { RiPriceTag3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
|
||||
type Props = {
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const CardTagsComponent = ({
|
||||
tags,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="mt-2 flex min-h-[20px] items-center gap-1">
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 overflow-hidden">
|
||||
{tags.slice(0, 2).map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex max-w-[100px] items-center gap-0.5 truncate rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]"
|
||||
title={tag}
|
||||
>
|
||||
<RiPriceTag3Line className="h-3 w-3 shrink-0 text-text-quaternary" />
|
||||
<span className="system-2xs-medium-uppercase text-text-tertiary">{tag.toUpperCase()}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Memoize to prevent unnecessary re-renders when tags array hasn't changed
|
||||
const CardTags = React.memo(CardTagsComponent)
|
||||
|
||||
export default CardTags
|
||||
@ -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 CardMoreInfo from './card-more-info'
|
||||
import CardTags from './card-tags'
|
||||
// ================================
|
||||
// Import Components Under Test
|
||||
// ================================
|
||||
@ -642,9 +642,9 @@ describe('Card', () => {
|
||||
})
|
||||
|
||||
// ================================
|
||||
// CardMoreInfo Component Tests
|
||||
// CardTags Component Tests
|
||||
// ================================
|
||||
describe('CardMoreInfo', () => {
|
||||
describe('CardTags', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@ -654,66 +654,24 @@ describe('CardMoreInfo', () => {
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CardMoreInfo downloadCount={100} tags={['tag1']} />)
|
||||
render(<CardTags tags={['tag1']} />)
|
||||
|
||||
expect(document.body).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render download count when provided', () => {
|
||||
render(<CardMoreInfo downloadCount={1000} tags={[]} />)
|
||||
it('should render tags in uppercase', () => {
|
||||
render(<CardTags tags={['search', 'image']} />)
|
||||
|
||||
expect(screen.getByText('1,000')).toBeInTheDocument()
|
||||
expect(screen.getByText('SEARCH')).toBeInTheDocument()
|
||||
expect(screen.getByText('IMAGE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tags when provided', () => {
|
||||
render(<CardMoreInfo tags={['search', 'image']} />)
|
||||
it('should render at most two tags', () => {
|
||||
render(<CardTags tags={['one', 'two', 'three']} />)
|
||||
|
||||
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()
|
||||
expect(screen.getByText('ONE')).toBeInTheDocument()
|
||||
expect(screen.getByText('TWO')).toBeInTheDocument()
|
||||
expect(screen.queryByText('THREE')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -722,54 +680,8 @@ describe('CardMoreInfo', () => {
|
||||
// ================================
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
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()
|
||||
expect(CardTags).toBeDefined()
|
||||
expect(typeof CardTags).toBe('object')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1688,7 +1600,7 @@ describe('Icon', () => {
|
||||
render(
|
||||
<Card
|
||||
payload={plugin}
|
||||
footer={<CardMoreInfo downloadCount={5000} tags={['search', 'api']} />}
|
||||
footer={<CardTags tags={['search', 'api']} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1700,9 +1612,8 @@ describe('Icon', () => {
|
||||
expect(screen.getByText('Tool')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
|
||||
expect(screen.getByText('5,000')).toBeInTheDocument()
|
||||
expect(screen.getByText('search')).toBeInTheDocument()
|
||||
expect(screen.getByText('api')).toBeInTheDocument()
|
||||
expect(screen.getByText('SEARCH')).toBeInTheDocument()
|
||||
expect(screen.getByText('API')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state correctly', () => {
|
||||
@ -1728,12 +1639,12 @@ describe('Icon', () => {
|
||||
<Card
|
||||
payload={plugin}
|
||||
installed={true}
|
||||
footer={<CardMoreInfo downloadCount={100} tags={['tag1']} />}
|
||||
footer={<CardTags tags={['tag1']} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('ri-check-line')).toBeInTheDocument()
|
||||
expect(screen.getByText('100')).toBeInTheDocument()
|
||||
expect(screen.getByText('TAG1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1817,9 +1728,9 @@ describe('Icon', () => {
|
||||
})
|
||||
|
||||
it('should have title attribute on tags', () => {
|
||||
render(<CardMoreInfo downloadCount={100} tags={['search']} />)
|
||||
render(<CardTags tags={['search']} />)
|
||||
|
||||
expect(screen.getByTitle('# search')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have semantic structure', () => {
|
||||
@ -1864,11 +1775,11 @@ describe('Icon', () => {
|
||||
expect(endTime - startTime).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
it('should handle CardMoreInfo with many tags', () => {
|
||||
it('should handle CardTags with many tags', () => {
|
||||
const tags = Array.from({ length: 20 }, (_, i) => `tag-${i}`)
|
||||
|
||||
const startTime = performance.now()
|
||||
render(<CardMoreInfo downloadCount={1000} tags={tags} />)
|
||||
render(<CardTags tags={tags} />)
|
||||
const endTime = performance.now()
|
||||
|
||||
expect(endTime - startTime).toBeLessThan(100)
|
||||
|
||||
@ -32,6 +32,7 @@ export type Props = {
|
||||
isLoading?: boolean
|
||||
loadingFileName?: string
|
||||
limitedInstall?: boolean
|
||||
disableOrgLink?: boolean
|
||||
}
|
||||
|
||||
const Card = ({
|
||||
@ -46,11 +47,12 @@ const Card = ({
|
||||
isLoading = false,
|
||||
loadingFileName,
|
||||
limitedInstall = false,
|
||||
disableOrgLink = false,
|
||||
}: Props) => {
|
||||
const locale = useGetLanguage()
|
||||
const { t } = useTranslation()
|
||||
const { categoriesMap } = useCategories(true)
|
||||
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [] } = payload
|
||||
const { category, type, org, label, brief, icon, icon_dark, verified, badges = [], install_count } = payload
|
||||
const { theme } = useTheme()
|
||||
const iconSrc = theme === Theme.dark && icon_dark ? icon_dark : icon
|
||||
const getLocalizedText = (obj: Record<string, string> | undefined) =>
|
||||
@ -86,7 +88,8 @@ const Card = ({
|
||||
<OrgInfo
|
||||
className="mt-0.5"
|
||||
orgName={org}
|
||||
packageName={name}
|
||||
downloadCount={install_count}
|
||||
linkToOrg={!disableOrgLink}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,31 +1,149 @@
|
||||
import type { SearchTab } from './search-params'
|
||||
import type { PluginsSort, SearchParamsFromCollection } from './types'
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { marketplaceSearchParamsParsers } from './search-params'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { CATEGORY_ALL, DEFAULT_PLUGIN_SORT, DEFAULT_TEMPLATE_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params'
|
||||
|
||||
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT)
|
||||
export function useMarketplaceSort() {
|
||||
return useAtom(marketplaceSortAtom)
|
||||
export const isMarketplacePlatformAtom = atom<boolean>(false)
|
||||
|
||||
const marketplacePluginSortAtom = atom<PluginsSort>(DEFAULT_PLUGIN_SORT)
|
||||
export function useMarketplacePluginSort() {
|
||||
return useAtom(marketplacePluginSortAtom)
|
||||
}
|
||||
export function useMarketplaceSortValue() {
|
||||
return useAtomValue(marketplaceSortAtom)
|
||||
export function useMarketplacePluginSortValue() {
|
||||
return useAtomValue(marketplacePluginSortAtom)
|
||||
}
|
||||
export function useSetMarketplaceSort() {
|
||||
return useSetAtom(marketplaceSortAtom)
|
||||
export function useSetMarketplacePluginSort() {
|
||||
return useSetAtom(marketplacePluginSortAtom)
|
||||
}
|
||||
|
||||
export function useSearchPluginText() {
|
||||
const marketplaceTemplateSortAtom = atom<PluginsSort>(DEFAULT_TEMPLATE_SORT)
|
||||
export function useMarketplaceTemplateSort() {
|
||||
return useAtom(marketplaceTemplateSortAtom)
|
||||
}
|
||||
export function useMarketplaceTemplateSortValue() {
|
||||
return useAtomValue(marketplaceTemplateSortAtom)
|
||||
}
|
||||
export function useSetMarketplaceTemplateSort() {
|
||||
return useSetAtom(marketplaceTemplateSortAtom)
|
||||
}
|
||||
|
||||
export function useSearchText() {
|
||||
return useQueryState('q', marketplaceSearchParamsParsers.q)
|
||||
}
|
||||
export function useActivePluginType() {
|
||||
return useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
export function useActivePluginCategory() {
|
||||
const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom)
|
||||
|
||||
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
const categoryFromPath = segments[1] || CATEGORY_ALL
|
||||
const validatedCategory = getValidatedPluginCategory(categoryFromPath)
|
||||
const handleChange = useCallback(
|
||||
(newCategory: string) => {
|
||||
router.push(`/plugins/${newCategory}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
if (isAtMarketplace) {
|
||||
return [validatedCategory, handleChange] as const
|
||||
}
|
||||
return [getValidatedPluginCategory(category), setCategory] as const
|
||||
}
|
||||
|
||||
export function useActiveTemplateCategory() {
|
||||
const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom)
|
||||
|
||||
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
const categoryFromPath = segments[1] || CATEGORY_ALL
|
||||
const validatedCategory = getValidatedTemplateCategory(categoryFromPath)
|
||||
const handleChange = useCallback(
|
||||
(newCategory: string) => {
|
||||
router.push(`/${CREATION_TYPE.templates}/${newCategory}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
if (isAtMarketplace) {
|
||||
return [validatedCategory, handleChange] as const
|
||||
}
|
||||
return [getValidatedTemplateCategory(category), setCategory] as const
|
||||
}
|
||||
export function useFilterPluginTags() {
|
||||
return useQueryState('tags', marketplaceSearchParamsParsers.tags)
|
||||
}
|
||||
|
||||
export function useFilterTemplateLanguages() {
|
||||
return useQueryState('languages', marketplaceSearchParamsParsers.languages)
|
||||
}
|
||||
|
||||
export function useSearchTab() {
|
||||
const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom)
|
||||
|
||||
const state = useQueryState('searchTab', marketplaceSearchParamsParsers.searchTab)
|
||||
|
||||
const router = useRouter()
|
||||
// /search/[searchTab]
|
||||
const { searchTab } = useParams()
|
||||
const handleChange = useCallback(
|
||||
(newTab: string) => {
|
||||
const location = new URL(window.location.href)
|
||||
location.pathname = `/search/${newTab}`
|
||||
router.push(location.href)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
if (isAtMarketplace) {
|
||||
return [searchTab, handleChange] as const
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export function useCreationType() {
|
||||
const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom)
|
||||
|
||||
const [creationType] = useQueryState('creationType', marketplaceSearchParamsParsers.creationType)
|
||||
|
||||
const pathname = usePathname()
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
|
||||
if (isAtMarketplace) {
|
||||
if (segments[0] === CREATION_TYPE.templates || segments[0] === 'template')
|
||||
return CREATION_TYPE.templates
|
||||
return CREATION_TYPE.plugins
|
||||
}
|
||||
return creationType
|
||||
}
|
||||
|
||||
// Search-page-specific filter hooks (separate from list-page category/tags)
|
||||
export function useSearchFilterCategories() {
|
||||
return useQueryState('searchCategories', marketplaceSearchParamsParsers.searchCategories)
|
||||
}
|
||||
|
||||
export function useSearchFilterLanguages() {
|
||||
return useQueryState('searchLanguages', marketplaceSearchParamsParsers.searchLanguages)
|
||||
}
|
||||
|
||||
export function useSearchFilterType() {
|
||||
const [type, setType] = useQueryState('searchType', marketplaceSearchParamsParsers.searchType)
|
||||
return [getValidatedPluginCategory(type), setType] as const
|
||||
}
|
||||
|
||||
export function useSearchFilterTags() {
|
||||
return useQueryState('searchTags', marketplaceSearchParamsParsers.searchTags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Not all categories have collections, so we need to
|
||||
* force the search mode for those categories.
|
||||
@ -33,30 +151,74 @@ export function useFilterPluginTags() {
|
||||
export const searchModeAtom = atom<true | null>(null)
|
||||
|
||||
export function useMarketplaceSearchMode() {
|
||||
const [searchPluginText] = useSearchPluginText()
|
||||
const creationType = useCreationType()
|
||||
const [searchText] = useSearchText()
|
||||
const [searchTab] = useSearchTab()
|
||||
const [filterPluginTags] = useFilterPluginTags()
|
||||
const [activePluginType] = useActivePluginType()
|
||||
const [filterTemplateLanguages] = useFilterTemplateLanguages()
|
||||
const [activePluginCategory] = useActivePluginCategory()
|
||||
const [activeTemplateCategory] = useActiveTemplateCategory()
|
||||
const isPluginsView = creationType === CREATION_TYPE.plugins
|
||||
|
||||
const searchMode = useAtomValue(searchModeAtom)
|
||||
const isSearchMode = !!searchPluginText
|
||||
|| filterPluginTags.length > 0
|
||||
|| (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType)))
|
||||
const isSearchMode = searchTab || searchText
|
||||
|| (isPluginsView && filterPluginTags.length > 0)
|
||||
|| (searchMode ?? (isPluginsView && !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginCategory)))
|
||||
|| (!isPluginsView && activeTemplateCategory !== CATEGORY_ALL)
|
||||
|| (!isPluginsView && filterTemplateLanguages.length > 0)
|
||||
return isSearchMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active sort state based on the current creationType.
|
||||
* Plugins use `marketplacePluginSortAtom`, templates use `marketplaceTemplateSortAtom`.
|
||||
*/
|
||||
export function useActiveSort(): [PluginsSort, (sort: PluginsSort) => void] {
|
||||
const creationType = useCreationType()
|
||||
const [pluginSort, setPluginSort] = useAtom(marketplacePluginSortAtom)
|
||||
const [templateSort, setTemplateSort] = useAtom(marketplaceTemplateSortAtom)
|
||||
const isTemplates = creationType === CREATION_TYPE.templates
|
||||
|
||||
const sort = isTemplates ? templateSort : pluginSort
|
||||
const setSort = useMemo(
|
||||
() => isTemplates ? setTemplateSort : setPluginSort,
|
||||
[isTemplates, setTemplateSort, setPluginSort],
|
||||
)
|
||||
return [sort, setSort]
|
||||
}
|
||||
|
||||
export function useActiveSortValue(): PluginsSort {
|
||||
const creationType = useCreationType()
|
||||
const pluginSort = useAtomValue(marketplacePluginSortAtom)
|
||||
const templateSort = useAtomValue(marketplaceTemplateSortAtom)
|
||||
return creationType === CREATION_TYPE.templates ? templateSort : pluginSort
|
||||
}
|
||||
|
||||
export function useMarketplaceMoreClick() {
|
||||
const [,setQ] = useSearchPluginText()
|
||||
const setSort = useSetAtom(marketplaceSortAtom)
|
||||
const [, setQ] = useSearchText()
|
||||
const [, setSearchTab] = useSearchTab()
|
||||
const setPluginSort = useSetAtom(marketplacePluginSortAtom)
|
||||
const setTemplateSort = useSetAtom(marketplaceTemplateSortAtom)
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
|
||||
return useCallback((searchParams?: SearchParamsFromCollection) => {
|
||||
return useCallback((searchParams?: SearchParamsFromCollection, searchTab?: SearchTab) => {
|
||||
if (!searchParams)
|
||||
return
|
||||
setQ(searchParams?.query || '')
|
||||
setSort({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
|
||||
})
|
||||
if (searchTab === 'templates') {
|
||||
setTemplateSort({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_TEMPLATE_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_TEMPLATE_SORT.sortOrder,
|
||||
})
|
||||
}
|
||||
else {
|
||||
setPluginSort({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_PLUGIN_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_PLUGIN_SORT.sortOrder,
|
||||
})
|
||||
}
|
||||
setSearchMode(true)
|
||||
}, [setQ, setSort, setSearchMode])
|
||||
if (searchTab)
|
||||
setSearchTab(searchTab)
|
||||
}, [setQ, setSearchTab, setPluginSort, setTemplateSort, setSearchMode])
|
||||
}
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import type { ActivePluginType, ActiveTemplateCategory } from '../constants'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP, TEMPLATE_CATEGORY_MAP } from '../constants'
|
||||
|
||||
/**
|
||||
* Returns a getter that translates a plugin category value to its display text.
|
||||
* Pass `allAsAllTypes = true` to use "All types" instead of "All" for the `all` category
|
||||
* (e.g. hero variant in category switch).
|
||||
*/
|
||||
export function usePluginCategoryText() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (category: ActivePluginType, allAsAllTypes = false): string => {
|
||||
switch (category) {
|
||||
case PLUGIN_TYPE_SEARCH_MAP.model:
|
||||
return t('category.models', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.tool:
|
||||
return t('category.tools', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.datasource:
|
||||
return t('category.datasources', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.trigger:
|
||||
return t('category.triggers', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.agent:
|
||||
return t('category.agents', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.extension:
|
||||
return t('category.extensions', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.bundle:
|
||||
return t('category.bundles', { ns: 'plugin' })
|
||||
case PLUGIN_TYPE_SEARCH_MAP.all:
|
||||
default:
|
||||
return allAsAllTypes
|
||||
? t('category.allTypes', { ns: 'plugin' })
|
||||
: t('category.all', { ns: 'plugin' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a getter that translates a template category value to its display text.
|
||||
*/
|
||||
export function useTemplateCategoryText() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (category: ActiveTemplateCategory): string => {
|
||||
switch (category) {
|
||||
case TEMPLATE_CATEGORY_MAP.marketing:
|
||||
return t('marketplace.templateCategory.marketing', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.sales:
|
||||
return t('marketplace.templateCategory.sales', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.support:
|
||||
return t('marketplace.templateCategory.support', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.operations:
|
||||
return t('marketplace.templateCategory.operations', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.it:
|
||||
return t('marketplace.templateCategory.it', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.knowledge:
|
||||
return t('marketplace.templateCategory.knowledge', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.design:
|
||||
return t('marketplace.templateCategory.design', { ns: 'plugin' })
|
||||
case TEMPLATE_CATEGORY_MAP.all:
|
||||
default:
|
||||
return t('marketplace.templateCategory.all', { ns: 'plugin' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export type CategoryOption = {
|
||||
value: string
|
||||
text: string
|
||||
icon: React.ReactNode | null
|
||||
}
|
||||
|
||||
type CategorySwitchProps = {
|
||||
className?: string
|
||||
variant?: 'default' | 'hero'
|
||||
options: CategoryOption[]
|
||||
activeValue: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const CommonCategorySwitch = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
options,
|
||||
activeValue,
|
||||
onChange,
|
||||
}: CategorySwitchProps) => {
|
||||
const isHeroVariant = variant === 'hero'
|
||||
|
||||
const getItemClassName = (isActive: boolean) => {
|
||||
if (isHeroVariant) {
|
||||
return cn(
|
||||
'system-md-medium flex h-8 cursor-pointer items-center rounded-lg px-3 text-text-primary-on-surface transition-all',
|
||||
isActive
|
||||
? 'bg-components-button-secondary-bg text-saas-dify-blue-inverted'
|
||||
: 'hover:bg-state-base-hover',
|
||||
)
|
||||
}
|
||||
return cn(
|
||||
'system-md-medium flex h-8 cursor-pointer items-center rounded-xl border border-transparent px-3 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
isActive && 'border-components-main-nav-nav-button-border !bg-components-main-nav-nav-button-bg-active !text-components-main-nav-nav-button-text-active shadow-xs',
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center space-x-2',
|
||||
!isHeroVariant && 'justify-center bg-background-body py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={getItemClassName(activeValue === option.value)}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.icon}
|
||||
{option.text}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiArrowDownSLine, RiCloseCircleFill, RiGlobalLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { LANGUAGE_OPTIONS } from '../search-page/constants'
|
||||
|
||||
type HeroLanguagesFilterProps = {
|
||||
languages: string[]
|
||||
onLanguagesChange: (languages: string[]) => void
|
||||
}
|
||||
|
||||
const LANGUAGE_LABEL_MAP: Record<string, string> = LANGUAGE_OPTIONS.reduce((acc, option) => {
|
||||
acc[option.value] = option.nativeLabel
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
const HeroLanguagesFilter = ({
|
||||
languages,
|
||||
onLanguagesChange,
|
||||
}: HeroLanguagesFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const selectedLanguagesLength = languages.length
|
||||
const hasSelected = selectedLanguagesLength > 0
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchText)
|
||||
return LANGUAGE_OPTIONS
|
||||
const normalizedSearchText = searchText.toLowerCase()
|
||||
return LANGUAGE_OPTIONS.filter(option =>
|
||||
option.nativeLabel.toLowerCase().includes(normalizedSearchText)
|
||||
|| option.label.toLowerCase().includes(normalizedSearchText),
|
||||
)
|
||||
}, [searchText])
|
||||
|
||||
const handleCheck = (value: string) => {
|
||||
if (languages.includes(value))
|
||||
onLanguagesChange(languages.filter(language => language !== value))
|
||||
else
|
||||
onLanguagesChange([...languages, value])
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -6,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="shrink-0"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg px-2.5 py-1.5',
|
||||
!hasSelected && 'border border-white/30 text-text-primary-on-surface',
|
||||
!hasSelected && open && 'bg-state-base-hover',
|
||||
!hasSelected && !open && 'hover:bg-state-base-hover',
|
||||
hasSelected && 'border-effect-highlight border bg-components-button-secondary-bg-hover shadow-md backdrop-blur-[5px]',
|
||||
)}
|
||||
>
|
||||
<RiGlobalLine
|
||||
className={cn(
|
||||
'size-4 shrink-0',
|
||||
hasSelected ? 'text-saas-dify-blue-inverted' : 'text-text-primary-on-surface',
|
||||
)}
|
||||
/>
|
||||
<div className="system-md-medium flex items-center gap-0.5">
|
||||
{!hasSelected && (
|
||||
<span>{t('marketplace.searchFilterLanguage', { ns: 'plugin' })}</span>
|
||||
)}
|
||||
{hasSelected && (
|
||||
<span className="text-saas-dify-blue-inverted">
|
||||
{languages
|
||||
.map(language => LANGUAGE_LABEL_MAP[language])
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{selectedLanguagesLength > 2 && (
|
||||
<div className="flex min-w-4 items-center justify-center rounded-[5px] border border-saas-dify-blue-inverted px-1 py-0.5">
|
||||
<span className="system-2xs-medium-uppercase text-saas-dify-blue-inverted">
|
||||
+
|
||||
{selectedLanguagesLength - 2}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasSelected && (
|
||||
<RiCloseCircleFill
|
||||
className="size-4 shrink-0 text-saas-dify-blue-inverted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onLanguagesChange([])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!hasSelected && (
|
||||
<RiArrowDownSLine className="size-4 shrink-0 text-text-primary-on-surface" />
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1000]">
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
placeholder={t('marketplace.searchFilterLanguage', { ns: 'plugin' })}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[448px] overflow-y-auto p-1">
|
||||
{filteredOptions.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => handleCheck(option.value)}
|
||||
>
|
||||
<Checkbox
|
||||
className="mr-1"
|
||||
checked={languages.includes(option.value)}
|
||||
/>
|
||||
<div className="system-sm-medium px-1 text-text-secondary">
|
||||
{option.nativeLabel}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HeroLanguagesFilter)
|
||||
@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import HeroTagsTrigger from './hero-tags-trigger'
|
||||
|
||||
type HeroTagsFilterProps = {
|
||||
tags: string[]
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
const HeroTagsFilter = ({
|
||||
tags,
|
||||
onTagsChange,
|
||||
}: HeroTagsFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { tags: options, tagsMap } = useTags()
|
||||
const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
|
||||
const handleCheck = (id: string) => {
|
||||
if (tags.includes(id))
|
||||
onTagsChange(tags.filter((tag: string) => tag !== id))
|
||||
else
|
||||
onTagsChange([...tags, id])
|
||||
}
|
||||
const selectedTagsLength = tags.length
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -6,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="shrink-0"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<HeroTagsTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1000]">
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
placeholder={t('searchTags', { ns: 'pluginTags' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[448px] overflow-y-auto p-1">
|
||||
{
|
||||
filteredOptions.map(option => (
|
||||
<div
|
||||
key={option.name}
|
||||
className="flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => handleCheck(option.name)}
|
||||
>
|
||||
<Checkbox
|
||||
className="mr-1"
|
||||
checked={tags.includes(option.name)}
|
||||
/>
|
||||
<div className="system-sm-medium px-1 text-text-secondary">
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeroTagsFilter
|
||||
@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import type { Tag } from '../../hooks'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiArrowDownSLine, RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type HeroTagsTriggerProps = {
|
||||
selectedTagsLength: number
|
||||
open: boolean
|
||||
tags: string[]
|
||||
tagsMap: Record<string, Tag>
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
const HeroTagsTrigger = ({
|
||||
selectedTagsLength,
|
||||
open,
|
||||
tags,
|
||||
tagsMap,
|
||||
onTagsChange,
|
||||
}: HeroTagsTriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const hasSelected = !!selectedTagsLength
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg px-2.5 py-1.5',
|
||||
!hasSelected && 'border border-white/30 text-text-primary-on-surface',
|
||||
!hasSelected && open && 'bg-state-base-hover',
|
||||
!hasSelected && !open && 'hover:bg-state-base-hover',
|
||||
hasSelected && 'border-effect-highlight border bg-components-button-secondary-bg-hover shadow-md backdrop-blur-[5px]',
|
||||
)}
|
||||
>
|
||||
<RiPriceTag3Line className={cn(
|
||||
'size-4 shrink-0',
|
||||
hasSelected ? 'text-saas-dify-blue-inverted' : 'text-text-primary-on-surface',
|
||||
)}
|
||||
/>
|
||||
<div className="system-md-medium flex items-center gap-0.5">
|
||||
{
|
||||
!hasSelected && (
|
||||
<span>{t('allTags', { ns: 'pluginTags' })}</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
hasSelected && (
|
||||
<span className="text-saas-dify-blue-inverted">
|
||||
{tags.map(tag => tagsMap[tag]?.label).filter(Boolean).slice(0, 2).join(', ')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className="flex min-w-4 items-center justify-center rounded-[5px] border border-saas-dify-blue-inverted px-1 py-0.5">
|
||||
<span className="system-2xs-medium-uppercase text-saas-dify-blue-inverted">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
hasSelected && (
|
||||
<RiCloseCircleFill
|
||||
className="size-4 shrink-0 text-saas-dify-blue-inverted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTagsChange([])
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!hasSelected && (
|
||||
<RiArrowDownSLine className="size-4 shrink-0 text-text-primary-on-surface" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HeroTagsTrigger)
|
||||
@ -0,0 +1,4 @@
|
||||
'use client'
|
||||
|
||||
export { PluginCategorySwitch } from './plugin'
|
||||
export { TemplateCategorySwitch } from './template'
|
||||
@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import type { ActivePluginType } from '../constants'
|
||||
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { RiArchive2Line } from '@remixicon/react'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { Plugin } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { searchModeAtom, useActivePluginCategory, useFilterPluginTags } from '../atoms'
|
||||
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from '../constants'
|
||||
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../plugin-type-icons'
|
||||
import { usePluginCategoryText } from './category-text'
|
||||
import { CommonCategorySwitch } from './common'
|
||||
import HeroTagsFilter from './hero-tags-filter'
|
||||
|
||||
type PluginTypeSwitchProps = {
|
||||
className?: string
|
||||
variant?: 'default' | 'hero'
|
||||
}
|
||||
|
||||
const categoryValues = [
|
||||
PLUGIN_TYPE_SEARCH_MAP.all,
|
||||
PLUGIN_TYPE_SEARCH_MAP.model,
|
||||
PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
PLUGIN_TYPE_SEARCH_MAP.datasource,
|
||||
PLUGIN_TYPE_SEARCH_MAP.trigger,
|
||||
PLUGIN_TYPE_SEARCH_MAP.agent,
|
||||
PLUGIN_TYPE_SEARCH_MAP.extension,
|
||||
PLUGIN_TYPE_SEARCH_MAP.bundle,
|
||||
] as const
|
||||
|
||||
const getTypeIcon = (value: ActivePluginType, isHeroVariant?: boolean) => {
|
||||
if (value === PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
return isHeroVariant ? <Plugin className="mr-1.5 h-4 w-4" /> : null
|
||||
if (value === PLUGIN_TYPE_SEARCH_MAP.bundle)
|
||||
return <RiArchive2Line className="mr-1.5 h-4 w-4" />
|
||||
const Icon = MARKETPLACE_TYPE_ICON_COMPONENTS[value as PluginCategoryEnum]
|
||||
return Icon ? <Icon className="mr-1.5 h-4 w-4" /> : null
|
||||
}
|
||||
|
||||
export const PluginCategorySwitch = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
}: PluginTypeSwitchProps) => {
|
||||
const [activePluginCategory, handleActivePluginCategoryChange] = useActivePluginCategory()
|
||||
const [filterPluginTags, setFilterPluginTags] = useFilterPluginTags()
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
const getPluginCategoryText = usePluginCategoryText()
|
||||
|
||||
const isHeroVariant = variant === 'hero'
|
||||
|
||||
const options = categoryValues.map(value => ({
|
||||
value,
|
||||
text: getPluginCategoryText(value, isHeroVariant),
|
||||
icon: getTypeIcon(value, isHeroVariant),
|
||||
}))
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
handleActivePluginCategoryChange(value)
|
||||
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(value as ActivePluginType)) {
|
||||
setSearchMode(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isHeroVariant) {
|
||||
return (
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activePluginCategory}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<HeroTagsFilter
|
||||
tags={filterPluginTags}
|
||||
onTagsChange={tags => setFilterPluginTags(tags.length ? tags : null)}
|
||||
/>
|
||||
<div className="text-text-primary-on-surface">
|
||||
·
|
||||
</div>
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activePluginCategory}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { Playground } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { useActiveTemplateCategory, useFilterTemplateLanguages } from '../atoms'
|
||||
import { CATEGORY_ALL, TEMPLATE_CATEGORY_MAP } from '../constants'
|
||||
import { useTemplateCategoryText } from './category-text'
|
||||
import { CommonCategorySwitch } from './common'
|
||||
import HeroLanguagesFilter from './hero-languages-filter'
|
||||
|
||||
type TemplateCategorySwitchProps = {
|
||||
className?: string
|
||||
variant?: 'default' | 'hero'
|
||||
}
|
||||
|
||||
const categoryValues = [
|
||||
CATEGORY_ALL,
|
||||
TEMPLATE_CATEGORY_MAP.marketing,
|
||||
TEMPLATE_CATEGORY_MAP.sales,
|
||||
TEMPLATE_CATEGORY_MAP.support,
|
||||
TEMPLATE_CATEGORY_MAP.operations,
|
||||
TEMPLATE_CATEGORY_MAP.it,
|
||||
TEMPLATE_CATEGORY_MAP.knowledge,
|
||||
TEMPLATE_CATEGORY_MAP.design,
|
||||
] as const
|
||||
|
||||
export const TemplateCategorySwitch = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
}: TemplateCategorySwitchProps) => {
|
||||
const [activeTemplateCategory, handleActiveTemplateCategoryChange] = useActiveTemplateCategory()
|
||||
const [filterTemplateLanguages, setFilterTemplateLanguages] = useFilterTemplateLanguages()
|
||||
const getTemplateCategoryText = useTemplateCategoryText()
|
||||
|
||||
const isHeroVariant = variant === 'hero'
|
||||
|
||||
const options = categoryValues.map(value => ({
|
||||
value,
|
||||
text: getTemplateCategoryText(value),
|
||||
icon: value === CATEGORY_ALL && isHeroVariant ? <Playground className="mr-1.5 h-4 w-4" /> : null,
|
||||
}))
|
||||
|
||||
if (!isHeroVariant) {
|
||||
return (
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activeTemplateCategory}
|
||||
onChange={handleActiveTemplateCategoryChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<HeroLanguagesFilter
|
||||
languages={filterTemplateLanguages}
|
||||
onLanguagesChange={languages => setFilterTemplateLanguages(languages.length ? languages : null)}
|
||||
/>
|
||||
<div className="text-text-primary-on-surface">
|
||||
·
|
||||
</div>
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activeTemplateCategory}
|
||||
onChange={handleActiveTemplateCategoryChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
web/app/components/plugins/marketplace/constants.spec.ts
Normal file
19
web/app/components/plugins/marketplace/constants.spec.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getValidatedPluginCategory } from './constants'
|
||||
|
||||
describe('getValidatedPluginCategory', () => {
|
||||
it('returns agent-strategy when query value is agent-strategy', () => {
|
||||
expect(getValidatedPluginCategory('agent-strategy')).toBe('agent-strategy')
|
||||
})
|
||||
|
||||
it('returns valid category values unchanged', () => {
|
||||
expect(getValidatedPluginCategory('model')).toBe('model')
|
||||
expect(getValidatedPluginCategory('tool')).toBe('tool')
|
||||
expect(getValidatedPluginCategory('bundle')).toBe('bundle')
|
||||
})
|
||||
|
||||
it('falls back to all for invalid category values', () => {
|
||||
expect(getValidatedPluginCategory('agent')).toBe('all')
|
||||
expect(getValidatedPluginCategory('invalid-category')).toBe('all')
|
||||
})
|
||||
})
|
||||
@ -1,14 +1,21 @@
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
|
||||
export const DEFAULT_SORT = {
|
||||
export const DEFAULT_PLUGIN_SORT = {
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
}
|
||||
|
||||
export const DEFAULT_TEMPLATE_SORT = {
|
||||
sortBy: 'usage_count',
|
||||
sortOrder: 'DESC',
|
||||
}
|
||||
|
||||
export const SCROLL_BOTTOM_THRESHOLD = 100
|
||||
|
||||
export const CATEGORY_ALL = 'all'
|
||||
|
||||
export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
all: 'all',
|
||||
[CATEGORY_ALL]: CATEGORY_ALL,
|
||||
model: PluginCategoryEnum.model,
|
||||
tool: PluginCategoryEnum.tool,
|
||||
agent: PluginCategoryEnum.agent,
|
||||
@ -21,6 +28,7 @@ export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
type ValueOf<T> = T[keyof T]
|
||||
|
||||
export type ActivePluginType = ValueOf<typeof PLUGIN_TYPE_SEARCH_MAP>
|
||||
const VALID_PLUGIN_CATEGORIES = new Set<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP))
|
||||
|
||||
export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
|
||||
[
|
||||
@ -28,3 +36,28 @@ export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
|
||||
PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
],
|
||||
)
|
||||
|
||||
export const TEMPLATE_CATEGORY_MAP = {
|
||||
[CATEGORY_ALL]: CATEGORY_ALL,
|
||||
marketing: 'marketing',
|
||||
sales: 'sales',
|
||||
support: 'support',
|
||||
operations: 'operations',
|
||||
it: 'it',
|
||||
knowledge: 'knowledge',
|
||||
design: 'design',
|
||||
} as const
|
||||
|
||||
export type ActiveTemplateCategory = typeof TEMPLATE_CATEGORY_MAP[keyof typeof TEMPLATE_CATEGORY_MAP]
|
||||
|
||||
export function getValidatedPluginCategory(category: string): ActivePluginType {
|
||||
if (VALID_PLUGIN_CATEGORIES.has(category as ActivePluginType))
|
||||
return category as ActivePluginType
|
||||
|
||||
return CATEGORY_ALL
|
||||
}
|
||||
|
||||
export function getValidatedTemplateCategory(category: string): ActiveTemplateCategory {
|
||||
const key = (category in TEMPLATE_CATEGORY_MAP ? category : CATEGORY_ALL) as keyof typeof TEMPLATE_CATEGORY_MAP
|
||||
return TEMPLATE_CATEGORY_MAP[key]
|
||||
}
|
||||
|
||||
@ -1,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
|
||||
|
||||
@ -1,72 +1,236 @@
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
'use client'
|
||||
|
||||
const Description = () => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const locale = useLocale()
|
||||
import type { MotionValue } from 'motion/react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { motion, useMotionValue, useSpring, useTransform } from 'motion/react'
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import marketPlaceBg from '@/public/marketplace/hero-bg.jpg'
|
||||
import marketplaceGradientNoise from '@/public/marketplace/hero-gradient-noise.svg'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useCreationType } from '../atoms'
|
||||
import { PluginCategorySwitch, TemplateCategorySwitch } from '../category-switch/index'
|
||||
import { CREATION_TYPE } from '../search-params'
|
||||
|
||||
const isZhHans = locale === 'zh-Hans'
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title-4xl-semi-bold mb-2 shrink-0 text-center text-text-primary">
|
||||
{t('marketplace.empower')}
|
||||
</h1>
|
||||
<h2 className="body-md-regular flex shrink-0 items-center justify-center text-center text-text-tertiary">
|
||||
{
|
||||
isZhHans && (
|
||||
<>
|
||||
<span className="mr-1">{tCommon('operation.in')}</span>
|
||||
{t('marketplace.difyMarketplace')}
|
||||
{t('marketplace.discover')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isZhHans && (
|
||||
<>
|
||||
{t('marketplace.discover')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.models')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.tools')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.datasources')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.triggers')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.agents')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.extensions')}
|
||||
</span>
|
||||
{t('marketplace.and')}
|
||||
<span className="body-md-medium relative z-[1] ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.bundles')}
|
||||
</span>
|
||||
{
|
||||
!isZhHans && (
|
||||
<>
|
||||
<span className="mr-1">{tCommon('operation.in')}</span>
|
||||
{t('marketplace.difyMarketplace')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</h2>
|
||||
</>
|
||||
)
|
||||
type DescriptionProps = {
|
||||
className?: string
|
||||
scrollContainerId?: string
|
||||
marketplaceNav?: React.ReactNode
|
||||
}
|
||||
|
||||
export default Description
|
||||
// Constants for collapse animation
|
||||
const MAX_SCROLL = 120 // pixels to fully collapse
|
||||
const EXPANDED_PADDING_TOP = 32 // pt-8
|
||||
const COLLAPSED_PADDING_TOP = 12 // pt-3
|
||||
const EXPANDED_PADDING_BOTTOM = 24 // pb-6
|
||||
const COLLAPSED_PADDING_BOTTOM = 12 // pb-3
|
||||
|
||||
export const Description = ({
|
||||
className,
|
||||
scrollContainerId = 'marketplace-container',
|
||||
marketplaceNav,
|
||||
}: DescriptionProps) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const creationType = useCreationType()
|
||||
const isTemplatesView = creationType === CREATION_TYPE.templates
|
||||
const heroTitleKey = isTemplatesView ? 'marketplace.templatesHeroTitle' : 'marketplace.pluginsHeroTitle'
|
||||
const heroSubtitleKey = isTemplatesView ? 'marketplace.templatesHeroSubtitle' : 'marketplace.pluginsHeroSubtitle'
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const lastProgressRef = useRef(0)
|
||||
const headerRef = useRef<HTMLDivElement | null>(null)
|
||||
const titleContentRef = useRef<HTMLDivElement | null>(null)
|
||||
const progress = useMotionValue(0)
|
||||
const titleHeight = useMotionValue(72)
|
||||
const smoothProgress = useSpring(progress, { stiffness: 260, damping: 34 })
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = titleContentRef.current
|
||||
if (!node)
|
||||
return
|
||||
|
||||
const updateHeight = () => {
|
||||
titleHeight.set(node.scrollHeight)
|
||||
}
|
||||
|
||||
updateHeight()
|
||||
|
||||
if (typeof ResizeObserver === 'undefined')
|
||||
return
|
||||
|
||||
const observer = new ResizeObserver(updateHeight)
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [titleHeight])
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(scrollContainerId)
|
||||
if (!container)
|
||||
return
|
||||
|
||||
const handleScroll = () => {
|
||||
// Cancel any pending animation frame
|
||||
if (rafRef.current)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
|
||||
// Use requestAnimationFrame for smooth updates
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const scrollTop = Math.round(container.scrollTop)
|
||||
const heightDelta = container.scrollHeight - container.clientHeight
|
||||
const effectiveMaxScroll = Math.max(1, Math.min(MAX_SCROLL, heightDelta))
|
||||
const rawProgress = Math.min(Math.max(scrollTop / effectiveMaxScroll, 0), 1)
|
||||
const snappedProgress = rawProgress >= 0.95
|
||||
? 1
|
||||
: rawProgress <= 0.05
|
||||
? 0
|
||||
: Math.round(rawProgress * 100) / 100
|
||||
|
||||
if (snappedProgress !== lastProgressRef.current) {
|
||||
lastProgressRef.current = snappedProgress
|
||||
progress.set(snappedProgress)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
// Initial check
|
||||
handleScroll()
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
if (rafRef.current)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}, [progress, scrollContainerId])
|
||||
|
||||
// Calculate interpolated values
|
||||
const contentOpacity = useTransform(smoothProgress, [0, 1], [1, 0])
|
||||
const contentScale = useTransform(smoothProgress, [0, 1], [1, 0.9])
|
||||
const titleMaxHeight: MotionValue<number> = useTransform(
|
||||
[smoothProgress, titleHeight],
|
||||
(values: number[]) => values[1] * (1 - values[0]),
|
||||
)
|
||||
const tabsMarginTop = useTransform(smoothProgress, [0, 1], [48, marketplaceNav ? 16 : 0])
|
||||
const titleMarginTop = useTransform(smoothProgress, [0, 1], [marketplaceNav ? 80 : 0, 0])
|
||||
const paddingTop = useTransform(smoothProgress, [0, 1], [marketplaceNav ? COLLAPSED_PADDING_TOP : EXPANDED_PADDING_TOP, COLLAPSED_PADDING_TOP])
|
||||
const paddingBottom = useTransform(smoothProgress, [0, 1], [EXPANDED_PADDING_BOTTOM, COLLAPSED_PADDING_BOTTOM])
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(scrollContainerId)
|
||||
const header = headerRef.current
|
||||
if (!container || !header)
|
||||
return
|
||||
|
||||
let maxHeaderHeight = 0
|
||||
let lastAppliedOffset = 0
|
||||
const updateOffset = () => {
|
||||
const currentHeaderHeight = Math.round(header.getBoundingClientRect().height)
|
||||
maxHeaderHeight = Math.max(maxHeaderHeight, currentHeaderHeight)
|
||||
const collapsedHeight = Math.max(0, maxHeaderHeight - currentHeaderHeight)
|
||||
const currentScrollableTop = container.scrollHeight - container.clientHeight
|
||||
const baseScrollableTop = Math.max(0, currentScrollableTop - lastAppliedOffset)
|
||||
const shouldCompensate = baseScrollableTop <= maxHeaderHeight
|
||||
const nextOffset = shouldCompensate ? collapsedHeight : 0
|
||||
const offsetDelta = nextOffset - lastAppliedOffset
|
||||
|
||||
if (nextOffset > 0) {
|
||||
// Only compensate when content is short enough that header collapse can clamp scrollTop.
|
||||
container.style.setProperty('--marketplace-header-collapse-offset', `${nextOffset}px`)
|
||||
if (offsetDelta !== 0 && container.scrollTop > 0)
|
||||
container.scrollTop = Math.max(0, container.scrollTop + offsetDelta)
|
||||
}
|
||||
else {
|
||||
container.style.removeProperty('--marketplace-header-collapse-offset')
|
||||
}
|
||||
|
||||
lastAppliedOffset = nextOffset
|
||||
}
|
||||
|
||||
updateOffset()
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return () => {
|
||||
container.style.removeProperty('--marketplace-header-collapse-offset')
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateOffset)
|
||||
observer.observe(header)
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
container.style.removeProperty('--marketplace-header-collapse-offset')
|
||||
}
|
||||
}, [scrollContainerId])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={headerRef}
|
||||
className={cn(
|
||||
'sticky top-[60px] z-20 mx-4 mt-4 shrink-0 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border px-6',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
}}
|
||||
>
|
||||
{/* Blue base background */}
|
||||
<div className="absolute inset-0 bg-[rgba(0,51,255,0.9)]" />
|
||||
|
||||
{/* Decorative image with blend mode - showing top 1/3 of the image */}
|
||||
<div
|
||||
className="absolute inset-0 bg-no-repeat opacity-80 mix-blend-lighten"
|
||||
style={{
|
||||
backgroundImage: `url(${marketPlaceBg.src})`,
|
||||
backgroundSize: '110% auto',
|
||||
backgroundPosition: 'center top',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient & Noise overlay */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${marketplaceGradientNoise.src})` }}
|
||||
/>
|
||||
|
||||
{marketplaceNav}
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10">
|
||||
{/* Title and subtitle - fade out and scale down */}
|
||||
<motion.div
|
||||
style={{
|
||||
opacity: contentOpacity,
|
||||
scale: contentScale,
|
||||
transformOrigin: 'left top',
|
||||
maxHeight: titleMaxHeight,
|
||||
overflow: 'hidden',
|
||||
willChange: 'opacity, transform',
|
||||
marginTop: titleMarginTop,
|
||||
}}
|
||||
>
|
||||
<div ref={titleContentRef}>
|
||||
<h1 className="title-4xl-semi-bold mb-2 shrink-0 text-text-primary-on-surface">
|
||||
{t(heroTitleKey)}
|
||||
</h1>
|
||||
<h2 className="body-md-regular shrink-0 text-text-secondary-on-surface">
|
||||
{t(heroSubtitleKey)}
|
||||
</h2>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Category switch tabs - Plugin or Template based on creationType */}
|
||||
<motion.div style={{ marginTop: tabsMarginTop }}>
|
||||
{isTemplatesView
|
||||
? (
|
||||
<TemplateCategorySwitch variant="hero" />
|
||||
)
|
||||
: (
|
||||
<PluginCategorySwitch variant="hero" />
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import type {
|
||||
} from '../types'
|
||||
import type {
|
||||
CollectionsAndPluginsSearchParams,
|
||||
MarketplaceCollection,
|
||||
PluginCollection,
|
||||
PluginsSearchParams,
|
||||
} from './types'
|
||||
import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
|
||||
@ -31,8 +31,8 @@ import {
|
||||
*/
|
||||
export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>()
|
||||
const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
|
||||
const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
|
||||
const [pluginCollectionsOverride, setPluginCollections] = useState<PluginCollection[]>()
|
||||
const [pluginCollectionPluginsMapOverride, setPluginCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
|
||||
|
||||
const {
|
||||
data,
|
||||
@ -54,10 +54,10 @@ export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
const isLoading = !!queryParams && (isFetching || isPending)
|
||||
|
||||
return {
|
||||
marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections,
|
||||
setMarketplaceCollections,
|
||||
marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap,
|
||||
setMarketplaceCollectionPluginsMap,
|
||||
pluginCollections: pluginCollectionsOverride ?? data?.marketplaceCollections,
|
||||
setPluginCollections,
|
||||
pluginCollectionPluginsMap: pluginCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap,
|
||||
setPluginCollectionPluginsMap,
|
||||
queryMarketplaceCollectionsAndPlugins,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
|
||||
17
web/app/components/plugins/marketplace/hydration-client.tsx
Normal file
17
web/app/components/plugins/marketplace/hydration-client.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useHydrateAtoms } from 'jotai/utils'
|
||||
import { isMarketplacePlatformAtom } from './atoms'
|
||||
|
||||
export function HydrateClient({
|
||||
isMarketplacePlatform = false,
|
||||
children,
|
||||
}: {
|
||||
isMarketplacePlatform?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
useHydrateAtoms([
|
||||
[isMarketplacePlatformAtom, isMarketplacePlatform],
|
||||
])
|
||||
return <>{children}</>
|
||||
}
|
||||
@ -1,42 +1,270 @@
|
||||
import type { SearchParams } from 'nuqs'
|
||||
import type { CreatorSearchParams, PluginsSearchParams, TemplateSearchParams } from './types'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { headers } from 'next/headers'
|
||||
import { createLoader } from 'nuqs/server'
|
||||
import { getQueryClientServer } from '@/context/query-client-server'
|
||||
import { marketplaceQuery } from '@/service/client'
|
||||
import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { marketplaceSearchParamsParsers } from './search-params'
|
||||
import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils'
|
||||
import {
|
||||
CATEGORY_ALL,
|
||||
DEFAULT_PLUGIN_SORT,
|
||||
DEFAULT_TEMPLATE_SORT,
|
||||
getValidatedPluginCategory,
|
||||
getValidatedTemplateCategory,
|
||||
PLUGIN_CATEGORY_WITH_COLLECTIONS,
|
||||
PLUGIN_TYPE_SEARCH_MAP,
|
||||
} from './constants'
|
||||
import { CREATION_TYPE, marketplaceSearchParamsParsers, SEARCH_TABS } from './search-params'
|
||||
import {
|
||||
getCollectionsParams,
|
||||
getMarketplaceCollectionsAndPlugins,
|
||||
getMarketplaceCreators,
|
||||
getMarketplacePlugins,
|
||||
getMarketplaceTemplateCollectionsAndTemplates,
|
||||
getMarketplaceTemplates,
|
||||
getPluginFilterType,
|
||||
} from './utils'
|
||||
|
||||
// The server side logic should move to marketplace's codebase so that we can get rid of Next.js
|
||||
export type Awaitable<T> = T | PromiseLike<T>
|
||||
|
||||
async function getDehydratedState(searchParams?: Promise<SearchParams>) {
|
||||
if (!searchParams) {
|
||||
const ZERO_WIDTH_SPACE = '\u200B'
|
||||
const SEARCH_PREVIEW_SIZE = 8
|
||||
const SEARCH_PAGE_SIZE = 40
|
||||
|
||||
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
|
||||
|
||||
function pickFirstParam(value: string | string[] | undefined) {
|
||||
if (Array.isArray(value))
|
||||
return value[0]
|
||||
return value
|
||||
}
|
||||
|
||||
function getNextPageParam(lastPage: { page: number, page_size: number, total: number }) {
|
||||
const nextPage = lastPage.page + 1
|
||||
const loaded = lastPage.page * lastPage.page_size
|
||||
return loaded < (lastPage.total || 0) ? nextPage : undefined
|
||||
}
|
||||
|
||||
type RouteParams = { category?: string, creationType?: string, searchTab?: string } | undefined
|
||||
|
||||
async function shouldSkipServerPrefetch() {
|
||||
const requestHeaders = await headers()
|
||||
return requestHeaders.get('sec-fetch-dest') !== 'document'
|
||||
}
|
||||
|
||||
async function getDehydratedState(
|
||||
params?: Awaitable<RouteParams>,
|
||||
searchParams?: Awaitable<SearchParams>,
|
||||
) {
|
||||
if (await shouldSkipServerPrefetch())
|
||||
return
|
||||
}
|
||||
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
|
||||
const params = await loadSearchParams(searchParams)
|
||||
|
||||
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) {
|
||||
return
|
||||
const rawParams = params ? await params : undefined
|
||||
const rawSearchParams = searchParams ? await searchParams : undefined
|
||||
const parsedSearchParams = await loadSearchParams(Promise.resolve(rawSearchParams ?? {}))
|
||||
|
||||
const routeState = rawSearchParams as SearchParams & {
|
||||
category?: string | string[]
|
||||
creationType?: string | string[]
|
||||
searchTab?: string | string[]
|
||||
}
|
||||
|
||||
const creationTypeFromSearch = pickFirstParam(routeState?.creationType)
|
||||
const categoryFromSearch = pickFirstParam(routeState?.category)
|
||||
const searchTabFromSearch = pickFirstParam(routeState?.searchTab)
|
||||
|
||||
const creationType = rawParams?.creationType === CREATION_TYPE.templates || creationTypeFromSearch === CREATION_TYPE.templates
|
||||
? CREATION_TYPE.templates
|
||||
: CREATION_TYPE.plugins
|
||||
const category = creationType === CREATION_TYPE.templates
|
||||
? getValidatedTemplateCategory(rawParams?.category ?? categoryFromSearch ?? CATEGORY_ALL)
|
||||
: getValidatedPluginCategory(rawParams?.category ?? categoryFromSearch ?? CATEGORY_ALL)
|
||||
const searchTabRaw = rawParams?.searchTab ?? searchTabFromSearch ?? ''
|
||||
const searchTab = SEARCH_TABS.includes(searchTabRaw as (typeof SEARCH_TABS)[number])
|
||||
? searchTabRaw as (typeof SEARCH_TABS)[number]
|
||||
: ''
|
||||
|
||||
const queryClient = getQueryClientServer()
|
||||
const prefetches: Promise<void>[] = []
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: marketplaceQuery.collections.queryKey({ input: { query: getCollectionsParams(params.category) } }),
|
||||
queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)),
|
||||
})
|
||||
if (searchTab) {
|
||||
const searchText = parsedSearchParams.q
|
||||
const query = searchText === ZERO_WIDTH_SPACE ? '' : searchText.trim()
|
||||
const hasQuery = !!searchText && (!!query || searchText === ZERO_WIDTH_SPACE)
|
||||
|
||||
if (!hasQuery)
|
||||
return
|
||||
|
||||
const pageSize = searchTab === 'all' ? SEARCH_PREVIEW_SIZE : SEARCH_PAGE_SIZE
|
||||
const searchFilterType = getValidatedPluginCategory(parsedSearchParams.searchType)
|
||||
const fetchPlugins = searchTab === 'all' || searchTab === 'plugins'
|
||||
const fetchTemplates = searchTab === 'all' || searchTab === 'templates'
|
||||
const fetchCreators = searchTab === 'all' || searchTab === 'creators'
|
||||
|
||||
if (fetchPlugins) {
|
||||
const pluginCategory = searchTab === 'plugins' && searchFilterType !== CATEGORY_ALL
|
||||
? searchFilterType
|
||||
: undefined
|
||||
const searchFilterTags = searchTab === 'plugins' && parsedSearchParams.searchTags.length > 0
|
||||
? parsedSearchParams.searchTags
|
||||
: undefined
|
||||
const pluginsParams: PluginsSearchParams = {
|
||||
query,
|
||||
page_size: pageSize,
|
||||
sort_by: DEFAULT_PLUGIN_SORT.sortBy,
|
||||
sort_order: DEFAULT_PLUGIN_SORT.sortOrder,
|
||||
category: pluginCategory,
|
||||
tags: searchFilterTags,
|
||||
type: getPluginFilterType(pluginCategory || PLUGIN_TYPE_SEARCH_MAP.all),
|
||||
}
|
||||
|
||||
prefetches.push(queryClient.prefetchInfiniteQuery({
|
||||
queryKey: marketplaceQuery.plugins.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: pluginsParams,
|
||||
params: { kind: pluginsParams.type === 'bundle' ? 'bundles' : 'plugins' },
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(pluginsParams, pageParam, signal),
|
||||
getNextPageParam,
|
||||
initialPageParam: 1,
|
||||
}))
|
||||
}
|
||||
|
||||
if (fetchTemplates) {
|
||||
const templateCategories = searchTab === 'templates' && parsedSearchParams.searchCategories.length > 0
|
||||
? parsedSearchParams.searchCategories
|
||||
: undefined
|
||||
const templateLanguages = searchTab === 'templates' && parsedSearchParams.searchLanguages.length > 0
|
||||
? parsedSearchParams.searchLanguages
|
||||
: undefined
|
||||
const templatesParams: TemplateSearchParams = {
|
||||
query,
|
||||
page_size: pageSize,
|
||||
sort_by: DEFAULT_TEMPLATE_SORT.sortBy,
|
||||
sort_order: DEFAULT_TEMPLATE_SORT.sortOrder,
|
||||
categories: templateCategories,
|
||||
languages: templateLanguages,
|
||||
}
|
||||
|
||||
prefetches.push(queryClient.prefetchInfiniteQuery({
|
||||
queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: templatesParams,
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplaceTemplates(templatesParams, pageParam, signal),
|
||||
getNextPageParam,
|
||||
initialPageParam: 1,
|
||||
}))
|
||||
}
|
||||
|
||||
if (fetchCreators) {
|
||||
const creatorsParams: CreatorSearchParams = {
|
||||
query,
|
||||
page_size: pageSize,
|
||||
}
|
||||
|
||||
prefetches.push(queryClient.prefetchInfiniteQuery({
|
||||
queryKey: marketplaceQuery.creators.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: creatorsParams,
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplaceCreators(creatorsParams, pageParam, signal),
|
||||
getNextPageParam,
|
||||
initialPageParam: 1,
|
||||
}))
|
||||
}
|
||||
}
|
||||
else if (creationType === CREATION_TYPE.templates) {
|
||||
prefetches.push(queryClient.prefetchQuery({
|
||||
queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query: undefined } }),
|
||||
queryFn: () => getMarketplaceTemplateCollectionsAndTemplates(),
|
||||
}))
|
||||
|
||||
const isSearchMode = !!parsedSearchParams.q
|
||||
|| category !== CATEGORY_ALL
|
||||
|| parsedSearchParams.languages.length > 0
|
||||
|
||||
if (isSearchMode) {
|
||||
const templatesParams: TemplateSearchParams = {
|
||||
query: parsedSearchParams.q,
|
||||
categories: category === CATEGORY_ALL ? undefined : [category],
|
||||
sort_by: DEFAULT_TEMPLATE_SORT.sortBy,
|
||||
sort_order: DEFAULT_TEMPLATE_SORT.sortOrder,
|
||||
...(parsedSearchParams.languages.length > 0 ? { languages: parsedSearchParams.languages } : {}),
|
||||
}
|
||||
|
||||
prefetches.push(queryClient.prefetchInfiniteQuery({
|
||||
queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: templatesParams,
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplaceTemplates(templatesParams, pageParam, signal),
|
||||
getNextPageParam,
|
||||
initialPageParam: 1,
|
||||
}))
|
||||
}
|
||||
}
|
||||
else {
|
||||
const pluginCategory = getValidatedPluginCategory(category)
|
||||
const collectionsParams = getCollectionsParams(pluginCategory)
|
||||
|
||||
prefetches.push(queryClient.prefetchQuery({
|
||||
queryKey: marketplaceQuery.plugins.collections.queryKey({ input: { query: collectionsParams } }),
|
||||
queryFn: () => getMarketplaceCollectionsAndPlugins(collectionsParams),
|
||||
}))
|
||||
|
||||
const isSearchMode = !!parsedSearchParams.q
|
||||
|| parsedSearchParams.tags.length > 0
|
||||
|| !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(pluginCategory)
|
||||
|
||||
if (isSearchMode) {
|
||||
const pluginsParams: PluginsSearchParams = {
|
||||
query: parsedSearchParams.q,
|
||||
category: pluginCategory === CATEGORY_ALL ? undefined : pluginCategory,
|
||||
tags: parsedSearchParams.tags,
|
||||
sort_by: DEFAULT_PLUGIN_SORT.sortBy,
|
||||
sort_order: DEFAULT_PLUGIN_SORT.sortOrder,
|
||||
type: getPluginFilterType(pluginCategory),
|
||||
}
|
||||
|
||||
prefetches.push(queryClient.prefetchInfiniteQuery({
|
||||
queryKey: marketplaceQuery.plugins.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: pluginsParams,
|
||||
params: { kind: pluginsParams.type === 'bundle' ? 'bundles' : 'plugins' },
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(pluginsParams, pageParam, signal),
|
||||
getNextPageParam,
|
||||
initialPageParam: 1,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (!prefetches.length)
|
||||
return
|
||||
|
||||
await Promise.all(prefetches)
|
||||
return dehydrate(queryClient)
|
||||
}
|
||||
|
||||
export async function HydrateQueryClient({
|
||||
params,
|
||||
searchParams,
|
||||
isMarketplacePlatform = false,
|
||||
children,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams> | undefined
|
||||
params?: Awaitable<{ category?: string, creationType?: string, searchTab?: string } | undefined>
|
||||
searchParams?: Awaitable<SearchParams>
|
||||
isMarketplacePlatform?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const dehydratedState = await getDehydratedState(searchParams)
|
||||
const dehydratedState = isMarketplacePlatform ? await getDehydratedState(params, searchParams) : null
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
{children}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { MarketplaceCollection } from './types'
|
||||
import type { PluginCollection } 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'
|
||||
@ -9,12 +9,12 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
// ================================
|
||||
|
||||
// Note: Import after mocks are set up
|
||||
import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants'
|
||||
import { DEFAULT_PLUGIN_SORT, DEFAULT_TEMPLATE_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants'
|
||||
import {
|
||||
getFormattedPlugin,
|
||||
getMarketplaceListCondition,
|
||||
getMarketplaceListFilterType,
|
||||
getPluginCondition,
|
||||
getPluginDetailLinkInMarketplace,
|
||||
getPluginFilterType,
|
||||
getPluginIconInMarketplace,
|
||||
getPluginLinkInMarketplace,
|
||||
} from './utils'
|
||||
@ -322,11 +322,10 @@ vi.mock('@/app/components/plugins/card', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// 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>
|
||||
// Mock CardTags component
|
||||
vi.mock('@/app/components/plugins/card/card-tags', () => ({
|
||||
default: ({ tags }: { tags: string[] }) => (
|
||||
<div data-testid="card-tags">
|
||||
<span data-testid="tags">{tags.join(',')}</span>
|
||||
</div>
|
||||
),
|
||||
@ -387,7 +386,7 @@ const createMockPluginList = (count: number): Plugin[] =>
|
||||
install_count: 1000 - i * 10,
|
||||
}))
|
||||
|
||||
const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({
|
||||
const createMockCollection = (overrides?: Partial<PluginCollection>): PluginCollection => ({
|
||||
name: 'test-collection',
|
||||
label: { 'en-US': 'Test Collection' },
|
||||
description: { 'en-US': 'Test collection description' },
|
||||
@ -407,20 +406,37 @@ const createMockCollection = (overrides?: Partial<MarketplaceCollection>): Marke
|
||||
// Constants Tests
|
||||
// ================================
|
||||
describe('constants', () => {
|
||||
describe('DEFAULT_SORT', () => {
|
||||
describe('DEFAULT_PLUGIN_SORT', () => {
|
||||
it('should have correct default sort values', () => {
|
||||
expect(DEFAULT_SORT).toEqual({
|
||||
expect(DEFAULT_PLUGIN_SORT).toEqual({
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should be immutable at runtime', () => {
|
||||
const originalSortBy = DEFAULT_SORT.sortBy
|
||||
const originalSortOrder = DEFAULT_SORT.sortOrder
|
||||
const originalSortBy = DEFAULT_PLUGIN_SORT.sortBy
|
||||
const originalSortOrder = DEFAULT_PLUGIN_SORT.sortOrder
|
||||
|
||||
expect(DEFAULT_SORT.sortBy).toBe(originalSortBy)
|
||||
expect(DEFAULT_SORT.sortOrder).toBe(originalSortOrder)
|
||||
expect(DEFAULT_PLUGIN_SORT.sortBy).toBe(originalSortBy)
|
||||
expect(DEFAULT_PLUGIN_SORT.sortOrder).toBe(originalSortOrder)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_TEMPLATE_SORT', () => {
|
||||
it('should have correct default sort values for templates', () => {
|
||||
expect(DEFAULT_TEMPLATE_SORT).toEqual({
|
||||
sortBy: 'usage_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should be immutable at runtime', () => {
|
||||
const originalSortBy = DEFAULT_TEMPLATE_SORT.sortBy
|
||||
const originalSortOrder = DEFAULT_TEMPLATE_SORT.sortOrder
|
||||
|
||||
expect(DEFAULT_TEMPLATE_SORT.sortBy).toBe(originalSortBy)
|
||||
expect(DEFAULT_TEMPLATE_SORT.sortOrder).toBe(originalSortOrder)
|
||||
})
|
||||
})
|
||||
|
||||
@ -514,7 +530,7 @@ describe('utils', () => {
|
||||
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
|
||||
const link = getPluginLinkInMarketplace(plugin)
|
||||
|
||||
expect(link).toBe('https://marketplace.dify.ai/plugins/test-org/test-plugin')
|
||||
expect(link).toBe('https://marketplace.dify.ai/plugin/test-org/test-plugin')
|
||||
})
|
||||
|
||||
it('should return correct link for bundle', () => {
|
||||
@ -530,7 +546,7 @@ describe('utils', () => {
|
||||
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
|
||||
const link = getPluginDetailLinkInMarketplace(plugin)
|
||||
|
||||
expect(link).toBe('/plugins/test-org/test-plugin')
|
||||
expect(link).toBe('/plugin/test-org/test-plugin')
|
||||
})
|
||||
|
||||
it('should return correct detail link for bundle', () => {
|
||||
@ -541,57 +557,57 @@ describe('utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceListCondition', () => {
|
||||
describe('getPluginCondition', () => {
|
||||
it('should return category condition for tool', () => {
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.tool)).toBe('category=tool')
|
||||
expect(getPluginCondition(PluginCategoryEnum.tool)).toBe('category=tool')
|
||||
})
|
||||
|
||||
it('should return category condition for model', () => {
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.model)).toBe('category=model')
|
||||
expect(getPluginCondition(PluginCategoryEnum.model)).toBe('category=model')
|
||||
})
|
||||
|
||||
it('should return category condition for agent', () => {
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
|
||||
expect(getPluginCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
|
||||
})
|
||||
|
||||
it('should return category condition for datasource', () => {
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
|
||||
expect(getPluginCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
|
||||
})
|
||||
|
||||
it('should return category condition for trigger', () => {
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
|
||||
expect(getPluginCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
|
||||
})
|
||||
|
||||
it('should return endpoint category for extension', () => {
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
|
||||
expect(getPluginCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
|
||||
})
|
||||
|
||||
it('should return type condition for bundle', () => {
|
||||
expect(getMarketplaceListCondition('bundle')).toBe('type=bundle')
|
||||
expect(getPluginCondition('bundle')).toBe('type=bundle')
|
||||
})
|
||||
|
||||
it('should return empty string for all', () => {
|
||||
expect(getMarketplaceListCondition('all')).toBe('')
|
||||
expect(getPluginCondition('all')).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for unknown type', () => {
|
||||
expect(getMarketplaceListCondition('unknown')).toBe('')
|
||||
expect(getPluginCondition('unknown')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceListFilterType', () => {
|
||||
describe('getPluginFilterType', () => {
|
||||
it('should return undefined for all', () => {
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
|
||||
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return bundle for bundle', () => {
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
|
||||
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
|
||||
})
|
||||
|
||||
it('should return plugin for other categories', () => {
|
||||
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')
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -611,8 +627,8 @@ describe('useMarketplaceCollectionsAndPlugins', () => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.isSuccess).toBe(false)
|
||||
expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined()
|
||||
expect(result.current.setMarketplaceCollections).toBeDefined()
|
||||
expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined()
|
||||
expect(result.current.setPluginCollections).toBeDefined()
|
||||
expect(result.current.setPluginCollectionPluginsMap).toBeDefined()
|
||||
})
|
||||
|
||||
it('should provide queryMarketplaceCollectionsAndPlugins function', async () => {
|
||||
@ -622,34 +638,34 @@ describe('useMarketplaceCollectionsAndPlugins', () => {
|
||||
expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide setMarketplaceCollections function', async () => {
|
||||
it('should provide setPluginCollections function', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
|
||||
expect(typeof result.current.setMarketplaceCollections).toBe('function')
|
||||
expect(typeof result.current.setPluginCollections).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide setMarketplaceCollectionPluginsMap function', async () => {
|
||||
it('should provide setPluginCollectionPluginsMap function', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
|
||||
expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function')
|
||||
expect(typeof result.current.setPluginCollectionPluginsMap).toBe('function')
|
||||
})
|
||||
|
||||
it('should return marketplaceCollections from data or override', async () => {
|
||||
it('should return pluginCollections from data or override', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
|
||||
// Initial state
|
||||
expect(result.current.marketplaceCollections).toBeUndefined()
|
||||
expect(result.current.pluginCollections).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return marketplaceCollectionPluginsMap from data or override', async () => {
|
||||
it('should return pluginCollectionPluginsMap from data or override', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
|
||||
// Initial state
|
||||
expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined()
|
||||
expect(result.current.pluginCollectionPluginsMap).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,34 +1,48 @@
|
||||
import type { SearchParams } from 'nuqs'
|
||||
import type { Awaitable } from './hydration-server'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import Description from './description'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { HydrateClient } from './hydration-client'
|
||||
import { HydrateQueryClient } from './hydration-server'
|
||||
import ListWrapper from './list/list-wrapper'
|
||||
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
import MarketplaceContent from './marketplace-content'
|
||||
import MarketplaceHeader from './marketplace-header'
|
||||
|
||||
type MarketplaceProps = {
|
||||
showInstallButton?: boolean
|
||||
pluginTypeSwitchClassName?: string
|
||||
/**
|
||||
* Pass the search params from the request to prefetch data on the server.
|
||||
* Pass the search params & params from the request to prefetch data on the server.
|
||||
*/
|
||||
searchParams?: Promise<SearchParams>
|
||||
params?: Awaitable<{ category?: string, creationType?: string, searchTab?: string } | undefined>
|
||||
searchParams?: Awaitable<SearchParams>
|
||||
/**
|
||||
* Whether the marketplace is the platform marketplace.
|
||||
*/
|
||||
isMarketplacePlatform?: boolean
|
||||
marketplaceNav?: React.ReactNode
|
||||
}
|
||||
|
||||
const Marketplace = async ({
|
||||
const Marketplace = ({
|
||||
showInstallButton = true,
|
||||
pluginTypeSwitchClassName,
|
||||
params,
|
||||
searchParams,
|
||||
isMarketplacePlatform = false,
|
||||
marketplaceNav,
|
||||
}: MarketplaceProps) => {
|
||||
return (
|
||||
<TanstackQueryInitializer>
|
||||
<HydrateQueryClient searchParams={searchParams}>
|
||||
<Description />
|
||||
<StickySearchAndSwitchWrapper
|
||||
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
|
||||
/>
|
||||
<ListWrapper
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
<HydrateQueryClient
|
||||
isMarketplacePlatform={isMarketplacePlatform}
|
||||
searchParams={searchParams}
|
||||
params={params}
|
||||
>
|
||||
<HydrateClient
|
||||
isMarketplacePlatform={isMarketplacePlatform}
|
||||
>
|
||||
<MarketplaceHeader descriptionClassName={cn('mx-12 mt-1', isMarketplacePlatform && 'top-0 mx-0 mt-0 rounded-none')} marketplaceNav={marketplaceNav} />
|
||||
<MarketplaceContent
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
</HydrateClient>
|
||||
</HydrateQueryClient>
|
||||
</TanstackQueryInitializer>
|
||||
)
|
||||
|
||||
@ -8,7 +8,7 @@ import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Card from '@/app/components/plugins/card'
|
||||
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
|
||||
import CardTags from '@/app/components/plugins/card/card-tags'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
|
||||
@ -43,14 +43,13 @@ const CardWrapperComponent = ({
|
||||
if (showInstallButton) {
|
||||
return (
|
||||
<div
|
||||
className="group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover"
|
||||
className="group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover"
|
||||
>
|
||||
<Card
|
||||
key={plugin.name}
|
||||
payload={plugin}
|
||||
footer={(
|
||||
<CardMoreInfo
|
||||
downloadCount={plugin.install_count}
|
||||
<CardTags
|
||||
tags={tagLabels}
|
||||
/>
|
||||
)}
|
||||
@ -88,15 +87,15 @@ const CardWrapperComponent = ({
|
||||
|
||||
return (
|
||||
<a
|
||||
className="group relative inline-block cursor-pointer rounded-xl"
|
||||
className="group relative block cursor-pointer rounded-xl"
|
||||
href={getPluginDetailLinkInMarketplace(plugin)}
|
||||
>
|
||||
<Card
|
||||
key={plugin.name}
|
||||
payload={plugin}
|
||||
disableOrgLink
|
||||
footer={(
|
||||
<CardMoreInfo
|
||||
downloadCount={plugin.install_count}
|
||||
<CardTags
|
||||
tags={tagLabels}
|
||||
/>
|
||||
)}
|
||||
|
||||
255
web/app/components/plugins/marketplace/list/carousel.tsx
Normal file
255
web/app/components/plugins/marketplace/list/carousel.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
'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
|
||||
@ -0,0 +1,22 @@
|
||||
export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
|
||||
|
||||
export const GRID_DISPLAY_LIMIT = 4
|
||||
|
||||
export const CAROUSEL_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)]'
|
||||
|
||||
/** Max visible columns at the widest (xl) breakpoint; used to decide 1-row vs 2-row carousel layout. */
|
||||
export const CAROUSEL_MAX_VISIBLE_COLUMNS = 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 }
|
||||
}
|
||||
190
web/app/components/plugins/marketplace/list/collection-list.tsx
Normal file
190
web/app/components/plugins/marketplace/list/collection-list.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import type { SearchTab } from '../search-params'
|
||||
import type { SearchParamsFromCollection } from '../types'
|
||||
import type { BaseCollection } from './collection-constants'
|
||||
import type { Locale } from '@/i18n-config/language'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMarketplaceMoreClick } from '../atoms'
|
||||
import Empty from '../empty'
|
||||
import { buildCarouselColumns, getItemKeyByField } from '../utils'
|
||||
import Carousel from './carousel'
|
||||
import { CAROUSEL_COLUMN_CLASS, CAROUSEL_MAX_VISIBLE_COLUMNS, GRID_CLASS, GRID_DISPLAY_LIMIT } from './collection-constants'
|
||||
|
||||
type ViewMoreButtonProps = {
|
||||
searchParams?: SearchParamsFromCollection
|
||||
searchTab?: SearchTab
|
||||
}
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
|
||||
type CollectionHeaderProps<TCollection extends BaseCollection> = {
|
||||
collection: TCollection
|
||||
itemsLength: number
|
||||
locale: Locale
|
||||
carouselCollectionNames: string[]
|
||||
viewMore: React.ReactNode
|
||||
}
|
||||
|
||||
export function CollectionHeader<TCollection extends BaseCollection>({
|
||||
collection,
|
||||
itemsLength,
|
||||
locale,
|
||||
carouselCollectionNames,
|
||||
viewMore,
|
||||
}: CollectionHeaderProps<TCollection>) {
|
||||
const showViewMore = (collection.searchable || collection.search_params)
|
||||
&& !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>
|
||||
)
|
||||
}
|
||||
|
||||
type CarouselCollectionProps<TItem> = {
|
||||
items: TItem[]
|
||||
getItemKey: (item: TItem) => string
|
||||
renderCard: (item: TItem) => React.ReactNode
|
||||
cardContainerClassName?: string
|
||||
}
|
||||
|
||||
export function CarouselCollection<TItem>({
|
||||
items,
|
||||
getItemKey,
|
||||
renderCard,
|
||||
cardContainerClassName,
|
||||
}: CarouselCollectionProps<TItem>) {
|
||||
const columns = buildCarouselColumns(items, CAROUSEL_MAX_VISIBLE_COLUMNS)
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
className={cardContainerClassName}
|
||||
showNavigation={items.length > 8}
|
||||
showPagination={items.length > 8}
|
||||
autoPlay={items.length > 8}
|
||||
autoPlayInterval={5000}
|
||||
>
|
||||
{columns.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', '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
|
||||
55
web/app/components/plugins/marketplace/list/flat-list.tsx
Normal file
55
web/app/components/plugins/marketplace/list/flat-list.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
'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-constants'
|
||||
import TemplateCard from './template-card'
|
||||
|
||||
type PluginsVariant = {
|
||||
variant: 'plugins'
|
||||
items: Plugin[]
|
||||
showInstallButton?: boolean
|
||||
}
|
||||
|
||||
type TemplatesVariant = {
|
||||
variant: 'templates'
|
||||
items: Template[]
|
||||
}
|
||||
|
||||
type FlatListProps = PluginsVariant | TemplatesVariant
|
||||
|
||||
const FlatList = (props: FlatListProps) => {
|
||||
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.id}
|
||||
template={template}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FlatList
|
||||
@ -1,4 +1,4 @@
|
||||
import type { MarketplaceCollection, SearchParamsFromCollection } from '../types'
|
||||
import type { PluginCollection, SearchParamsFromCollection } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -36,8 +36,8 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => {
|
||||
mockMarketplaceData: {
|
||||
plugins: undefined as Plugin[] | undefined,
|
||||
pluginsTotal: 0,
|
||||
marketplaceCollections: undefined as MarketplaceCollection[] | undefined,
|
||||
marketplaceCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
|
||||
pluginCollections: undefined as PluginCollection[] | undefined,
|
||||
pluginCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
|
||||
isLoading: false,
|
||||
page: 1,
|
||||
},
|
||||
@ -47,10 +47,12 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => {
|
||||
|
||||
vi.mock('../state', () => ({
|
||||
useMarketplaceData: () => mockMarketplaceData,
|
||||
isPluginsData: (data: Record<string, unknown>) => 'pluginCollections' in data,
|
||||
}))
|
||||
|
||||
vi.mock('../atoms', () => ({
|
||||
useMarketplaceMoreClick: () => mockMoreClick,
|
||||
useMarketplaceSearchMode: () => false,
|
||||
}))
|
||||
|
||||
// Mock useLocale context
|
||||
@ -113,12 +115,16 @@ vi.mock('@/i18n-config/language', () => ({
|
||||
}))
|
||||
|
||||
// Mock marketplace utils
|
||||
vi.mock('../utils', () => ({
|
||||
getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record<string, string | undefined>) =>
|
||||
`/plugins/${plugin.org}/${plugin.name}`,
|
||||
getPluginDetailLinkInMarketplace: (plugin: Plugin) =>
|
||||
`/plugins/${plugin.org}/${plugin.name}`,
|
||||
}))
|
||||
vi.mock('../utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../utils')>()
|
||||
return {
|
||||
...actual,
|
||||
getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record<string, string | undefined>) =>
|
||||
`/plugin/${plugin.org}/${plugin.name}`,
|
||||
getPluginDetailLinkInMarketplace: (plugin: Plugin) =>
|
||||
`/plugin/${plugin.org}/${plugin.name}`,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock Card component
|
||||
vi.mock('@/app/components/plugins/card', () => ({
|
||||
@ -131,11 +137,10 @@ vi.mock('@/app/components/plugins/card', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// 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>
|
||||
// Mock CardTags component
|
||||
vi.mock('@/app/components/plugins/card/card-tags', () => ({
|
||||
default: ({ tags }: { tags: string[] }) => (
|
||||
<div data-testid="card-tags">
|
||||
<span data-testid="tags">{tags.join(',')}</span>
|
||||
</div>
|
||||
),
|
||||
@ -208,7 +213,7 @@ const createMockPluginList = (count: number): Plugin[] =>
|
||||
label: { 'en-US': `Plugin ${i}` },
|
||||
}))
|
||||
|
||||
const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({
|
||||
const createMockCollection = (overrides?: Partial<PluginCollection>): PluginCollection => ({
|
||||
name: `collection-${Math.random().toString(36).substring(7)}`,
|
||||
label: { 'en-US': 'Test Collection' },
|
||||
description: { 'en-US': 'Test collection description' },
|
||||
@ -220,7 +225,7 @@ const createMockCollection = (overrides?: Partial<MarketplaceCollection>): Marke
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockCollectionList = (count: number): MarketplaceCollection[] =>
|
||||
const createMockCollectionList = (count: number): PluginCollection[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
createMockCollection({
|
||||
name: `collection-${i}`,
|
||||
@ -233,8 +238,8 @@ const createMockCollectionList = (count: number): MarketplaceCollection[] =>
|
||||
// ================================
|
||||
describe('List', () => {
|
||||
const defaultProps = {
|
||||
marketplaceCollections: [] as MarketplaceCollection[],
|
||||
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
pluginCollections: [] as PluginCollection[],
|
||||
pluginCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
plugins: undefined,
|
||||
showInstallButton: false,
|
||||
cardContainerClassName: '',
|
||||
@ -268,8 +273,8 @@ describe('List', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
pluginCollections={collections}
|
||||
pluginCollectionPluginsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -314,8 +319,8 @@ describe('List', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
pluginCollections={collections}
|
||||
pluginCollectionPluginsMap={pluginsMap}
|
||||
plugins={[]}
|
||||
/>,
|
||||
)
|
||||
@ -426,12 +431,12 @@ describe('List', () => {
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty marketplaceCollections', () => {
|
||||
it('should handle empty pluginCollections', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -448,8 +453,8 @@ describe('List', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
pluginCollections={collections}
|
||||
pluginCollectionPluginsMap={pluginsMap}
|
||||
plugins={undefined}
|
||||
/>,
|
||||
)
|
||||
@ -496,12 +501,12 @@ describe('List', () => {
|
||||
// ================================
|
||||
describe('ListWithCollection', () => {
|
||||
const defaultProps = {
|
||||
marketplaceCollections: [] as MarketplaceCollection[],
|
||||
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
variant: 'plugins' as const,
|
||||
collections: [] as PluginCollection[],
|
||||
collectionItemsMap: {} as Record<string, Plugin[]>,
|
||||
showInstallButton: false,
|
||||
cardContainerClassName: '',
|
||||
cardRender: undefined,
|
||||
onMoreClick: undefined,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@ -528,8 +533,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -548,8 +553,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -568,8 +573,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -582,21 +587,21 @@ describe('ListWithCollection', () => {
|
||||
// View More Button Tests
|
||||
// ================================
|
||||
describe('View More Button', () => {
|
||||
it('should render View More button when collection is searchable', () => {
|
||||
it('should render View More button when non-carousel collection is searchable and exceeds display limit', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
name: 'searchable-collection',
|
||||
searchable: true,
|
||||
search_params: { query: 'test' },
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
'searchable-collection': createMockPluginList(5),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -607,16 +612,38 @@ describe('ListWithCollection', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
searchable: false,
|
||||
search_params: undefined,
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
'collection-0': createMockPluginList(5),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('View More')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render View More button when items do not exceed display limit', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'small-collection',
|
||||
searchable: true,
|
||||
search_params: { query: 'test' },
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'small-collection': createMockPluginList(4),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -626,26 +653,85 @@ describe('ListWithCollection', () => {
|
||||
it('should call moreClick hook with search_params when View More is clicked', () => {
|
||||
const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' }
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
name: 'clickable-collection',
|
||||
searchable: true,
|
||||
search_params: searchParams,
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
'clickable-collection': createMockPluginList(5),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('View More'))
|
||||
|
||||
expect(mockMoreClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockMoreClick).toHaveBeenCalledWith(searchParams)
|
||||
expect(mockMoreClick).toHaveBeenCalledWith(searchParams, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Grid Display Limit Tests
|
||||
// ================================
|
||||
describe('Grid Display Limit', () => {
|
||||
it('should render at most 4 cards for non-carousel collections', () => {
|
||||
const collections = createMockCollectionList(1)
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(8),
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const cards = container.querySelectorAll('[data-testid^="card-plugin-"]')
|
||||
expect(cards.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should render all cards when count is within the display limit', () => {
|
||||
const collections = createMockCollectionList(1)
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(3),
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const cards = container.querySelectorAll('[data-testid^="card-plugin-"]')
|
||||
expect(cards.length).toBe(3)
|
||||
})
|
||||
|
||||
it('should render exactly 4 cards when collection has exactly 4 items', () => {
|
||||
const collections = createMockCollectionList(1)
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(4),
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const cards = container.querySelectorAll('[data-testid^="card-plugin-"]')
|
||||
expect(cards.length).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
@ -669,8 +755,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
cardRender={customCardRender}
|
||||
/>,
|
||||
)
|
||||
@ -693,8 +779,8 @@ describe('ListWithCollection', () => {
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
cardContainerClassName="custom-container"
|
||||
/>,
|
||||
)
|
||||
@ -711,8 +797,8 @@ describe('ListWithCollection', () => {
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
)
|
||||
@ -730,8 +816,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
collections={[]}
|
||||
collectionItemsMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -746,8 +832,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -764,8 +850,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -784,8 +870,8 @@ describe('ListWrapper', () => {
|
||||
// Reset mock data
|
||||
mockMarketplaceData.plugins = undefined
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
mockMarketplaceData.marketplaceCollections = undefined
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
|
||||
mockMarketplaceData.pluginCollections = undefined
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = undefined
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.page = 1
|
||||
})
|
||||
@ -862,8 +948,8 @@ describe('ListWrapper', () => {
|
||||
describe('List Rendering Logic', () => {
|
||||
it('should render collections when not loading', () => {
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
@ -875,8 +961,8 @@ describe('ListWrapper', () => {
|
||||
it('should render List when loading but page > 1', () => {
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 2
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
@ -900,13 +986,13 @@ describe('ListWrapper', () => {
|
||||
})
|
||||
|
||||
it('should show View More button and call moreClick hook', () => {
|
||||
mockMarketplaceData.marketplaceCollections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
mockMarketplaceData.pluginCollections = [createMockCollection({
|
||||
name: 'wrapper-collection',
|
||||
searchable: true,
|
||||
search_params: { query: 'test' },
|
||||
})]
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'wrapper-collection': createMockPluginList(5),
|
||||
}
|
||||
|
||||
render(<ListWrapper />)
|
||||
@ -974,8 +1060,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@ -983,7 +1069,7 @@ describe('CardWrapper (via List integration)', () => {
|
||||
expect(screen.getByTestId('card-test-plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CardMoreInfo with download count and tags', () => {
|
||||
it('should render CardTags with tags', () => {
|
||||
const plugin = createMockPlugin({
|
||||
name: 'test-plugin',
|
||||
install_count: 5000,
|
||||
@ -992,14 +1078,13 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('card-more-info')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('download-count')).toHaveTextContent('5000')
|
||||
expect(screen.getByTestId('card-tags')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1012,8 +1097,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
/>,
|
||||
)
|
||||
@ -1032,8 +1117,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@ -1052,8 +1137,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@ -1073,15 +1158,15 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const detailLink = screen.getByText('Detail').closest('a')
|
||||
expect(detailLink).toHaveAttribute('href', '/plugins/test-org/link-test-plugin')
|
||||
expect(detailLink).toHaveAttribute('href', '/plugin/test-org/link-test-plugin')
|
||||
expect(detailLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
@ -1091,8 +1176,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@ -1107,8 +1192,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@ -1123,8 +1208,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@ -1149,8 +1234,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={false}
|
||||
/>,
|
||||
@ -1169,8 +1254,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={false}
|
||||
/>,
|
||||
@ -1184,8 +1269,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@ -1207,8 +1292,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@ -1224,8 +1309,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@ -1241,8 +1326,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@ -1263,8 +1348,8 @@ describe('Combined Workflows', () => {
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.page = 1
|
||||
mockMarketplaceData.marketplaceCollections = undefined
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
|
||||
mockMarketplaceData.pluginCollections = undefined
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = undefined
|
||||
})
|
||||
|
||||
it('should transition from loading to showing collections', async () => {
|
||||
@ -1277,8 +1362,8 @@ describe('Combined Workflows', () => {
|
||||
|
||||
// Simulate loading complete
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
@ -1289,8 +1374,8 @@ describe('Combined Workflows', () => {
|
||||
})
|
||||
|
||||
it('should transition from collections to search results', async () => {
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
@ -1352,8 +1437,9 @@ describe('Accessibility', () => {
|
||||
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
variant="plugins"
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1364,17 +1450,19 @@ describe('Accessibility', () => {
|
||||
|
||||
it('should have clickable View More button', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
name: 'accessible-collection',
|
||||
searchable: true,
|
||||
search_params: { query: 'test' },
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
'accessible-collection': createMockPluginList(5),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
variant="plugins"
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -1388,13 +1476,13 @@ describe('Accessibility', () => {
|
||||
|
||||
const { container } = render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
/>,
|
||||
)
|
||||
|
||||
const grid = container.querySelector('.grid-cols-4')
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1413,8 +1501,8 @@ describe('Performance', () => {
|
||||
const startTime = performance.now()
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
/>,
|
||||
)
|
||||
@ -1434,8 +1522,9 @@ describe('Performance', () => {
|
||||
const startTime = performance.now()
|
||||
render(
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
variant="plugins"
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
const endTime = performance.now()
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import type { Plugin } from '../../types'
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import type { PluginCollection } from '../types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Empty from '../empty'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import { GRID_CLASS } from './collection-constants'
|
||||
import ListWithCollection from './list-with-collection'
|
||||
|
||||
type ListProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
pluginCollections: PluginCollection[]
|
||||
pluginCollectionPluginsMap: Record<string, Plugin[]>
|
||||
plugins?: Plugin[]
|
||||
showInstallButton?: boolean
|
||||
cardContainerClassName?: string
|
||||
@ -16,8 +18,8 @@ type ListProps = {
|
||||
emptyClassName?: string
|
||||
}
|
||||
const List = ({
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
pluginCollections,
|
||||
pluginCollectionPluginsMap,
|
||||
plugins,
|
||||
showInstallButton,
|
||||
cardContainerClassName,
|
||||
@ -29,8 +31,9 @@ const List = ({
|
||||
{
|
||||
!plugins && (
|
||||
<ListWithCollection
|
||||
marketplaceCollections={marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
|
||||
variant="plugins"
|
||||
collections={pluginCollections}
|
||||
collectionItemsMap={pluginCollectionPluginsMap}
|
||||
showInstallButton={showInstallButton}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
cardRender={cardRender}
|
||||
@ -39,11 +42,7 @@ const List = ({
|
||||
}
|
||||
{
|
||||
plugins && !!plugins.length && (
|
||||
<div className={cn(
|
||||
'grid grid-cols-4 gap-3',
|
||||
cardContainerClassName,
|
||||
)}
|
||||
>
|
||||
<div className={cn(GRID_CLASS, cardContainerClassName)}>
|
||||
{
|
||||
plugins.map((plugin) => {
|
||||
if (cardRender)
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import {
|
||||
useActivePluginCategory,
|
||||
useActiveTemplateCategory,
|
||||
useCreationType,
|
||||
useFilterPluginTags,
|
||||
} from '../atoms'
|
||||
import { usePluginCategoryText, useTemplateCategoryText } from '../category-switch/category-text'
|
||||
import {
|
||||
CATEGORY_ALL,
|
||||
} from '../constants'
|
||||
import { CREATION_TYPE } from '../search-params'
|
||||
import SortDropdown from '../sort-dropdown'
|
||||
|
||||
const ListTopInfo = () => {
|
||||
const creationType = useCreationType()
|
||||
const { t } = useTranslation()
|
||||
const [filterPluginTags] = useFilterPluginTags()
|
||||
const [activePluginCategory] = useActivePluginCategory()
|
||||
const [activeTemplateCategory] = useActiveTemplateCategory()
|
||||
const getPluginCategoryText = usePluginCategoryText()
|
||||
const getTemplateCategoryText = useTemplateCategoryText()
|
||||
|
||||
const isPluginsView = creationType === CREATION_TYPE.plugins
|
||||
|
||||
const hasTags = isPluginsView && filterPluginTags.length > 0
|
||||
|
||||
if (hasTags) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between pt-3">
|
||||
<p className="title-xl-semi-bold text-text-primary">
|
||||
{t('marketplace.listTopInfo.tagsTitle', { ns: 'plugin' })}
|
||||
</p>
|
||||
<SortDropdown />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isAllCategory = isPluginsView
|
||||
? activePluginCategory === CATEGORY_ALL
|
||||
: activeTemplateCategory === CATEGORY_ALL
|
||||
|
||||
const categoryText = isPluginsView
|
||||
? getPluginCategoryText(activePluginCategory)
|
||||
: getTemplateCategoryText(activeTemplateCategory)
|
||||
|
||||
const title = t(
|
||||
`marketplace.listTopInfo.${creationType}${isAllCategory ? 'TitleAll' : 'TitleByCategory'}`,
|
||||
isAllCategory
|
||||
? { ns: 'plugin' }
|
||||
: { ns: 'plugin', category: categoryText },
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between pt-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="title-xl-semi-bold truncate text-text-primary">
|
||||
{title}
|
||||
</p>
|
||||
<p className="system-xs-regular truncate text-text-tertiary">
|
||||
{t(`marketplace.listTopInfo.${creationType}Subtitle`, { ns: 'plugin' })}
|
||||
</p>
|
||||
</div>
|
||||
<SortDropdown />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListTopInfo
|
||||
@ -1,83 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import type { PluginCollection, Template, TemplateCollection } 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 { CAROUSEL_COLLECTION_NAMES } from './collection-constants'
|
||||
import CollectionList from './collection-list'
|
||||
import TemplateCard from './template-card'
|
||||
|
||||
type ListWithCollectionProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
showInstallButton?: boolean
|
||||
type BaseProps = {
|
||||
cardContainerClassName?: string
|
||||
}
|
||||
|
||||
type PluginsVariant = BaseProps & {
|
||||
variant: 'plugins'
|
||||
collections: PluginCollection[]
|
||||
collectionItemsMap: Record<string, Plugin[]>
|
||||
showInstallButton?: boolean
|
||||
cardRender?: (plugin: Plugin) => React.JSX.Element | null
|
||||
}
|
||||
const ListWithCollection = ({
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
showInstallButton,
|
||||
cardContainerClassName,
|
||||
cardRender,
|
||||
}: ListWithCollectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const onMoreClick = useMarketplaceMoreClick()
|
||||
|
||||
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, CAROUSEL_COLLECTION_NAMES.featured]}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const { collections, collectionItemsMap } = props
|
||||
|
||||
const renderTemplateCard = (template: Template) => (
|
||||
<TemplateCard template={template} />
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
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>
|
||||
))
|
||||
}
|
||||
</>
|
||||
<CollectionList
|
||||
collections={collections}
|
||||
collectionItemsMap={collectionItemsMap}
|
||||
itemKeyField="id"
|
||||
renderCard={renderTemplateCard}
|
||||
carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.featured]}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
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 = [{ 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()
|
||||
})
|
||||
})
|
||||
@ -1,64 +1,70 @@
|
||||
'use client'
|
||||
import { useTranslation } from '#i18n'
|
||||
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import SortDropdown from '../sort-dropdown'
|
||||
import { useMarketplaceData } from '../state'
|
||||
import List from './index'
|
||||
import { useMarketplaceSearchMode } from '../atoms'
|
||||
import { isPluginsData, useMarketplaceData } from '../state'
|
||||
import FlatList from './flat-list'
|
||||
import ListTopInfo from './list-top-info'
|
||||
import ListWithCollection from './list-with-collection'
|
||||
|
||||
type ListWrapperProps = {
|
||||
showInstallButton?: boolean
|
||||
}
|
||||
const ListWrapper = ({
|
||||
showInstallButton,
|
||||
}: ListWrapperProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
plugins,
|
||||
pluginsTotal,
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
page,
|
||||
} = useMarketplaceData()
|
||||
const ListWrapper = ({ showInstallButton }: ListWrapperProps) => {
|
||||
const marketplaceData = useMarketplaceData()
|
||||
const { isLoading, page, isFetchingNextPage } = marketplaceData
|
||||
const isSearchMode = useMarketplaceSearchMode()
|
||||
|
||||
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 || {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
|
||||
style={{
|
||||
scrollbarGutter: 'stable',
|
||||
paddingBottom: 'calc(0.5rem + var(--marketplace-header-collapse-offset, 0px))',
|
||||
}}
|
||||
className="relative flex grow flex-col bg-background-default-subtle px-12 pt-2"
|
||||
>
|
||||
{
|
||||
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 && (
|
||||
{isSearchMode && <ListTopInfo />}
|
||||
<div className="relative grow">
|
||||
{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" />
|
||||
)
|
||||
}
|
||||
)}
|
||||
{(!isLoading || page > 1) && renderContent()}
|
||||
</div>
|
||||
{isFetchingNextPage && <Loading className="my-3" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,256 @@
|
||||
import type { Template } from '../types'
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TemplateCard from './template-card'
|
||||
|
||||
// Mock AppIcon component to capture props for assertion
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ size, iconType, icon, imageUrl, background }: {
|
||||
size?: string
|
||||
iconType?: string
|
||||
icon?: string
|
||||
imageUrl?: string | null
|
||||
background?: string | null
|
||||
}) => (
|
||||
<span
|
||||
data-testid="app-icon"
|
||||
data-size={size}
|
||||
data-icon-type={iconType}
|
||||
data-icon={icon}
|
||||
data-image-url={imageUrl || ''}
|
||||
data-background={background || ''}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (key === 'marketplace.templateCard.by')
|
||||
return `by ${options?.author || ''}`
|
||||
if (key === 'usedCount')
|
||||
return `${options?.num || 0} used`
|
||||
return key
|
||||
},
|
||||
}),
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
// Mock next/link
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, ...props }: { children: React.ReactNode, href: string }) => (
|
||||
<a href={href} {...props}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock next-themes
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
// Mock marketplace utils
|
||||
vi.mock('@/utils/get-icon', () => ({
|
||||
getIconFromMarketPlace: (id: string) => `https://marketplace.dify.ai/api/v1/plugins/${id}/icon`,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/template', () => ({
|
||||
formatUsedCount: (count: number) => String(count),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({
|
||||
default: ({ text }: { text: string }) => <span data-testid="corner-mark">{text}</span>,
|
||||
}))
|
||||
|
||||
// Mock marketplace utils (getTemplateIconUrl)
|
||||
vi.mock('../utils', () => ({
|
||||
getTemplateIconUrl: (template: { id: string, icon?: string, icon_file_key?: string }): string => {
|
||||
if (template.icon?.startsWith('http'))
|
||||
return template.icon
|
||||
if (template.icon_file_key)
|
||||
return `https://marketplace.dify.ai/api/v1/templates/${template.id}/icon`
|
||||
return ''
|
||||
},
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Test Data Factories
|
||||
// ================================
|
||||
|
||||
const createMockTemplate = (overrides?: Partial<Template>): Template => ({
|
||||
id: 'test-template-id',
|
||||
index_id: 'test-template-id',
|
||||
template_name: 'test-template',
|
||||
icon: '📄',
|
||||
icon_background: '',
|
||||
icon_file_key: '',
|
||||
categories: ['Agent'],
|
||||
overview: 'A test template',
|
||||
readme: 'readme content',
|
||||
partner_link: '',
|
||||
deps_plugins: [],
|
||||
preferred_languages: ['en'],
|
||||
publisher_handle: 'test-publisher',
|
||||
publisher_type: 'individual',
|
||||
kind: 'classic',
|
||||
status: 'published',
|
||||
usage_count: 100,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Tests
|
||||
// ================================
|
||||
|
||||
describe('TemplateCard', () => {
|
||||
describe('Icon Rendering via AppIcon', () => {
|
||||
it('should pass emoji id to AppIcon when icon is an emoji id like sweat_smile', () => {
|
||||
const template = createMockTemplate({ icon: 'sweat_smile' })
|
||||
const { container } = render(<TemplateCard template={template} />)
|
||||
|
||||
const appIcon = container.querySelector('[data-testid="app-icon"]')
|
||||
expect(appIcon).toBeInTheDocument()
|
||||
expect(appIcon?.getAttribute('data-icon-type')).toBe('emoji')
|
||||
expect(appIcon?.getAttribute('data-icon')).toBe('sweat_smile')
|
||||
expect(appIcon?.getAttribute('data-size')).toBe('large')
|
||||
})
|
||||
|
||||
it('should pass unicode emoji to AppIcon when icon is a unicode character', () => {
|
||||
const template = createMockTemplate({ icon: '😅' })
|
||||
const { container } = render(<TemplateCard template={template} />)
|
||||
|
||||
const appIcon = container.querySelector('[data-testid="app-icon"]')
|
||||
expect(appIcon).toBeInTheDocument()
|
||||
expect(appIcon?.getAttribute('data-icon-type')).toBe('emoji')
|
||||
expect(appIcon?.getAttribute('data-icon')).toBe('😅')
|
||||
})
|
||||
|
||||
it('should pass default fallback icon to AppIcon when icon and icon_file_key are both empty', () => {
|
||||
const template = createMockTemplate({ icon: '', icon_file_key: '' })
|
||||
const { container } = render(<TemplateCard template={template} />)
|
||||
|
||||
const appIcon = container.querySelector('[data-testid="app-icon"]')
|
||||
expect(appIcon).toBeInTheDocument()
|
||||
expect(appIcon?.getAttribute('data-icon-type')).toBe('emoji')
|
||||
expect(appIcon?.getAttribute('data-icon')).toBe('📄')
|
||||
})
|
||||
|
||||
it('should pass image URL to AppIcon when icon is a URL', () => {
|
||||
const template = createMockTemplate({ icon: 'https://example.com/icon.png' })
|
||||
const { container } = render(<TemplateCard template={template} />)
|
||||
|
||||
const appIcon = container.querySelector('[data-testid="app-icon"]')
|
||||
expect(appIcon).toBeInTheDocument()
|
||||
expect(appIcon?.getAttribute('data-icon-type')).toBe('image')
|
||||
expect(appIcon?.getAttribute('data-image-url')).toBe('https://example.com/icon.png')
|
||||
// icon prop should not be set for URL icons
|
||||
expect(appIcon?.hasAttribute('data-icon')).toBe(false)
|
||||
})
|
||||
|
||||
it('should resolve image URL from icon_file_key when icon is empty but icon_file_key is set', () => {
|
||||
const template = createMockTemplate({
|
||||
id: 'tpl-123',
|
||||
icon: '',
|
||||
icon_file_key: 'fa3b0f86-bc64-47ec-ad83-8e3cfc6739ae.jpg',
|
||||
})
|
||||
const { container } = render(<TemplateCard template={template} />)
|
||||
|
||||
const appIcon = container.querySelector('[data-testid="app-icon"]')
|
||||
expect(appIcon).toBeInTheDocument()
|
||||
expect(appIcon?.getAttribute('data-icon-type')).toBe('image')
|
||||
expect(appIcon?.getAttribute('data-image-url')).toBe('https://marketplace.dify.ai/api/v1/templates/tpl-123/icon')
|
||||
// icon prop should not be set when rendering as image
|
||||
expect(appIcon?.hasAttribute('data-icon')).toBe(false)
|
||||
})
|
||||
|
||||
it('should prefer icon URL over icon_file_key when both are present', () => {
|
||||
const template = createMockTemplate({
|
||||
icon: 'https://example.com/custom-icon.png',
|
||||
icon_file_key: 'fa3b0f86-bc64-47ec-ad83-8e3cfc6739ae.jpg',
|
||||
})
|
||||
const { container } = render(<TemplateCard template={template} />)
|
||||
|
||||
const appIcon = container.querySelector('[data-testid="app-icon"]')
|
||||
expect(appIcon?.getAttribute('data-icon-type')).toBe('image')
|
||||
expect(appIcon?.getAttribute('data-image-url')).toBe('https://example.com/custom-icon.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Avatar Background', () => {
|
||||
it('should pass icon_background to AppIcon when provided', () => {
|
||||
const template = createMockTemplate({ icon: 'sweat_smile', icon_background: '#FFEAD5' })
|
||||
const { container } = render(<TemplateCard template={template} />)
|
||||
|
||||
const appIcon = container.querySelector('[data-testid="app-icon"]')
|
||||
expect(appIcon?.getAttribute('data-background')).toBe('#FFEAD5')
|
||||
})
|
||||
|
||||
it('should not pass background to AppIcon when icon_background is empty', () => {
|
||||
const template = createMockTemplate({ icon: 'sweat_smile', icon_background: '' })
|
||||
const { container } = render(<TemplateCard template={template} />)
|
||||
|
||||
const appIcon = container.querySelector('[data-testid="app-icon"]')
|
||||
// Empty string means no background was passed (undefined becomes '')
|
||||
expect(appIcon?.getAttribute('data-background')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sandbox', () => {
|
||||
it('should render CornerMark when kind is sandboxed', () => {
|
||||
const template = createMockTemplate({ kind: 'sandboxed' })
|
||||
const { container } = render(<TemplateCard template={template} />)
|
||||
|
||||
const cornerMark = container.querySelector('[data-testid="corner-mark"]')
|
||||
expect(cornerMark).toBeInTheDocument()
|
||||
expect(cornerMark?.textContent).toBe('Sandbox')
|
||||
})
|
||||
|
||||
it('should not render CornerMark when kind is classic', () => {
|
||||
const template = createMockTemplate({ kind: 'classic' })
|
||||
const { container } = render(<TemplateCard template={template} />)
|
||||
|
||||
const cornerMark = container.querySelector('[data-testid="corner-mark"]')
|
||||
expect(cornerMark).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Creator Link', () => {
|
||||
it('should append publisher_type query to creator link', () => {
|
||||
const template = createMockTemplate({ publisher_type: 'organization' })
|
||||
const { getByText } = render(<TemplateCard template={template} />)
|
||||
|
||||
const creatorLink = getByText('test-publisher').closest('a')
|
||||
expect(creatorLink).toHaveAttribute('href', '/creator/test-publisher?publisher_type=organization')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deps Plugins', () => {
|
||||
it('should render dep plugin icons', () => {
|
||||
const template = createMockTemplate({
|
||||
deps_plugins: ['langgenius/google-search', 'langgenius/dalle'],
|
||||
})
|
||||
const { container } = render(<TemplateCard template={template} />)
|
||||
|
||||
const pluginIcons = container.querySelectorAll('.h-6.w-6 img')
|
||||
expect(pluginIcons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should show +N when deps_plugins exceed MAX_VISIBLE_DEPS_PLUGINS', () => {
|
||||
const deps = Array.from({ length: 10 }, (_, i) => `org/plugin-${i}`)
|
||||
const template = createMockTemplate({ deps_plugins: deps })
|
||||
const { container } = render(<TemplateCard template={template} />)
|
||||
|
||||
// Should show 7 visible + "+3"
|
||||
const pluginIcons = container.querySelectorAll('.h-6.w-6 img')
|
||||
expect(pluginIcons.length).toBe(7)
|
||||
|
||||
expect(container.textContent).toContain('+3')
|
||||
})
|
||||
})
|
||||
})
|
||||
148
web/app/components/plugins/marketplace/list/template-card.tsx
Normal file
148
web/app/components/plugins/marketplace/list/template-card.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import type { Template } from '../types'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import Link from 'next/link'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import CornerMark from '@/app/components/plugins/card/base/corner-mark'
|
||||
import { MARKETPLACE_URL_PREFIX } from '@/config'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getIconFromMarketPlace } from '@/utils/get-icon'
|
||||
import { formatUsedCount } from '@/utils/template'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { getTemplateIconUrl } from '../utils'
|
||||
|
||||
type TemplateCardProps = {
|
||||
template: Template
|
||||
className?: string
|
||||
includeSource?: boolean
|
||||
}
|
||||
|
||||
// Number of tag icons to show before showing "+X"
|
||||
const MAX_VISIBLE_DEPS_PLUGINS = 7
|
||||
|
||||
const TemplateCardComponent = ({
|
||||
template,
|
||||
className,
|
||||
includeSource = false,
|
||||
}: TemplateCardProps) => {
|
||||
const locale = useLocale()
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const { id, template_name, overview, icon, publisher_handle, publisher_type, usage_count, icon_background, deps_plugins, kind } = template
|
||||
const isSandbox = kind === 'sandboxed'
|
||||
const iconUrl = getTemplateIconUrl(template)
|
||||
|
||||
const href = useMemo(() => {
|
||||
const queryParams = {
|
||||
theme,
|
||||
language: locale,
|
||||
templateId: id,
|
||||
creationType: 'templates',
|
||||
}
|
||||
return includeSource
|
||||
? getMarketplaceUrl(`/template/${publisher_handle}/${template_name}`, queryParams)
|
||||
: `${MARKETPLACE_URL_PREFIX}/template/${publisher_handle}/${template_name}?${new URLSearchParams(queryParams).toString()}`
|
||||
}, [publisher_handle, template_name, theme, locale, id, includeSource])
|
||||
|
||||
const visibleDepsPlugins = deps_plugins?.slice(0, MAX_VISIBLE_DEPS_PLUGINS) || []
|
||||
const remainingDepsPluginsCount = deps_plugins ? Math.max(0, deps_plugins.length - MAX_VISIBLE_DEPS_PLUGINS) : 0
|
||||
|
||||
const formattedUsedCount = formatUsedCount(usage_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,
|
||||
)}
|
||||
>
|
||||
{isSandbox && <CornerMark text="Sandbox" />}
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-3 px-4 pb-2 pt-4">
|
||||
{/* Avatar */}
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={iconUrl ? 'image' : 'emoji'}
|
||||
icon={iconUrl ? undefined : (icon || '📄')}
|
||||
imageUrl={iconUrl || undefined}
|
||||
background={icon_background || undefined}
|
||||
/>
|
||||
{/* Title */}
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="system-md-medium truncate text-text-primary after:absolute after:inset-0"
|
||||
>
|
||||
{template_name}
|
||||
</a>
|
||||
<div className="system-xs-regular flex items-center gap-2 text-text-tertiary">
|
||||
<span className="relative z-[1] flex shrink-0 items-center gap-1">
|
||||
<span className="shrink-0">{t('marketplace.templateCard.by', { ns: 'plugin' })}</span>
|
||||
<Link
|
||||
href={`/creator/${publisher_handle}?publisher_type=${encodeURIComponent(publisher_type || 'individual')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate hover:text-text-secondary hover:underline"
|
||||
>
|
||||
{publisher_handle}
|
||||
</Link>
|
||||
</span>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="shrink-0">
|
||||
{t('usedCount', { ns: 'plugin', num: formattedUsedCount || 0 })}
|
||||
</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={overview}
|
||||
>
|
||||
{overview}
|
||||
</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">
|
||||
{deps_plugins && deps_plugins.length > 0 && (
|
||||
<>
|
||||
{visibleDepsPlugins.map((depsPlugin, index) => (
|
||||
<div
|
||||
key={`${id}-depsPlugin-${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={depsPlugin}
|
||||
>
|
||||
<img
|
||||
className="h-full w-full object-cover"
|
||||
src={getIconFromMarketPlace(depsPlugin)}
|
||||
alt={depsPlugin}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{remainingDepsPluginsCount > 0 && (
|
||||
<div className="flex items-center justify-center p-0.5">
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
+
|
||||
{remainingDepsPluginsCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TemplateCard = React.memo(TemplateCardComponent)
|
||||
|
||||
export default TemplateCard
|
||||
@ -0,0 +1,19 @@
|
||||
'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
|
||||
@ -0,0 +1,21 @@
|
||||
'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
|
||||
21
web/app/components/plugins/marketplace/plugin-type-icons.tsx
Normal file
21
web/app/components/plugins/marketplace/plugin-type-icons.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
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,
|
||||
}
|
||||
@ -1,105 +0,0 @@
|
||||
'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
|
||||
@ -1,23 +1,37 @@
|
||||
import type { PluginsSearchParams } from './types'
|
||||
import type { CreatorSearchParams, PluginsSearchParams, TemplateSearchParams, UnifiedSearchParams } from './types'
|
||||
import type { MarketPlaceInputs } from '@/contract/router'
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { marketplaceQuery } from '@/service/client'
|
||||
import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils'
|
||||
import { getMarketplaceCollectionsAndPlugins, getMarketplaceCreators, getMarketplacePlugins, getMarketplaceTemplateCollectionsAndTemplates, getMarketplaceTemplates, getMarketplaceUnifiedSearch } from './utils'
|
||||
|
||||
export function useMarketplaceCollectionsAndPlugins(
|
||||
collectionsParams: MarketPlaceInputs['collections']['query'],
|
||||
collectionsParams: MarketPlaceInputs['plugins']['collections']['query'],
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: marketplaceQuery.collections.queryKey({ input: { query: collectionsParams } }),
|
||||
queryKey: marketplaceQuery.plugins.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.searchAdvanced.queryKey({
|
||||
queryKey: marketplaceQuery.plugins.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: queryParams!,
|
||||
params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' },
|
||||
@ -30,6 +44,59 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
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'
|
||||
|
||||
@ -13,34 +16,90 @@ import ToolSelectorTrigger from './trigger/tool-selector'
|
||||
// Mock i18n translation hook
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
t: (key: string, options?: { ns?: string, num?: number, author?: 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 { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => {
|
||||
const {
|
||||
mockSearchText,
|
||||
mockHandleSearchTextChange,
|
||||
mockFilterPluginTags,
|
||||
mockHandleFilterPluginTagsChange,
|
||||
mockActivePluginCategory,
|
||||
mockSortValue,
|
||||
} = vi.hoisted(() => {
|
||||
return {
|
||||
mockSearchPluginText: '',
|
||||
mockHandleSearchPluginTextChange: vi.fn(),
|
||||
mockSearchText: '',
|
||||
mockHandleSearchTextChange: vi.fn(),
|
||||
mockFilterPluginTags: [] as string[],
|
||||
mockHandleFilterPluginTagsChange: vi.fn(),
|
||||
mockActivePluginCategory: 'all',
|
||||
mockSortValue: {
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../atoms', () => ({
|
||||
useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange],
|
||||
useSearchText: () => [mockSearchText, mockHandleSearchTextChange],
|
||||
useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
|
||||
useActivePluginCategory: () => [mockActivePluginCategory, vi.fn()],
|
||||
useMarketplacePluginSortValue: () => mockSortValue,
|
||||
useMarketplaceTemplateSortValue: () => ({ sortBy: 'usage_count', sortOrder: 'DESC' }),
|
||||
useActiveSort: () => [mockSortValue, vi.fn()],
|
||||
useActiveSortValue: () => mockSortValue,
|
||||
useCreationType: () => ['plugins', vi.fn()],
|
||||
useSearchTab: () => ['', vi.fn()],
|
||||
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' },
|
||||
@ -60,8 +119,64 @@ 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
|
||||
|
||||
@ -115,6 +230,7 @@ describe('SearchBox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpenState = false
|
||||
mockDropdownPlugins = []
|
||||
})
|
||||
|
||||
// ================================
|
||||
@ -424,6 +540,68 @@ 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
@ -433,6 +611,7 @@ describe('SearchBoxWrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpenState = false
|
||||
mockDropdownPlugins = []
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -457,13 +636,47 @@ describe('SearchBoxWrapper', () => {
|
||||
})
|
||||
|
||||
describe('Hook Integration', () => {
|
||||
it('should call handleSearchPluginTextChange when search changes', () => {
|
||||
it('should not commit search when input changes', () => {
|
||||
render(<SearchBoxWrapper />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'new search' } })
|
||||
|
||||
expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -8,6 +8,9 @@ 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[]
|
||||
@ -22,6 +25,9 @@ type SearchBoxProps = {
|
||||
const SearchBox = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
onSearchSubmit,
|
||||
onSearchFocus,
|
||||
onSearchBlur,
|
||||
wrapperClassName,
|
||||
inputClassName,
|
||||
tags,
|
||||
@ -58,6 +64,12 @@ const SearchBox = ({
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
onSearchSubmit?.()
|
||||
}}
|
||||
onFocus={onSearchFocus}
|
||||
onBlur={onSearchBlur}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{
|
||||
@ -89,6 +101,12 @@ const SearchBox = ({
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
onSearchSubmit?.()
|
||||
}}
|
||||
onFocus={onSearchFocus}
|
||||
onBlur={onSearchBlur}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{
|
||||
|
||||
@ -1,25 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import type { UnifiedSearchParams } from '../types'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useFilterPluginTags, useSearchPluginText } from '../atoms'
|
||||
import SearchBox from './index'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useRouter } from 'next/navigation'
|
||||
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 {
|
||||
isMarketplacePlatformAtom,
|
||||
searchModeAtom,
|
||||
useSearchTab,
|
||||
useSearchText,
|
||||
} from '../atoms'
|
||||
import { useMarketplaceUnifiedSearch } from '../query'
|
||||
import { mapUnifiedCreatorToCreator, mapUnifiedPluginToPlugin, mapUnifiedTemplateToTemplate } from '../utils'
|
||||
import SearchDropdown from './search-dropdown'
|
||||
|
||||
const SearchBoxWrapper = () => {
|
||||
type SearchBoxWrapperProps = {
|
||||
wrapperClassName?: string
|
||||
inputClassName?: string
|
||||
includeSource?: boolean
|
||||
}
|
||||
const SearchBoxWrapper = ({
|
||||
wrapperClassName,
|
||||
inputClassName,
|
||||
includeSource = true,
|
||||
}: SearchBoxWrapperProps) => {
|
||||
const isMarketplacePlatform = useAtomValue(isMarketplacePlatformAtom)
|
||||
const { t } = useTranslation()
|
||||
const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText()
|
||||
const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
|
||||
const [searchText, handleSearchTextChange] = useSearchText()
|
||||
const [, setSearchTab] = useSearchTab()
|
||||
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 router = useRouter()
|
||||
|
||||
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
|
||||
|
||||
if (isMarketplacePlatform) {
|
||||
router.push(`/search/all/?q=${encodeURIComponent(trimmed)}`)
|
||||
}
|
||||
else {
|
||||
handleSearchTextChange(trimmed)
|
||||
setSearchTab('all')
|
||||
setSearchMode(true)
|
||||
}
|
||||
setIsFocused(false)
|
||||
}
|
||||
|
||||
const inputValue = isFocused ? draftSearch : committedSearch
|
||||
const isDropdownOpen = hasDraft && (isFocused || isHoveringDropdown)
|
||||
|
||||
return (
|
||||
<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
|
||||
/>
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={8}
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsFocused}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<div>
|
||||
<Input
|
||||
wrapperClassName={cn('w-[100px] min-w-[80px] shrink-0 rounded-lg sm:w-[160px] md:w-[200px] lg:w-[300px]', wrapperClassName)}
|
||||
className={cn('h-9 bg-components-input-bg-normal', inputClassName)}
|
||||
showLeftIcon
|
||||
value={inputValue}
|
||||
placeholder={t('searchInMarketplace', { 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}
|
||||
includeSource={includeSource}
|
||||
onShowAll={handleSubmit}
|
||||
isLoading={dropdownQuery.isLoading}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,333 @@
|
||||
import type { Creator, Template } from '../../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiArrowRightLine } from '@remixicon/react'
|
||||
import { Fragment } from 'react'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useCategories } from '@/app/components/plugins/hooks'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { formatUsedCount } from '@/utils/template'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons'
|
||||
import { getCreatorAvatarUrl, getPluginDetailLinkInMarketplace, getTemplateIconUrl } from '../../utils'
|
||||
|
||||
const DROPDOWN_PANEL = 'w-[472px] max-h-[710px] overflow-y-auto 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>
|
||||
)
|
||||
|
||||
const getSearchParamsString = (params?: Record<string, string | undefined>) => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params) {
|
||||
Object.keys(params).forEach((key) => {
|
||||
const value = params[key]
|
||||
if (value !== undefined && value !== null)
|
||||
searchParams.append(key, value)
|
||||
})
|
||||
}
|
||||
return searchParams.toString()
|
||||
}
|
||||
|
||||
const getDropdownMarketplaceUrl = (
|
||||
path: string,
|
||||
params: Record<string, string | undefined> | undefined,
|
||||
includeSource: boolean,
|
||||
) => {
|
||||
if (includeSource)
|
||||
return getMarketplaceUrl(path, params)
|
||||
const query = getSearchParamsString(params)
|
||||
return query ? `${path}?${query}` : path
|
||||
}
|
||||
|
||||
type SearchDropdownProps = {
|
||||
query: string
|
||||
plugins: Plugin[]
|
||||
templates: Template[]
|
||||
creators: Creator[]
|
||||
includeSource?: boolean
|
||||
onShowAll: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const SearchDropdown = ({
|
||||
query,
|
||||
plugins,
|
||||
templates,
|
||||
creators,
|
||||
includeSource = true,
|
||||
onShowAll,
|
||||
isLoading = false,
|
||||
}: SearchDropdownProps) => {
|
||||
const { t } = useTranslation()
|
||||
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}
|
||||
includeSource={includeSource}
|
||||
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}
|
||||
includeSource={includeSource}
|
||||
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, includeSource, t }: {
|
||||
templates: Template[]
|
||||
includeSource: boolean
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
}) {
|
||||
return (
|
||||
<DropdownSection title={t('templates', { ns: 'plugin' })}>
|
||||
{templates.map((template) => {
|
||||
const descriptionText = template.overview
|
||||
const formattedUsedCount = formatUsedCount(template.usage_count, { precision: 0, rounding: 'floor' })
|
||||
const usedLabel = t('usedCount', { ns: 'plugin', num: formattedUsedCount || 0 })
|
||||
const iconUrl = getTemplateIconUrl(template)
|
||||
return (
|
||||
<DropdownItem
|
||||
key={template.id}
|
||||
href={getDropdownMarketplaceUrl(
|
||||
`/template/${template.publisher_handle}/${template.template_name}`,
|
||||
{ templateId: template.id },
|
||||
includeSource,
|
||||
)}
|
||||
icon={(
|
||||
<div className="flex shrink-0 items-start py-1">
|
||||
<AppIcon
|
||||
size="small"
|
||||
iconType={iconUrl ? 'image' : 'emoji'}
|
||||
icon={iconUrl ? undefined : (template.icon || '📄')}
|
||||
imageUrl={iconUrl || undefined}
|
||||
background={template.icon_background || undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="system-md-medium truncate text-text-primary">{template.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.publisher_handle }),
|
||||
usedLabel,
|
||||
]}
|
||||
/>
|
||||
</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, includeSource, t }: {
|
||||
creators: Creator[]
|
||||
includeSource: boolean
|
||||
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={getDropdownMarketplaceUrl(`/creators/${creator.unique_handle}`, undefined, includeSource)}
|
||||
>
|
||||
<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
|
||||
@ -0,0 +1,2 @@
|
||||
export { default as MarketplaceTrigger } from './marketplace'
|
||||
export { default as ToolSelectorTrigger } from './tool-selector'
|
||||
@ -0,0 +1,15 @@
|
||||
export type LanguageOption = {
|
||||
value: string
|
||||
label: string
|
||||
nativeLabel: string
|
||||
}
|
||||
|
||||
export const LANGUAGE_OPTIONS: LanguageOption[] = [
|
||||
{ value: 'en', label: 'English', nativeLabel: 'English' },
|
||||
{ value: 'zh-Hans', label: 'Simplified Chinese', nativeLabel: '简体中文' },
|
||||
{ value: 'zh-Hant', label: 'Traditional Chinese', nativeLabel: '繁體中文' },
|
||||
{ value: 'ja', label: 'Japanese', nativeLabel: '日本語' },
|
||||
{ value: 'es', label: 'Spanish', nativeLabel: 'Español' },
|
||||
{ value: 'fr', label: 'French', nativeLabel: 'Français' },
|
||||
{ value: 'ko', label: 'Korean', nativeLabel: '한국어' },
|
||||
]
|
||||
@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import type { Creator } from '../types'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { getCreatorAvatarUrl } from '../utils'
|
||||
|
||||
type CreatorCardProps = {
|
||||
creator: Creator
|
||||
}
|
||||
|
||||
const CreatorCard = ({ creator }: CreatorCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const href = `/creator/${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
|
||||
@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
RiCloseCircleFill,
|
||||
} from '@remixicon/react'
|
||||
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 { cn } from '@/utils/classnames'
|
||||
|
||||
export type FilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type FilterChipProps = {
|
||||
label: string
|
||||
options: FilterOption[]
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
multiple?: boolean
|
||||
searchable?: boolean
|
||||
searchPlaceholder?: string
|
||||
}
|
||||
|
||||
const FilterChip = ({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
multiple = true,
|
||||
searchable = false,
|
||||
searchPlaceholder = '',
|
||||
}: FilterChipProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const hasSelected = value.length > 0
|
||||
const filteredOptions = searchable
|
||||
? options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
|
||||
: options
|
||||
|
||||
const getSelectedLabels = () => {
|
||||
return value
|
||||
.map(v => options.find(o => o.value === v)?.label)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
if (multiple) {
|
||||
if (value.includes(optionValue))
|
||||
onChange(value.filter(v => v !== optionValue))
|
||||
else
|
||||
onChange([...value, optionValue])
|
||||
}
|
||||
else {
|
||||
onChange([optionValue])
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onChange([])
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="shrink-0"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex h-8 cursor-pointer select-none items-center gap-0 rounded-lg px-2 py-1',
|
||||
!hasSelected && 'bg-components-input-bg-normal text-text-tertiary',
|
||||
!hasSelected && open && 'bg-state-base-hover',
|
||||
hasSelected && 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
{!hasSelected && (
|
||||
<span className="system-sm-regular text-text-tertiary">{label}</span>
|
||||
)}
|
||||
{hasSelected && (
|
||||
<>
|
||||
<span className="system-sm-regular text-text-tertiary">{label}</span>
|
||||
<span className="system-sm-medium text-text-secondary">
|
||||
{getSelectedLabels()}
|
||||
</span>
|
||||
{value.length > 2 && (
|
||||
<span className="system-xs-medium text-text-tertiary">
|
||||
+
|
||||
{value.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hasSelected && (
|
||||
<RiCloseCircleFill
|
||||
className="size-4 shrink-0 text-text-quaternary"
|
||||
onClick={handleClear}
|
||||
/>
|
||||
)}
|
||||
{!hasSelected && (
|
||||
<RiArrowDownSLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1000]">
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
{searchable && (
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[448px] overflow-y-auto p-1">
|
||||
{filteredOptions.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
{multiple && (
|
||||
<Checkbox
|
||||
className="mr-1"
|
||||
checked={value.includes(option.value)}
|
||||
/>
|
||||
)}
|
||||
<div className="system-sm-medium flex-1 px-1 text-text-secondary">
|
||||
{option.label}
|
||||
</div>
|
||||
{!multiple && value.includes(option.value) && (
|
||||
<RiCheckLine className="size-4 text-text-accent" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterChip
|
||||
286
web/app/components/plugins/marketplace/search-page/index.tsx
Normal file
286
web/app/components/plugins/marketplace/search-page/index.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
'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 {
|
||||
useMarketplacePluginSortValue,
|
||||
useMarketplaceTemplateSortValue,
|
||||
useSearchFilterCategories,
|
||||
useSearchFilterLanguages,
|
||||
useSearchFilterTags,
|
||||
useSearchFilterType,
|
||||
useSearchTab,
|
||||
useSearchText,
|
||||
} from '../atoms'
|
||||
import { CATEGORY_ALL, 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'
|
||||
import PluginFilters from './plugin-filters'
|
||||
import TemplateFilters from './template-filters'
|
||||
|
||||
const PAGE_SIZE = 40
|
||||
const ALL_TAB_PREVIEW_SIZE = 8
|
||||
const ZERO_WIDTH_SPACE = '\u200B'
|
||||
|
||||
// type SortValue = { sortBy: string, sortOrder: string }
|
||||
|
||||
// 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 pluginSort = useMarketplacePluginSortValue()
|
||||
const templateSort = useMarketplaceTemplateSortValue()
|
||||
|
||||
// Search-page-specific filters
|
||||
const [searchFilterCategories] = useSearchFilterCategories()
|
||||
const [searchFilterLanguages] = useSearchFilterLanguages()
|
||||
const [searchFilterType] = useSearchFilterType()
|
||||
const [searchFilterTags] = useSearchFilterTags()
|
||||
|
||||
const query = debouncedQuery === ZERO_WIDTH_SPACE ? '' : debouncedQuery.trim()
|
||||
const hasQuery = !!searchText && (!!query || searchText === ZERO_WIDTH_SPACE)
|
||||
|
||||
const pluginsParams = useMemo(() => {
|
||||
if (!hasQuery)
|
||||
return undefined
|
||||
const category = searchTab === 'plugins' && searchFilterType !== CATEGORY_ALL
|
||||
? searchFilterType
|
||||
: undefined
|
||||
const tags = searchTab === 'plugins' && searchFilterTags.length > 0
|
||||
? searchFilterTags
|
||||
: undefined
|
||||
return {
|
||||
query,
|
||||
page_size: searchTab === 'all' ? ALL_TAB_PREVIEW_SIZE : PAGE_SIZE,
|
||||
sort_by: pluginSort.sortBy,
|
||||
sort_order: pluginSort.sortOrder,
|
||||
category,
|
||||
tags,
|
||||
type: getPluginFilterType(category || PLUGIN_TYPE_SEARCH_MAP.all),
|
||||
} as PluginsSearchParams
|
||||
}, [hasQuery, query, searchTab, pluginSort, searchFilterType, searchFilterTags])
|
||||
|
||||
const templatesParams = useMemo(() => {
|
||||
if (!hasQuery)
|
||||
return undefined
|
||||
const categories = searchTab === 'templates' && searchFilterCategories.length > 0
|
||||
? searchFilterCategories
|
||||
: undefined
|
||||
const languages = searchTab === 'templates' && searchFilterLanguages.length > 0
|
||||
? searchFilterLanguages
|
||||
: undefined
|
||||
return {
|
||||
query,
|
||||
page_size: searchTab === 'all' ? ALL_TAB_PREVIEW_SIZE : PAGE_SIZE,
|
||||
sort_by: templateSort.sortBy,
|
||||
sort_order: templateSort.sortOrder,
|
||||
categories,
|
||||
languages,
|
||||
}
|
||||
}, [hasQuery, query, searchTab, templateSort, searchFilterCategories, searchFilterLanguages])
|
||||
|
||||
const creatorsParams = useMemo(() => {
|
||||
if (!hasQuery)
|
||||
return undefined
|
||||
return {
|
||||
query,
|
||||
page_size: searchTab === 'all' ? ALL_TAB_PREVIEW_SIZE : PAGE_SIZE,
|
||||
// sort_by,
|
||||
// sort_order,
|
||||
}
|
||||
}, [hasQuery, query, searchTab])
|
||||
|
||||
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.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, ALL_TAB_PREVIEW_SIZE)}
|
||||
</section>
|
||||
)}
|
||||
{plugins.length > 0 && (
|
||||
<section>
|
||||
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
|
||||
{t('plugins', { ns: 'plugin' })}
|
||||
</h3>
|
||||
{renderPluginsSection(plugins, ALL_TAB_PREVIEW_SIZE)}
|
||||
</section>
|
||||
)}
|
||||
{creators.length > 0 && (
|
||||
<section>
|
||||
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
|
||||
{t('marketplace.searchFilterCreators', { ns: 'plugin' })}
|
||||
</h3>
|
||||
{renderCreatorsSection(creators, ALL_TAB_PREVIEW_SIZE)}
|
||||
</section>
|
||||
)}
|
||||
{!isLoading && plugins.length === 0 && templates.length === 0 && creators.length === 0 && (
|
||||
<Empty />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderTab = <T,>(
|
||||
items: T[],
|
||||
isItemLoading: boolean,
|
||||
renderSection: (items: T[]) => React.ReactNode,
|
||||
emptyText?: string,
|
||||
) => {
|
||||
if (items.length === 0 && !isItemLoading)
|
||||
return <Empty text={emptyText} />
|
||||
return (
|
||||
<div className="py-4">
|
||||
{renderSection(items)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
scrollbarGutter: 'stable',
|
||||
paddingBottom: 'calc(0.5rem + var(--marketplace-header-collapse-offset, 0px))',
|
||||
}}
|
||||
className="relative flex grow flex-col bg-background-default-subtle px-12 pt-2"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SegmentedControl
|
||||
size="large"
|
||||
activeState="accentLight"
|
||||
value={searchTab}
|
||||
onChange={v => setSearchTab(v as SearchTab)}
|
||||
options={tabOptions}
|
||||
/>
|
||||
{searchTab === 'templates' && <TemplateFilters />}
|
||||
{searchTab === 'plugins' && <PluginFilters />}
|
||||
</div>
|
||||
{(searchTab === 'templates' || searchTab === 'plugins') && <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' && renderTab(plugins, pluginsQuery.isLoading, renderPluginsSection)}
|
||||
{searchTab === 'templates' && renderTab(templates, templatesQuery.isLoading, renderTemplatesSection, t('marketplace.noTemplateFound', { ns: 'plugin' }))}
|
||||
{searchTab === 'creators' && renderTab(creators, creatorsQuery.isLoading, renderCreatorsSection, t('marketplace.noCreatorFound', { ns: 'plugin' }))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isFetchingNextPage && <Loading className="my-3" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchPage
|
||||
@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import type { FilterOption } from './filter-chip'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useMemo } from 'react'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import { useSearchFilterTags, useSearchFilterType } from '../atoms'
|
||||
import { usePluginCategoryText } from '../category-switch/category-text'
|
||||
import { CATEGORY_ALL, PLUGIN_TYPE_SEARCH_MAP } from '../constants'
|
||||
import FilterChip from './filter-chip'
|
||||
|
||||
const PluginFilters = () => {
|
||||
const { t } = useTranslation()
|
||||
const [searchType, setSearchType] = useSearchFilterType()
|
||||
const [searchTags, setSearchTags] = useSearchFilterTags()
|
||||
const getPluginCategoryText = usePluginCategoryText()
|
||||
const { tags: tagsList } = useTags()
|
||||
|
||||
const typeOptions: FilterOption[] = useMemo(() => {
|
||||
return Object.values(PLUGIN_TYPE_SEARCH_MAP).map(value => ({
|
||||
value,
|
||||
label: getPluginCategoryText(value),
|
||||
}))
|
||||
}, [getPluginCategoryText])
|
||||
|
||||
const tagOptions: FilterOption[] = useMemo(() => {
|
||||
return tagsList.map(tag => ({
|
||||
value: tag.name,
|
||||
label: tag.label,
|
||||
}))
|
||||
}, [tagsList])
|
||||
|
||||
const typeValue = searchType === CATEGORY_ALL ? [] : [searchType]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterChip
|
||||
label={t('marketplace.searchFilterTypes', { ns: 'plugin' })}
|
||||
options={typeOptions}
|
||||
value={typeValue}
|
||||
onChange={(v) => {
|
||||
const newType = v.length > 0 ? v[v.length - 1] : CATEGORY_ALL
|
||||
setSearchType(newType === CATEGORY_ALL ? null : newType)
|
||||
}}
|
||||
multiple={false}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t('marketplace.searchFilterTags', { ns: 'plugin' })}
|
||||
options={tagOptions}
|
||||
value={searchTags}
|
||||
onChange={v => setSearchTags(v.length ? v : null)}
|
||||
multiple
|
||||
searchable
|
||||
searchPlaceholder={t('searchTags', { ns: 'pluginTags' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginFilters
|
||||
@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import type { FilterOption } from './filter-chip'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useMemo } from 'react'
|
||||
import { useSearchFilterCategories, useSearchFilterLanguages } from '../atoms'
|
||||
import { useTemplateCategoryText } from '../category-switch/category-text'
|
||||
import { TEMPLATE_CATEGORY_MAP } from '../constants'
|
||||
import { LANGUAGE_OPTIONS } from './constants'
|
||||
import FilterChip from './filter-chip'
|
||||
|
||||
const TemplateFilters = () => {
|
||||
const { t } = useTranslation()
|
||||
const [categories, setCategories] = useSearchFilterCategories()
|
||||
const [languages, setLanguages] = useSearchFilterLanguages()
|
||||
const getTemplateCategoryText = useTemplateCategoryText()
|
||||
|
||||
const categoryOptions: FilterOption[] = useMemo(() => {
|
||||
const entries = Object.entries(TEMPLATE_CATEGORY_MAP).filter(([key]) => key !== 'all')
|
||||
return entries.map(([, value]) => ({
|
||||
value,
|
||||
label: getTemplateCategoryText(value),
|
||||
}))
|
||||
}, [getTemplateCategoryText])
|
||||
|
||||
const languageOptions: FilterOption[] = useMemo(() => {
|
||||
return LANGUAGE_OPTIONS.map(lang => ({
|
||||
value: lang.value,
|
||||
label: `${lang.nativeLabel}`,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterChip
|
||||
label={t('marketplace.searchFilterCategory', { ns: 'plugin' })}
|
||||
options={categoryOptions}
|
||||
value={categories}
|
||||
onChange={v => setCategories(v.length ? v : null)}
|
||||
multiple
|
||||
searchable
|
||||
searchPlaceholder={t('searchCategories', { ns: 'plugin' })}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t('marketplace.searchFilterLanguage', { ns: 'plugin' })}
|
||||
options={languageOptions}
|
||||
value={languages}
|
||||
onChange={v => setLanguages(v.length ? v : null)}
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplateFilters
|
||||
@ -1,9 +1,26 @@
|
||||
import type { ActivePluginType } from './constants'
|
||||
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
|
||||
export const CREATION_TYPE = {
|
||||
plugins: 'plugins',
|
||||
templates: 'templates',
|
||||
} as const
|
||||
|
||||
export type CreationType = typeof CREATION_TYPE[keyof typeof CREATION_TYPE]
|
||||
export const SEARCH_TABS = ['all', 'plugins', 'templates', 'creators'] as const
|
||||
export type SearchTab = (typeof SEARCH_TABS)[number] | ''
|
||||
|
||||
export const marketplaceSearchParamsParsers = {
|
||||
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' }),
|
||||
languages: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
|
||||
// Search-page-specific filters (independent from list-page category/tags)
|
||||
searchCategories: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
|
||||
searchLanguages: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
|
||||
searchType: parseAsString.withDefault('all').withOptions({ history: 'replace' }),
|
||||
searchTags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
|
||||
|
||||
// In marketplace, we use path instead of query
|
||||
category: parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
|
||||
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' }),
|
||||
}
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
'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
|
||||
@ -30,9 +30,15 @@ vi.mock('#i18n', () => ({
|
||||
// Mock marketplace atoms with controllable values
|
||||
let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const mockHandleSortChange = vi.fn()
|
||||
let mockCreationType = 'plugins'
|
||||
|
||||
vi.mock('../atoms', () => ({
|
||||
useMarketplaceSort: () => [mockSort, mockHandleSortChange],
|
||||
useActiveSort: () => [mockSort, mockHandleSortChange],
|
||||
useCreationType: () => [mockCreationType, vi.fn()],
|
||||
}))
|
||||
|
||||
vi.mock('../search-params', () => ({
|
||||
CREATION_TYPE: { plugins: 'plugins', templates: 'templates' },
|
||||
}))
|
||||
|
||||
// Mock portal component with controllable open state
|
||||
@ -91,6 +97,7 @@ describe('SortDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
mockCreationType = 'plugins'
|
||||
mockPortalOpenState = false
|
||||
})
|
||||
|
||||
|
||||
@ -10,33 +10,36 @@ import {
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useMarketplaceSort } from '../atoms'
|
||||
import { useActiveSort, useCreationType } from '../atoms'
|
||||
import { CREATION_TYPE } from '../search-params'
|
||||
|
||||
const PLUGIN_SORT_OPTIONS = [
|
||||
{ value: 'install_count', order: 'DESC', labelKey: 'marketplace.sortOption.mostPopular' },
|
||||
{ value: 'version_updated_at', order: 'DESC', labelKey: 'marketplace.sortOption.recentlyUpdated' },
|
||||
{ value: 'created_at', order: 'DESC', labelKey: 'marketplace.sortOption.newlyReleased' },
|
||||
{ value: 'created_at', order: 'ASC', labelKey: 'marketplace.sortOption.firstReleased' },
|
||||
] as const
|
||||
|
||||
const TEMPLATE_SORT_OPTIONS = [
|
||||
{ value: 'usage_count', order: 'DESC', labelKey: 'marketplace.sortOption.mostPopular' },
|
||||
{ value: 'updated_at', order: 'DESC', labelKey: 'marketplace.sortOption.recentlyUpdated' },
|
||||
{ value: 'created_at', order: 'DESC', labelKey: 'marketplace.sortOption.newlyReleased' },
|
||||
{ value: 'created_at', order: 'ASC', labelKey: 'marketplace.sortOption.firstReleased' },
|
||||
] as const
|
||||
|
||||
const SortDropdown = () => {
|
||||
const { t } = useTranslation()
|
||||
const options = [
|
||||
{
|
||||
value: 'install_count',
|
||||
order: 'DESC',
|
||||
text: t('marketplace.sortOption.mostPopular', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
value: 'version_updated_at',
|
||||
order: 'DESC',
|
||||
text: t('marketplace.sortOption.recentlyUpdated', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
value: 'created_at',
|
||||
order: 'DESC',
|
||||
text: t('marketplace.sortOption.newlyReleased', { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
value: 'created_at',
|
||||
order: 'ASC',
|
||||
text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }),
|
||||
},
|
||||
]
|
||||
const [sort, handleSortChange] = useMarketplaceSort()
|
||||
const creationType = useCreationType()
|
||||
const isTemplates = creationType === CREATION_TYPE.templates
|
||||
|
||||
const rawOptions = isTemplates ? TEMPLATE_SORT_OPTIONS : PLUGIN_SORT_OPTIONS
|
||||
const options = rawOptions.map(opt => ({
|
||||
value: opt.value,
|
||||
order: opt.order,
|
||||
text: t(opt.labelKey, { ns: 'plugin' }),
|
||||
}))
|
||||
|
||||
const [sort, handleSortChange] = useActiveSort()
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]
|
||||
|
||||
|
||||
@ -1,38 +1,50 @@
|
||||
import type { PluginsSearchParams } from './types'
|
||||
import type { PluginsSearchParams, TemplateSearchParams } from './types'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
import { useActivePluginCategory, useActiveTemplateCategory, useCreationType, useFilterPluginTags, useFilterTemplateLanguages, useMarketplacePluginSortValue, useMarketplaceSearchMode, useMarketplaceTemplateSortValue, useSearchText } from './atoms'
|
||||
import { CATEGORY_ALL } from './constants'
|
||||
import { useMarketplaceContainerScroll } from './hooks'
|
||||
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query'
|
||||
import { getCollectionsParams, getMarketplaceListFilterType } from './utils'
|
||||
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query'
|
||||
import { CREATION_TYPE } from './search-params'
|
||||
import { getCollectionsParams, getPluginFilterType, mapTemplateDetailToTemplate } from './utils'
|
||||
|
||||
export function useMarketplaceData() {
|
||||
const [searchPluginTextOriginal] = useSearchPluginText()
|
||||
const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 })
|
||||
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 })
|
||||
const [filterPluginTags] = useFilterPluginTags()
|
||||
const [activePluginType] = useActivePluginType()
|
||||
const [activePluginCategory] = useActivePluginCategory()
|
||||
|
||||
const collectionsQuery = useMarketplaceCollectionsAndPlugins(
|
||||
getCollectionsParams(activePluginType),
|
||||
const pluginsCollectionsQuery = useMarketplaceCollectionsAndPlugins(
|
||||
getCollectionsParams(activePluginCategory),
|
||||
{ enabled },
|
||||
)
|
||||
|
||||
const sort = useMarketplaceSortValue()
|
||||
const sort = useMarketplacePluginSortValue()
|
||||
const isSearchMode = useMarketplaceSearchMode()
|
||||
const queryParams = useMemo((): PluginsSearchParams | undefined => {
|
||||
if (!isSearchMode)
|
||||
return undefined
|
||||
return {
|
||||
query: searchPluginText,
|
||||
category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType,
|
||||
query: searchText,
|
||||
category: getCategory(activePluginCategory),
|
||||
tags: filterPluginTags,
|
||||
sort_by: sort.sortBy,
|
||||
sort_order: sort.sortOrder,
|
||||
type: getMarketplaceListFilterType(activePluginType),
|
||||
type: getPluginFilterType(activePluginCategory),
|
||||
}
|
||||
}, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort])
|
||||
}, [isSearchMode, searchText, activePluginCategory, filterPluginTags, sort])
|
||||
|
||||
const pluginsQuery = useMarketplacePlugins(queryParams)
|
||||
const pluginsQuery = useMarketplacePlugins(queryParams, { enabled })
|
||||
const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = pluginsQuery
|
||||
|
||||
const handlePageChange = useCallback(() => {
|
||||
@ -44,12 +56,89 @@ export function useMarketplaceData() {
|
||||
useMarketplaceContainerScroll(handlePageChange)
|
||||
|
||||
return {
|
||||
marketplaceCollections: collectionsQuery.data?.marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap,
|
||||
pluginCollections: pluginsCollectionsQuery.data?.marketplaceCollections,
|
||||
pluginCollectionPluginsMap: pluginsCollectionsQuery.data?.marketplaceCollectionPluginsMap,
|
||||
plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins),
|
||||
pluginsTotal: pluginsQuery.data?.pages[0]?.total,
|
||||
page: pluginsQuery.data?.pages.length || 1,
|
||||
isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading,
|
||||
isLoading: pluginsCollectionsQuery.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()
|
||||
const [filterTemplateLanguages] = useFilterTemplateLanguages()
|
||||
|
||||
// Template collections query (for non-search mode)
|
||||
const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates(undefined, { enabled })
|
||||
|
||||
// Template-specific sort value (independent from plugin sort)
|
||||
const sort = useMarketplaceTemplateSortValue()
|
||||
|
||||
// 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,
|
||||
...(filterTemplateLanguages.length > 0 ? { languages: filterTemplateLanguages } : {}),
|
||||
}
|
||||
}, [isSearchMode, searchText, activeTemplateCategory, sort, filterTemplateLanguages])
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
'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
|
||||
@ -6,7 +6,7 @@ export type SearchParamsFromCollection = {
|
||||
sort_order?: string
|
||||
}
|
||||
|
||||
export type MarketplaceCollection = {
|
||||
export type PluginCollection = {
|
||||
name: string
|
||||
label: Record<string, string>
|
||||
description: Record<string, string>
|
||||
@ -18,7 +18,7 @@ export type MarketplaceCollection = {
|
||||
}
|
||||
|
||||
export type MarketplaceCollectionsResponse = {
|
||||
collections: MarketplaceCollection[]
|
||||
collections: PluginCollection[]
|
||||
total: number
|
||||
}
|
||||
|
||||
@ -56,4 +56,163 @@ 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 = {
|
||||
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
|
||||
deps_plugins: string[]
|
||||
preferred_languages: string[]
|
||||
publisher_handle: string
|
||||
publisher_type: string
|
||||
kind: string
|
||||
status: string
|
||||
usage_count: number
|
||||
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 = {
|
||||
id?: string
|
||||
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, extends Template with extra fields)
|
||||
export type TemplateDetail = Template & {
|
||||
publisher_unique_handle: string
|
||||
creator_email: string
|
||||
dsl_file_key: string
|
||||
review_comment: 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 (same as Template)
|
||||
export type UnifiedTemplateItem = Template
|
||||
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,18 @@
|
||||
import type { ActivePluginType } from './constants'
|
||||
import type {
|
||||
CollectionsAndPluginsSearchParams,
|
||||
MarketplaceCollection,
|
||||
Creator,
|
||||
CreatorSearchParams,
|
||||
PluginCollection,
|
||||
PluginsSearchParams,
|
||||
Template,
|
||||
TemplateCollection,
|
||||
TemplateDetail,
|
||||
TemplateSearchParams,
|
||||
UnifiedCreatorItem,
|
||||
UnifiedPluginItem,
|
||||
UnifiedSearchParams,
|
||||
UnifiedSearchResponse,
|
||||
} from '@/app/components/plugins/marketplace/types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
@ -17,12 +27,53 @@ type MarketplaceFetchOptions = {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Get a string key from an item by field name (e.g. plugin_id, id). */
|
||||
export function getItemKeyByField<T>(item: T, field: keyof T): string {
|
||||
return String((item as Record<string, unknown>)[field as string])
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a flat array into columns for a carousel grid layout.
|
||||
* When the item count exceeds `maxVisibleColumns`, items are arranged in
|
||||
* a two-row, column-first order with the first row always fully filled.
|
||||
*/
|
||||
export function buildCarouselColumns<T>(items: T[], maxVisibleColumns: number): T[][] {
|
||||
const useDoubleRow = items.length > maxVisibleColumns
|
||||
const numColumns = useDoubleRow
|
||||
? Math.max(maxVisibleColumns, Math.ceil(items.length / 2))
|
||||
: items.length
|
||||
const columns: T[][] = []
|
||||
for (let i = 0; i < numColumns; i++) {
|
||||
const column: T[] = [items[i]]
|
||||
if (useDoubleRow && i + numColumns < items.length)
|
||||
column.push(items[i + numColumns])
|
||||
columns.push(column)
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
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 getTemplateIconUrl = (template: { id: string, icon?: string, icon_file_key?: string }): string => {
|
||||
if (template.icon?.startsWith('http'))
|
||||
return template.icon
|
||||
if (template.icon_file_key)
|
||||
return `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon`
|
||||
return ''
|
||||
}
|
||||
|
||||
export const getCreatorAvatarUrl = (uniqueHandle: string) => {
|
||||
return `${MARKETPLACE_API_PREFIX}/creators/${uniqueHandle}/avatar`
|
||||
}
|
||||
|
||||
export const getOrganizationAvatarUrl = (id: string) => {
|
||||
return `${MARKETPLACE_API_PREFIX}/organizations/${id}/avatar`
|
||||
}
|
||||
|
||||
export const getFormattedPlugin = (bundle: Plugin): Plugin => {
|
||||
if (bundle.type === 'bundle') {
|
||||
return {
|
||||
@ -42,13 +93,13 @@ export const getFormattedPlugin = (bundle: Plugin): Plugin => {
|
||||
export const getPluginLinkInMarketplace = (plugin: Plugin, params?: Record<string, string | undefined>) => {
|
||||
if (plugin.type === 'bundle')
|
||||
return getMarketplaceUrl(`/bundles/${plugin.org}/${plugin.name}`, params)
|
||||
return getMarketplaceUrl(`/plugins/${plugin.org}/${plugin.name}`, params)
|
||||
return getMarketplaceUrl(`/plugin/${plugin.org}/${plugin.name}`, params)
|
||||
}
|
||||
|
||||
export const getPluginDetailLinkInMarketplace = (plugin: Plugin) => {
|
||||
if (plugin.type === 'bundle')
|
||||
return `/bundles/${plugin.org}/${plugin.name}`
|
||||
return `/plugins/${plugin.org}/${plugin.name}`
|
||||
return `/plugin/${plugin.org}/${plugin.name}`
|
||||
}
|
||||
|
||||
export const getMarketplacePluginsByCollectionId = async (
|
||||
@ -59,7 +110,7 @@ export const getMarketplacePluginsByCollectionId = async (
|
||||
let plugins: Plugin[] = []
|
||||
|
||||
try {
|
||||
const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins({
|
||||
const marketplaceCollectionPluginsDataJson = await marketplaceClient.plugins.collectionPlugins({
|
||||
params: {
|
||||
collectionId,
|
||||
},
|
||||
@ -81,10 +132,10 @@ export const getMarketplaceCollectionsAndPlugins = async (
|
||||
query?: CollectionsAndPluginsSearchParams,
|
||||
options?: MarketplaceFetchOptions,
|
||||
) => {
|
||||
let marketplaceCollections: MarketplaceCollection[] = []
|
||||
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
|
||||
let pluginCollections: PluginCollection[] = []
|
||||
let pluginCollectionPluginsMap: Record<string, Plugin[]> = {}
|
||||
try {
|
||||
const marketplaceCollectionsDataJson = await marketplaceClient.collections({
|
||||
const collectionsDataJson = await marketplaceClient.plugins.collections({
|
||||
query: {
|
||||
...query,
|
||||
page: 1,
|
||||
@ -93,22 +144,75 @@ export const getMarketplaceCollectionsAndPlugins = async (
|
||||
}, {
|
||||
signal: options?.signal,
|
||||
})
|
||||
marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || []
|
||||
await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => {
|
||||
pluginCollections = collectionsDataJson.data?.collections || []
|
||||
await Promise.all(pluginCollections.map(async (collection: PluginCollection) => {
|
||||
const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options)
|
||||
|
||||
marketplaceCollectionPluginsMap[collection.name] = plugins
|
||||
pluginCollectionPluginsMap[collection.name] = plugins
|
||||
}))
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
marketplaceCollections = []
|
||||
marketplaceCollectionPluginsMap = {}
|
||||
pluginCollections = []
|
||||
pluginCollectionPluginsMap = {}
|
||||
}
|
||||
|
||||
return {
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
marketplaceCollections: pluginCollections,
|
||||
marketplaceCollectionPluginsMap: pluginCollectionPluginsMap,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapTemplateDetailToTemplate(template: TemplateDetail): Template {
|
||||
// TemplateDetail extends Template; just override publisher_handle from the detail-specific field
|
||||
return {
|
||||
...template,
|
||||
publisher_handle: template.publisher_handle || template.publisher_unique_handle || template.creator_email || '',
|
||||
index_id: template.index_id || template.id,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,7 +241,7 @@ export const getMarketplacePlugins = async (
|
||||
} = queryParams
|
||||
|
||||
try {
|
||||
const res = await marketplaceClient.searchAdvanced({
|
||||
const res = await marketplaceClient.plugins.searchAdvanced({
|
||||
params: {
|
||||
kind: type === 'bundle' ? 'bundles' : 'plugins',
|
||||
},
|
||||
@ -170,7 +274,7 @@ export const getMarketplacePlugins = async (
|
||||
}
|
||||
}
|
||||
|
||||
export const getMarketplaceListCondition = (pluginType: string) => {
|
||||
export const getPluginCondition = (pluginType: string) => {
|
||||
if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum))
|
||||
return `category=${pluginType}`
|
||||
|
||||
@ -183,7 +287,7 @@ export const getMarketplaceListCondition = (pluginType: string) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
export const getMarketplaceListFilterType = (category: ActivePluginType) => {
|
||||
export const getPluginFilterType = (category: ActivePluginType) => {
|
||||
if (category === PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
return undefined
|
||||
|
||||
@ -199,7 +303,242 @@ export function getCollectionsParams(category: ActivePluginType): CollectionsAnd
|
||||
}
|
||||
return {
|
||||
category,
|
||||
condition: getMarketplaceListCondition(category),
|
||||
type: getMarketplaceListFilterType(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 body = {
|
||||
page: pageParam,
|
||||
page_size,
|
||||
query,
|
||||
sort_by,
|
||||
sort_order,
|
||||
...(categories ? { categories } : {}),
|
||||
...(languages ? { languages } : {}),
|
||||
}
|
||||
const res = await marketplaceClient.templates.searchAdvanced({
|
||||
body,
|
||||
}, { 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 (identity since UnifiedTemplateItem = Template)
|
||||
*/
|
||||
export function mapUnifiedTemplateToTemplate(item: Template): Template {
|
||||
return item
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,7 +65,7 @@ const getDetailUrl = (
|
||||
return `https://github.com/${repo}`
|
||||
}
|
||||
if (source === PluginSource.marketplace)
|
||||
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme })
|
||||
return getMarketplaceUrl(`/plugin/${author}/${name}`, { language: locale, theme })
|
||||
return ''
|
||||
}
|
||||
|
||||
@ -249,7 +249,7 @@ const DetailHeader = ({
|
||||
status={status}
|
||||
deprecatedReason={deprecated_reason}
|
||||
alternativePluginId={alternative_plugin_id}
|
||||
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
|
||||
alternativePluginURL={getMarketplaceUrl(`/plugin/${alternative_plugin_id}`, { language: currentLocale, theme })}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -195,7 +195,7 @@ const PluginItem: FC<Props> = ({
|
||||
{source === PluginSource.marketplace && enable_marketplace
|
||||
&& (
|
||||
<>
|
||||
<a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target="_blank" className="flex items-center gap-0.5">
|
||||
<a href={getMarketplaceUrl(`/plugin/${author}/${name}`, { theme })} target="_blank" className="flex items-center gap-0.5">
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('from', { ns: 'plugin' })}
|
||||
{' '}
|
||||
|
||||
@ -2,13 +2,11 @@
|
||||
|
||||
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'
|
||||
@ -17,22 +15,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'
|
||||
@ -46,7 +44,6 @@ const PluginPage = ({
|
||||
marketplace,
|
||||
}: PluginPageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
useDocumentTitle(t('metadata.title', { ns: 'plugin' }))
|
||||
|
||||
// Use nuqs hook for installation state
|
||||
@ -140,55 +137,20 @@ const PluginPage = ({
|
||||
id="marketplace-container"
|
||||
ref={containerRef}
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
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')}
|
||||
className="relative flex grow flex-col overflow-y-auto rounded-t-xl border-t border-divider-subtle bg-components-panel-bg"
|
||||
>
|
||||
<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="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="flex w-full items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-1 items-center justify-start gap-2">
|
||||
<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 && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{isExploringMarketplace && <SubmitRequestDropdown />}
|
||||
<PluginTasks />
|
||||
{canManagement && (
|
||||
<InstallPluginDropdown
|
||||
|
||||
143
web/app/components/plugins/plugin-page/nav-operations.tsx
Normal file
143
web/app/components/plugins/plugin-page/nav-operations.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
import type { DocPathWithoutLang } from '@/types/doc-paths'
|
||||
import { RiAddLine, RiArrowRightUpLine, RiBookOpenLine } 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 { MARKETPLACE_URL_PREFIX } from '@/config'
|
||||
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-text-secondary">{text}</span>
|
||||
<RiArrowRightUpLine className="ml-auto h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</Link>
|
||||
)
|
||||
|
||||
type OptionLabelKey = 'requestAPlugin' | 'publishPlugins' | 'createPublishTemplates'
|
||||
|
||||
const getOptions = (docLink: (path: DocPathWithoutLang) => string): { href: string, icon: React.ReactNode, labelKey: OptionLabelKey }[] => {
|
||||
return [
|
||||
{
|
||||
href: 'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml',
|
||||
icon: <Plugin className="h-4 w-4 shrink-0 text-text-tertiary" />,
|
||||
labelKey: 'requestAPlugin',
|
||||
},
|
||||
{
|
||||
href: docLink('/develop-plugin/publishing/marketplace-listing/release-to-dify-marketplace'),
|
||||
icon: <RiBookOpenLine className="h-4 w-4 shrink-0 text-text-tertiary" />,
|
||||
labelKey: 'publishPlugins',
|
||||
},
|
||||
{
|
||||
href: MARKETPLACE_URL_PREFIX.replace('marketplace', 'creators'),
|
||||
icon: <Playground className="h-4 w-4 shrink-0 text-text-tertiary" />,
|
||||
labelKey: 'createPublishTemplates',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
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">
|
||||
{getOptions(docLink).map(option => (
|
||||
<DropdownItem
|
||||
key={option.href}
|
||||
href={option.href}
|
||||
icon={option.icon}
|
||||
text={t(option.labelKey, { 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={`/${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={`/${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">
|
||||
{t('badge.new', { ns: 'plugin' })}
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -9,11 +9,11 @@ import {
|
||||
useMarketplaceCollectionsAndPlugins,
|
||||
useMarketplacePlugins,
|
||||
} from '@/app/components/plugins/marketplace/hooks'
|
||||
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
|
||||
import { getPluginCondition } from '@/app/components/plugins/marketplace/utils'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useAllToolProviders } from '@/service/use-tools'
|
||||
|
||||
export const useMarketplace = (searchPluginText: string, filterPluginTags: string[]) => {
|
||||
export const useMarketplace = (searchText: string, filterPluginTags: string[]) => {
|
||||
const { data: toolProvidersData, isSuccess } = useAllToolProviders()
|
||||
const exclude = useMemo(() => {
|
||||
if (isSuccess)
|
||||
@ -21,8 +21,8 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
}, [isSuccess, toolProvidersData])
|
||||
const {
|
||||
isLoading,
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
pluginCollections,
|
||||
pluginCollectionPluginsMap,
|
||||
queryMarketplaceCollectionsAndPlugins,
|
||||
} = useMarketplaceCollectionsAndPlugins()
|
||||
const {
|
||||
@ -35,19 +35,19 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
hasNextPage,
|
||||
page: pluginsPage,
|
||||
} = useMarketplacePlugins()
|
||||
const searchPluginTextRef = useRef(searchPluginText)
|
||||
const searchTextRef = useRef(searchText)
|
||||
const filterPluginTagsRef = useRef(filterPluginTags)
|
||||
|
||||
useEffect(() => {
|
||||
searchPluginTextRef.current = searchPluginText
|
||||
searchTextRef.current = searchText
|
||||
filterPluginTagsRef.current = filterPluginTags
|
||||
}, [searchPluginText, filterPluginTags])
|
||||
}, [searchText, filterPluginTags])
|
||||
useEffect(() => {
|
||||
if ((searchPluginText || filterPluginTags.length) && isSuccess) {
|
||||
if (searchPluginText) {
|
||||
if ((searchText || filterPluginTags.length) && isSuccess) {
|
||||
if (searchText) {
|
||||
queryPluginsWithDebounced({
|
||||
category: PluginCategoryEnum.tool,
|
||||
query: searchPluginText,
|
||||
query: searchText,
|
||||
tags: filterPluginTags,
|
||||
exclude,
|
||||
type: 'plugin',
|
||||
@ -56,7 +56,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
}
|
||||
queryPlugins({
|
||||
category: PluginCategoryEnum.tool,
|
||||
query: searchPluginText,
|
||||
query: searchText,
|
||||
tags: filterPluginTags,
|
||||
exclude,
|
||||
type: 'plugin',
|
||||
@ -66,14 +66,14 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
if (isSuccess) {
|
||||
queryMarketplaceCollectionsAndPlugins({
|
||||
category: PluginCategoryEnum.tool,
|
||||
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
|
||||
condition: getPluginCondition(PluginCategoryEnum.tool),
|
||||
exclude,
|
||||
type: 'plugin',
|
||||
})
|
||||
resetPlugins()
|
||||
}
|
||||
}
|
||||
}, [searchPluginText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess])
|
||||
}, [searchText, 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 = (searchPluginText: string, filterPluginTags: strin
|
||||
clientHeight,
|
||||
} = target
|
||||
if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) {
|
||||
const searchPluginText = searchPluginTextRef.current
|
||||
const searchText = searchTextRef.current
|
||||
const filterPluginTags = filterPluginTagsRef.current
|
||||
if (hasNextPage && (!!searchPluginText || !!filterPluginTags.length))
|
||||
if (hasNextPage && (!!searchText || !!filterPluginTags.length))
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [exclude, fetchNextPage, hasNextPage, plugins, queryPlugins])
|
||||
|
||||
return {
|
||||
isLoading: isLoading || isPluginsLoading,
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
pluginCollections,
|
||||
pluginCollectionPluginsMap,
|
||||
plugins,
|
||||
handleScroll,
|
||||
page: Math.max(pluginsPage || 0, 1),
|
||||
|
||||
@ -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 { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
|
||||
import { getPluginCondition } 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: {
|
||||
marketplaceCollections: unknown[]
|
||||
marketplaceCollectionPluginsMap: Record<string, unknown[]>
|
||||
pluginCollections: unknown[]
|
||||
pluginCollectionPluginsMap: 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,
|
||||
marketplaceCollections: [],
|
||||
marketplaceCollectionPluginsMap: {},
|
||||
pluginCollections: [],
|
||||
pluginCollectionPluginsMap: {},
|
||||
plugins: [],
|
||||
handleScroll: vi.fn(),
|
||||
page: 1,
|
||||
@ -110,7 +110,7 @@ describe('Marketplace', () => {
|
||||
const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 })
|
||||
render(
|
||||
<Marketplace
|
||||
searchPluginText=""
|
||||
searchText=""
|
||||
filterPluginTags={[]}
|
||||
isMarketplaceArrowVisible={false}
|
||||
showMarketplacePanel={vi.fn()}
|
||||
@ -131,7 +131,7 @@ describe('Marketplace', () => {
|
||||
})
|
||||
render(
|
||||
<Marketplace
|
||||
searchPluginText=""
|
||||
searchText=""
|
||||
filterPluginTags={[]}
|
||||
isMarketplaceArrowVisible={false}
|
||||
showMarketplacePanel={vi.fn()}
|
||||
@ -156,7 +156,7 @@ describe('Marketplace', () => {
|
||||
const showMarketplacePanel = vi.fn()
|
||||
const { container } = render(
|
||||
<Marketplace
|
||||
searchPluginText="vector"
|
||||
searchText="vector"
|
||||
filterPluginTags={['tag-a', 'tag-b']}
|
||||
isMarketplaceArrowVisible
|
||||
showMarketplacePanel={showMarketplacePanel}
|
||||
@ -199,8 +199,8 @@ describe('useMarketplace', () => {
|
||||
}) => {
|
||||
mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({
|
||||
isLoading: overrides?.isLoading ?? false,
|
||||
marketplaceCollections: [],
|
||||
marketplaceCollectionPluginsMap: {},
|
||||
pluginCollections: [],
|
||||
pluginCollectionPluginsMap: {},
|
||||
queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins,
|
||||
})
|
||||
mockUseMarketplacePlugins.mockReturnValue({
|
||||
@ -289,7 +289,7 @@ describe('useMarketplace', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockQueryMarketplaceCollectionsAndPlugins).toHaveBeenCalledWith({
|
||||
category: PluginCategoryEnum.tool,
|
||||
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
|
||||
condition: getPluginCondition(PluginCategoryEnum.tool),
|
||||
exclude: ['plugin-c'],
|
||||
type: 'plugin',
|
||||
})
|
||||
|
||||
@ -11,14 +11,14 @@ import List from '@/app/components/plugins/marketplace/list'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
type MarketplaceProps = {
|
||||
searchPluginText: string
|
||||
searchText: string
|
||||
filterPluginTags: string[]
|
||||
isMarketplaceArrowVisible: boolean
|
||||
showMarketplacePanel: () => void
|
||||
marketplaceContext: ReturnType<typeof useMarketplace>
|
||||
}
|
||||
const Marketplace = ({
|
||||
searchPluginText,
|
||||
searchText,
|
||||
filterPluginTags,
|
||||
isMarketplaceArrowVisible,
|
||||
showMarketplacePanel,
|
||||
@ -29,8 +29,8 @@ const Marketplace = ({
|
||||
const { theme } = useTheme()
|
||||
const {
|
||||
isLoading,
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
pluginCollections,
|
||||
pluginCollectionPluginsMap,
|
||||
plugins,
|
||||
page,
|
||||
} = marketplaceContext
|
||||
@ -79,7 +79,7 @@ const Marketplace = ({
|
||||
</span>
|
||||
{t('operation.in', { ns: 'common' })}
|
||||
<a
|
||||
href={getMarketplaceUrl('', { language: locale, q: searchPluginText, tags: filterPluginTags.join(','), theme })}
|
||||
href={getMarketplaceUrl('', { language: locale, q: searchText, 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
|
||||
marketplaceCollections={marketplaceCollections || []}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
|
||||
pluginCollections={pluginCollections || []}
|
||||
pluginCollectionPluginsMap={pluginCollectionPluginsMap || {}}
|
||||
plugins={plugins}
|
||||
showInstallButton
|
||||
/>
|
||||
|
||||
@ -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 CardMoreInfo from '@/app/components/plugins/card/card-more-info'
|
||||
import CardTags from '@/app/components/plugins/card/card-tags'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import 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={(
|
||||
<CardMoreInfo
|
||||
<CardTags
|
||||
tags={collection.labels?.map(label => getTagLabel(label)) || []}
|
||||
/>
|
||||
)}
|
||||
@ -199,7 +199,7 @@ const ProviderList = () => {
|
||||
<div ref={toolListTailRef} />
|
||||
{enable_marketplace && activeTab === 'builtin' && (
|
||||
<Marketplace
|
||||
searchPluginText={keywords}
|
||||
searchText={keywords}
|
||||
filterPluginTags={tagFilterValue}
|
||||
isMarketplaceArrowVisible={isMarketplaceArrowVisible}
|
||||
showMarketplacePanel={showMarketplacePanel}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user