mirror of
https://github.com/langgenius/dify.git
synced 2026-02-04 18:57:51 +08:00
Compare commits
20 Commits
fix/search
...
deploy/dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 02206cc64b | |||
| cba157038f | |||
| d606de26f1 | |||
| 49f46a05e7 | |||
| 0d74ac634b | |||
| 30b73f2765 | |||
| 468990cc39 | |||
| 64e769f96e | |||
| 778aabb485 | |||
| d8402f686e | |||
| 8bd8dee767 | |||
| 05f2764d7c | |||
| f5d6c250ed | |||
| 45daec7541 | |||
| c14a8bb437 | |||
| b76c8fa853 | |||
| 8c3e77cd0c | |||
| 476946f122 | |||
| 62a698a883 | |||
| ebca36ffbb |
@ -1,15 +1,16 @@
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource, fields, marshal, marshal_with, reqparse
|
||||
from flask_restx import Resource, fields, marshal, marshal_with
|
||||
from pydantic import BaseModel
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.fields import Parameters as ParametersResponse
|
||||
from controllers.common.fields import Site as SiteResponse
|
||||
from controllers.common.schema import get_or_create_model
|
||||
from controllers.console import api
|
||||
from controllers.console import api, console_ns
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
AudioTooLargeError,
|
||||
@ -117,7 +118,56 @@ workflow_fields_copy["rag_pipeline_variables"] = fields.List(fields.Nested(pipel
|
||||
workflow_model = get_or_create_model("TrialWorkflow", workflow_fields_copy)
|
||||
|
||||
|
||||
# Pydantic models for request validation
|
||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||
|
||||
|
||||
class WorkflowRunRequest(BaseModel):
|
||||
inputs: dict
|
||||
files: list | None = None
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
inputs: dict
|
||||
query: str
|
||||
files: list | None = None
|
||||
conversation_id: str | None = None
|
||||
parent_message_id: str | None = None
|
||||
retriever_from: str = "explore_app"
|
||||
|
||||
|
||||
class TextToSpeechRequest(BaseModel):
|
||||
message_id: str | None = None
|
||||
voice: str | None = None
|
||||
text: str | None = None
|
||||
streaming: bool | None = None
|
||||
|
||||
|
||||
class CompletionRequest(BaseModel):
|
||||
inputs: dict
|
||||
query: str = ""
|
||||
files: list | None = None
|
||||
response_mode: Literal["blocking", "streaming"] | None = None
|
||||
retriever_from: str = "explore_app"
|
||||
|
||||
|
||||
# Register schemas for Swagger documentation
|
||||
console_ns.schema_model(
|
||||
WorkflowRunRequest.__name__, WorkflowRunRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
||||
)
|
||||
console_ns.schema_model(
|
||||
ChatRequest.__name__, ChatRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
||||
)
|
||||
console_ns.schema_model(
|
||||
TextToSpeechRequest.__name__, TextToSpeechRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
||||
)
|
||||
console_ns.schema_model(
|
||||
CompletionRequest.__name__, CompletionRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
||||
)
|
||||
|
||||
|
||||
class TrialAppWorkflowRunApi(TrialAppResource):
|
||||
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
|
||||
def post(self, trial_app):
|
||||
"""
|
||||
Run workflow
|
||||
@ -129,10 +179,8 @@ class TrialAppWorkflowRunApi(TrialAppResource):
|
||||
if app_mode != AppMode.WORKFLOW:
|
||||
raise NotWorkflowAppError()
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("inputs", type=dict, required=True, nullable=False, location="json")
|
||||
parser.add_argument("files", type=list, required=False, location="json")
|
||||
args = parser.parse_args()
|
||||
request_data = WorkflowRunRequest.model_validate(console_ns.payload)
|
||||
args = request_data.model_dump()
|
||||
assert current_user is not None
|
||||
try:
|
||||
app_id = app_model.id
|
||||
@ -183,6 +231,7 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource):
|
||||
|
||||
|
||||
class TrialChatApi(TrialAppResource):
|
||||
@console_ns.expect(console_ns.models[ChatRequest.__name__])
|
||||
@trial_feature_enable
|
||||
def post(self, trial_app):
|
||||
app_model = trial_app
|
||||
@ -190,14 +239,14 @@ class TrialChatApi(TrialAppResource):
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("inputs", type=dict, required=True, location="json")
|
||||
parser.add_argument("query", type=str, required=True, location="json")
|
||||
parser.add_argument("files", type=list, required=False, location="json")
|
||||
parser.add_argument("conversation_id", type=uuid_value, location="json")
|
||||
parser.add_argument("parent_message_id", type=uuid_value, required=False, location="json")
|
||||
parser.add_argument("retriever_from", type=str, required=False, default="explore_app", location="json")
|
||||
args = parser.parse_args()
|
||||
request_data = ChatRequest.model_validate(console_ns.payload)
|
||||
args = request_data.model_dump()
|
||||
|
||||
# Validate UUID values if provided
|
||||
if args.get("conversation_id"):
|
||||
args["conversation_id"] = uuid_value(args["conversation_id"])
|
||||
if args.get("parent_message_id"):
|
||||
args["parent_message_id"] = uuid_value(args["parent_message_id"])
|
||||
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
@ -320,20 +369,16 @@ class TrialChatAudioApi(TrialAppResource):
|
||||
|
||||
|
||||
class TrialChatTextApi(TrialAppResource):
|
||||
@console_ns.expect(console_ns.models[TextToSpeechRequest.__name__])
|
||||
@trial_feature_enable
|
||||
def post(self, trial_app):
|
||||
app_model = trial_app
|
||||
try:
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("message_id", type=str, required=False, location="json")
|
||||
parser.add_argument("voice", type=str, location="json")
|
||||
parser.add_argument("text", type=str, location="json")
|
||||
parser.add_argument("streaming", type=bool, location="json")
|
||||
args = parser.parse_args()
|
||||
request_data = TextToSpeechRequest.model_validate(console_ns.payload)
|
||||
|
||||
message_id = args.get("message_id", None)
|
||||
text = args.get("text", None)
|
||||
voice = args.get("voice", None)
|
||||
message_id = request_data.message_id
|
||||
text = request_data.text
|
||||
voice = request_data.voice
|
||||
if not isinstance(current_user, Account):
|
||||
raise ValueError("current_user must be an Account instance")
|
||||
|
||||
@ -371,19 +416,15 @@ class TrialChatTextApi(TrialAppResource):
|
||||
|
||||
|
||||
class TrialCompletionApi(TrialAppResource):
|
||||
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
|
||||
@trial_feature_enable
|
||||
def post(self, trial_app):
|
||||
app_model = trial_app
|
||||
if app_model.mode != "completion":
|
||||
raise NotCompletionAppError()
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("inputs", type=dict, required=True, location="json")
|
||||
parser.add_argument("query", type=str, location="json", default="")
|
||||
parser.add_argument("files", type=list, required=False, location="json")
|
||||
parser.add_argument("response_mode", type=str, choices=["blocking", "streaming"], location="json")
|
||||
parser.add_argument("retriever_from", type=str, required=False, default="explore_app", location="json")
|
||||
args = parser.parse_args()
|
||||
request_data = CompletionRequest.model_validate(console_ns.payload)
|
||||
args = request_data.model_dump()
|
||||
|
||||
streaming = args["response_mode"] == "streaming"
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
@ -1,14 +1,27 @@
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Namespace, Resource, fields, marshal_with
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||
from controllers.fastopenapi import console_router
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from services.tag_service import TagService
|
||||
|
||||
dataset_tag_fields = {
|
||||
"id": fields.String,
|
||||
"name": fields.String,
|
||||
"type": fields.String,
|
||||
"binding_count": fields.String,
|
||||
}
|
||||
|
||||
|
||||
def build_dataset_tag_fields(api_or_ns: Namespace):
|
||||
return api_or_ns.model("DataSetTag", dataset_tag_fields)
|
||||
|
||||
|
||||
class TagBasePayload(BaseModel):
|
||||
name: str = Field(description="Tag name", min_length=1, max_length=50)
|
||||
@ -32,129 +45,115 @@ class TagListQueryParam(BaseModel):
|
||||
keyword: str | None = Field(None, description="Search keyword")
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
id: str = Field(description="Tag ID")
|
||||
name: str = Field(description="Tag name")
|
||||
type: str = Field(description="Tag type")
|
||||
binding_count: int = Field(description="Number of bindings")
|
||||
|
||||
|
||||
class TagBindingResult(BaseModel):
|
||||
result: Literal["success"] = Field(description="Operation result", examples=["success"])
|
||||
|
||||
|
||||
@console_router.get(
|
||||
"/tags",
|
||||
response_model=list[TagResponse],
|
||||
tags=["console"],
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
TagBasePayload,
|
||||
TagBindingPayload,
|
||||
TagBindingRemovePayload,
|
||||
TagListQueryParam,
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def list_tags(query: TagListQueryParam) -> list[TagResponse]:
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
tags = TagService.get_tags(query.type, current_tenant_id, query.keyword)
|
||||
|
||||
return [
|
||||
TagResponse(
|
||||
id=tag.id,
|
||||
name=tag.name,
|
||||
type=tag.type,
|
||||
binding_count=int(tag.binding_count),
|
||||
)
|
||||
for tag in tags
|
||||
]
|
||||
|
||||
|
||||
@console_router.post(
|
||||
"/tags",
|
||||
response_model=TagResponse,
|
||||
tags=["console"],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def create_tag(payload: TagBasePayload) -> TagResponse:
|
||||
current_user, _ = current_account_with_tenant()
|
||||
# The role of the current user in the tag table must be admin, owner, or editor
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
@console_ns.route("/tags")
|
||||
class TagListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.doc(
|
||||
params={"type": 'Tag type filter. Can be "knowledge" or "app".', "keyword": "Search keyword for tag name."}
|
||||
)
|
||||
@marshal_with(dataset_tag_fields)
|
||||
def get(self):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
raw_args = request.args.to_dict()
|
||||
param = TagListQueryParam.model_validate(raw_args)
|
||||
tags = TagService.get_tags(param.type, current_tenant_id, param.keyword)
|
||||
|
||||
tag = TagService.save_tags(payload.model_dump())
|
||||
return tags, 200
|
||||
|
||||
return TagResponse(id=tag.id, name=tag.name, type=tag.type, binding_count=0)
|
||||
@console_ns.expect(console_ns.models[TagBasePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
payload = TagBasePayload.model_validate(console_ns.payload or {})
|
||||
tag = TagService.save_tags(payload.model_dump())
|
||||
|
||||
response = {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}
|
||||
|
||||
return response, 200
|
||||
|
||||
|
||||
@console_router.patch(
|
||||
"/tags/<uuid:tag_id>",
|
||||
response_model=TagResponse,
|
||||
tags=["console"],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def update_tag(tag_id: UUID, payload: TagBasePayload) -> TagResponse:
|
||||
current_user, _ = current_account_with_tenant()
|
||||
tag_id_str = str(tag_id)
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
@console_ns.route("/tags/<uuid:tag_id>")
|
||||
class TagUpdateDeleteApi(Resource):
|
||||
@console_ns.expect(console_ns.models[TagBasePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def patch(self, tag_id):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
tag_id = str(tag_id)
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
tag = TagService.update_tags(payload.model_dump(), tag_id_str)
|
||||
payload = TagBasePayload.model_validate(console_ns.payload or {})
|
||||
tag = TagService.update_tags(payload.model_dump(), tag_id)
|
||||
|
||||
binding_count = TagService.get_tag_binding_count(tag_id_str)
|
||||
binding_count = TagService.get_tag_binding_count(tag_id)
|
||||
|
||||
return TagResponse(id=tag.id, name=tag.name, type=tag.type, binding_count=binding_count)
|
||||
response = {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count}
|
||||
|
||||
return response, 200
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def delete(self, tag_id):
|
||||
tag_id = str(tag_id)
|
||||
|
||||
TagService.delete_tag(tag_id)
|
||||
|
||||
return 204
|
||||
|
||||
|
||||
@console_router.delete(
|
||||
"/tags/<uuid:tag_id>",
|
||||
tags=["console"],
|
||||
status_code=204,
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def delete_tag(tag_id: UUID) -> None:
|
||||
tag_id_str = str(tag_id)
|
||||
@console_ns.route("/tag-bindings/create")
|
||||
class TagBindingCreateApi(Resource):
|
||||
@console_ns.expect(console_ns.models[TagBindingPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
TagService.delete_tag(tag_id_str)
|
||||
payload = TagBindingPayload.model_validate(console_ns.payload or {})
|
||||
TagService.save_tag_binding(payload.model_dump())
|
||||
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
@console_router.post(
|
||||
"/tag-bindings/create",
|
||||
response_model=TagBindingResult,
|
||||
tags=["console"],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def create_tag_binding(payload: TagBindingPayload) -> TagBindingResult:
|
||||
current_user, _ = current_account_with_tenant()
|
||||
# The role of the current user in the tag table must be admin, owner, editor, or dataset_operator
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
@console_ns.route("/tag-bindings/remove")
|
||||
class TagBindingDeleteApi(Resource):
|
||||
@console_ns.expect(console_ns.models[TagBindingRemovePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
TagService.save_tag_binding(payload.model_dump())
|
||||
payload = TagBindingRemovePayload.model_validate(console_ns.payload or {})
|
||||
TagService.delete_tag_binding(payload.model_dump())
|
||||
|
||||
return TagBindingResult(result="success")
|
||||
|
||||
|
||||
@console_router.post(
|
||||
"/tag-bindings/remove",
|
||||
response_model=TagBindingResult,
|
||||
tags=["console"],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def delete_tag_binding(payload: TagBindingRemovePayload) -> TagBindingResult:
|
||||
current_user, _ = current_account_with_tenant()
|
||||
# The role of the current user in the tag table must be admin, owner, editor, or dataset_operator
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
TagService.delete_tag_binding(payload.model_dump())
|
||||
|
||||
return TagBindingResult(result="success")
|
||||
return {"result": "success"}, 200
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.11.4"
|
||||
version = "1.12.0"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@ -24,7 +24,7 @@ class TagService:
|
||||
escaped_keyword = escape_like_pattern(keyword)
|
||||
query = query.where(sa.and_(Tag.name.ilike(f"%{escaped_keyword}%", escape="\\")))
|
||||
query = query.group_by(Tag.id, Tag.type, Tag.name, Tag.created_at)
|
||||
results = query.order_by(Tag.created_at.desc()).all()
|
||||
results: list = query.order_by(Tag.created_at.desc()).all()
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -1,222 +0,0 @@
|
||||
import builtins
|
||||
import contextlib
|
||||
import importlib
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.views import MethodView
|
||||
|
||||
from extensions import ext_fastopenapi
|
||||
from extensions.ext_database import db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.config["SECRET_KEY"] = "test-secret"
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fix_method_view_issue(monkeypatch):
|
||||
if not hasattr(builtins, "MethodView"):
|
||||
monkeypatch.setattr(builtins, "MethodView", MethodView, raising=False)
|
||||
|
||||
|
||||
def _create_isolated_router():
|
||||
import controllers.fastopenapi
|
||||
|
||||
router_class = type(controllers.fastopenapi.console_router)
|
||||
return router_class()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _patch_auth_and_router(temp_router):
|
||||
def noop(func):
|
||||
return func
|
||||
|
||||
default_user = MagicMock(has_edit_permission=True, is_dataset_editor=False)
|
||||
|
||||
with (
|
||||
patch("controllers.fastopenapi.console_router", temp_router),
|
||||
patch("extensions.ext_fastopenapi.console_router", temp_router),
|
||||
patch("controllers.console.wraps.setup_required", side_effect=noop),
|
||||
patch("libs.login.login_required", side_effect=noop),
|
||||
patch("controllers.console.wraps.account_initialization_required", side_effect=noop),
|
||||
patch("controllers.console.wraps.edit_permission_required", side_effect=noop),
|
||||
patch("libs.login.current_account_with_tenant", return_value=(default_user, "tenant-id")),
|
||||
patch("configs.dify_config.EDITION", "CLOUD"),
|
||||
):
|
||||
import extensions.ext_fastopenapi
|
||||
|
||||
importlib.reload(extensions.ext_fastopenapi)
|
||||
|
||||
yield
|
||||
|
||||
|
||||
def _force_reload_module(target_module: str, alias_module: str):
|
||||
if target_module in sys.modules:
|
||||
del sys.modules[target_module]
|
||||
if alias_module in sys.modules:
|
||||
del sys.modules[alias_module]
|
||||
|
||||
module = importlib.import_module(target_module)
|
||||
sys.modules[alias_module] = sys.modules[target_module]
|
||||
|
||||
return module
|
||||
|
||||
|
||||
def _dedupe_routes(router):
|
||||
seen = set()
|
||||
unique_routes = []
|
||||
for path, method, endpoint in reversed(router.get_routes()):
|
||||
key = (path, method, endpoint.__name__)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique_routes.append((path, method, endpoint))
|
||||
router._routes = list(reversed(unique_routes))
|
||||
|
||||
|
||||
def _cleanup_modules(target_module: str, alias_module: str):
|
||||
if target_module in sys.modules:
|
||||
del sys.modules[target_module]
|
||||
if alias_module in sys.modules:
|
||||
del sys.modules[alias_module]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tags_module_env():
|
||||
target_module = "controllers.console.tag.tags"
|
||||
alias_module = "api.controllers.console.tag.tags"
|
||||
temp_router = _create_isolated_router()
|
||||
|
||||
try:
|
||||
with _patch_auth_and_router(temp_router):
|
||||
tags_module = _force_reload_module(target_module, alias_module)
|
||||
_dedupe_routes(temp_router)
|
||||
yield tags_module
|
||||
finally:
|
||||
_cleanup_modules(target_module, alias_module)
|
||||
|
||||
|
||||
def test_list_tags_success(app: Flask, mock_tags_module_env):
|
||||
# Arrange
|
||||
tag = SimpleNamespace(id="tag-1", name="Alpha", type="app", binding_count=2)
|
||||
with patch("controllers.console.tag.tags.TagService.get_tags", return_value=[tag]):
|
||||
ext_fastopenapi.init_app(app)
|
||||
client = app.test_client()
|
||||
|
||||
# Act
|
||||
response = client.get("/console/api/tags?type=app&keyword=Alpha")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == [
|
||||
{"id": "tag-1", "name": "Alpha", "type": "app", "binding_count": 2},
|
||||
]
|
||||
|
||||
|
||||
def test_create_tag_success(app: Flask, mock_tags_module_env):
|
||||
# Arrange
|
||||
tag = SimpleNamespace(id="tag-2", name="Beta", type="app")
|
||||
with patch("controllers.console.tag.tags.TagService.save_tags", return_value=tag) as mock_save:
|
||||
ext_fastopenapi.init_app(app)
|
||||
client = app.test_client()
|
||||
|
||||
# Act
|
||||
response = client.post("/console/api/tags", json={"name": "Beta", "type": "app"})
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {
|
||||
"id": "tag-2",
|
||||
"name": "Beta",
|
||||
"type": "app",
|
||||
"binding_count": 0,
|
||||
}
|
||||
mock_save.assert_called_once_with({"name": "Beta", "type": "app"})
|
||||
|
||||
|
||||
def test_update_tag_success(app: Flask, mock_tags_module_env):
|
||||
# Arrange
|
||||
tag = SimpleNamespace(id="tag-3", name="Gamma", type="app")
|
||||
with (
|
||||
patch("controllers.console.tag.tags.TagService.update_tags", return_value=tag) as mock_update,
|
||||
patch("controllers.console.tag.tags.TagService.get_tag_binding_count", return_value=4),
|
||||
):
|
||||
ext_fastopenapi.init_app(app)
|
||||
client = app.test_client()
|
||||
|
||||
# Act
|
||||
response = client.patch(
|
||||
"/console/api/tags/11111111-1111-1111-1111-111111111111",
|
||||
json={"name": "Gamma", "type": "app"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {
|
||||
"id": "tag-3",
|
||||
"name": "Gamma",
|
||||
"type": "app",
|
||||
"binding_count": 4,
|
||||
}
|
||||
mock_update.assert_called_once_with(
|
||||
{"name": "Gamma", "type": "app"},
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
)
|
||||
|
||||
|
||||
def test_delete_tag_success(app: Flask, mock_tags_module_env):
|
||||
# Arrange
|
||||
with patch("controllers.console.tag.tags.TagService.delete_tag") as mock_delete:
|
||||
ext_fastopenapi.init_app(app)
|
||||
client = app.test_client()
|
||||
|
||||
# Act
|
||||
response = client.delete("/console/api/tags/11111111-1111-1111-1111-111111111111")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 204
|
||||
mock_delete.assert_called_once_with("11111111-1111-1111-1111-111111111111")
|
||||
|
||||
|
||||
def test_create_tag_binding_success(app: Flask, mock_tags_module_env):
|
||||
# Arrange
|
||||
payload = {"tag_ids": ["tag-1", "tag-2"], "target_id": "target-1", "type": "app"}
|
||||
with patch("controllers.console.tag.tags.TagService.save_tag_binding") as mock_bind:
|
||||
ext_fastopenapi.init_app(app)
|
||||
client = app.test_client()
|
||||
|
||||
# Act
|
||||
response = client.post("/console/api/tag-bindings/create", json=payload)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"result": "success"}
|
||||
mock_bind.assert_called_once_with(payload)
|
||||
|
||||
|
||||
def test_delete_tag_binding_success(app: Flask, mock_tags_module_env):
|
||||
# Arrange
|
||||
payload = {"tag_id": "tag-1", "target_id": "target-1", "type": "app"}
|
||||
with patch("controllers.console.tag.tags.TagService.delete_tag_binding") as mock_unbind:
|
||||
ext_fastopenapi.init_app(app)
|
||||
client = app.test_client()
|
||||
|
||||
# Act
|
||||
response = client.post("/console/api/tag-bindings/remove", json=payload)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"result": "success"}
|
||||
mock_unbind.assert_called_once_with(payload)
|
||||
2
api/uv.lock
generated
2
api/uv.lock
generated
@ -1368,7 +1368,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.11.4"
|
||||
version = "1.12.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -x
|
||||
set -euxo pipefail
|
||||
|
||||
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
|
||||
cd "$SCRIPT_DIR/../.."
|
||||
|
||||
@ -21,7 +21,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.11.4
|
||||
image: langgenius/dify-api:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -63,7 +63,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.11.4
|
||||
image: langgenius/dify-api:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -102,7 +102,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.11.4
|
||||
image: langgenius/dify-api:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -132,7 +132,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.11.4
|
||||
image: langgenius/dify-web:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
@ -662,13 +662,14 @@ services:
|
||||
- "${IRIS_SUPER_SERVER_PORT:-1972}:1972"
|
||||
- "${IRIS_WEB_SERVER_PORT:-52773}:52773"
|
||||
volumes:
|
||||
- ./volumes/iris:/opt/iris
|
||||
- ./volumes/iris:/durable
|
||||
- ./iris/iris-init.script:/iris-init.script
|
||||
- ./iris/docker-entrypoint.sh:/custom-entrypoint.sh
|
||||
entrypoint: ["/custom-entrypoint.sh"]
|
||||
tty: true
|
||||
environment:
|
||||
TZ: ${IRIS_TIMEZONE:-UTC}
|
||||
ISC_DATA_DIRECTORY: /durable/iris
|
||||
|
||||
# Oracle vector database
|
||||
oracle:
|
||||
|
||||
@ -707,7 +707,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.11.4
|
||||
image: langgenius/dify-api:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -749,7 +749,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.11.4
|
||||
image: langgenius/dify-api:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -788,7 +788,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.11.4
|
||||
image: langgenius/dify-api:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -818,7 +818,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.11.4
|
||||
image: langgenius/dify-web:1.12.0
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
@ -1348,13 +1348,14 @@ services:
|
||||
- "${IRIS_SUPER_SERVER_PORT:-1972}:1972"
|
||||
- "${IRIS_WEB_SERVER_PORT:-52773}:52773"
|
||||
volumes:
|
||||
- ./volumes/iris:/opt/iris
|
||||
- ./volumes/iris:/durable
|
||||
- ./iris/iris-init.script:/iris-init.script
|
||||
- ./iris/docker-entrypoint.sh:/custom-entrypoint.sh
|
||||
entrypoint: ["/custom-entrypoint.sh"]
|
||||
tty: true
|
||||
environment:
|
||||
TZ: ${IRIS_TIMEZONE:-UTC}
|
||||
ISC_DATA_DIRECTORY: /durable/iris
|
||||
|
||||
# Oracle vector database
|
||||
oracle:
|
||||
|
||||
@ -1,15 +1,33 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# IRIS configuration flag file
|
||||
IRIS_CONFIG_DONE="/opt/iris/.iris-configured"
|
||||
# IRIS configuration flag file (stored in durable directory to persist with data)
|
||||
IRIS_CONFIG_DONE="/durable/.iris-configured"
|
||||
|
||||
# Function to wait for IRIS to be ready
|
||||
wait_for_iris() {
|
||||
echo "Waiting for IRIS to be ready..."
|
||||
local max_attempts=30
|
||||
local attempt=1
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if iris qlist IRIS 2>/dev/null | grep -q "running"; then
|
||||
echo "IRIS is ready."
|
||||
return 0
|
||||
fi
|
||||
echo "Attempt $attempt/$max_attempts: IRIS not ready yet, waiting..."
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo "ERROR: IRIS failed to start within expected time." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to configure IRIS
|
||||
configure_iris() {
|
||||
echo "Configuring IRIS for first-time setup..."
|
||||
|
||||
# Wait for IRIS to be fully started
|
||||
sleep 5
|
||||
wait_for_iris
|
||||
|
||||
# Execute the initialization script
|
||||
iris session IRIS < /iris-init.script
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
@ -28,7 +28,7 @@ export const AppInitializer = ({
|
||||
const [init, setInit] = useState(false)
|
||||
const [oauthNewUser, setOauthNewUser] = useQueryState(
|
||||
'oauth_new_user',
|
||||
parseAsString.withOptions({ history: 'replace' }),
|
||||
parseAsBoolean.withOptions({ history: 'replace' }),
|
||||
)
|
||||
|
||||
const isSetupFinished = useCallback(async () => {
|
||||
@ -46,7 +46,7 @@ export const AppInitializer = ({
|
||||
(async () => {
|
||||
const action = searchParams.get('action')
|
||||
|
||||
if (oauthNewUser === 'true') {
|
||||
if (oauthNewUser) {
|
||||
let utmInfo = null
|
||||
const utmInfoStr = Cookies.get('utm_info')
|
||||
if (utmInfoStr) {
|
||||
|
||||
@ -62,19 +62,19 @@ const AppCard = ({
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
{canCreate && (
|
||||
{(canCreate || isTrialApp) && (
|
||||
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', isTrialApp && 'grid-cols-2')}>
|
||||
<Button variant="primary" onClick={() => onCreate()}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
<span className="text-xs">{t('newApp.useTemplate', { ns: 'app' })}</span>
|
||||
</Button>
|
||||
{isTrialApp && (
|
||||
<Button onClick={showTryAPPPanel(app.app_id)}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', canCreate && 'grid-cols-2')}>
|
||||
{canCreate && (
|
||||
<Button variant="primary" onClick={() => onCreate()}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
<span className="text-xs">{t('newApp.useTemplate', { ns: 'app' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={showTryAPPPanel(app.app_id)}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -154,7 +154,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
|
||||
</div>
|
||||
))}
|
||||
{
|
||||
showSummaryIndexSetting && (
|
||||
showSummaryIndexSetting && IS_CE_EDITION && (
|
||||
<div className="mt-3">
|
||||
<SummaryIndexSetting
|
||||
entry="create-document"
|
||||
|
||||
@ -12,6 +12,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import { ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import RadioCard from '@/app/components/base/radio-card'
|
||||
import SummaryIndexSetting from '@/app/components/datasets/settings/summary-index-setting'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import FileList from '../../assets/file-list-3-fill.svg'
|
||||
import Note from '../../assets/note-mod.svg'
|
||||
@ -191,7 +192,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
</div>
|
||||
))}
|
||||
{
|
||||
showSummaryIndexSetting && (
|
||||
showSummaryIndexSetting && IS_CE_EDITION && (
|
||||
<div className="mt-3">
|
||||
<SummaryIndexSetting
|
||||
entry="create-document"
|
||||
|
||||
@ -26,6 +26,7 @@ import CustomPopover from '@/app/components/base/popover'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { DataSourceType, DocumentActionType } from '@/models/datasets'
|
||||
import {
|
||||
useDocumentArchive,
|
||||
@ -263,10 +264,14 @@ const Operations = ({
|
||||
<span className={s.actionName}>{t('list.action.sync', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={s.actionItem} onClick={() => onOperate('summary')}>
|
||||
<SearchLinesSparkle className="h-4 w-4 text-text-tertiary" />
|
||||
<span className={s.actionName}>{t('list.action.summary', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
{
|
||||
IS_CE_EDITION && (
|
||||
<div className={s.actionItem} onClick={() => onOperate('summary')}>
|
||||
<SearchLinesSparkle className="h-4 w-4 text-text-tertiary" />
|
||||
<span className={s.actionName}>{t('list.action.summary', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Divider className="my-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -7,6 +7,7 @@ import Button from '@/app/components/base/button'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { SearchLinesSparkle } from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const i18nPrefix = 'batchAction'
|
||||
@ -87,7 +88,7 @@ const BatchAction: FC<IBatchActionProps> = ({
|
||||
<span className="px-0.5">{t('metadata.metadata', { ns: 'dataset' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
{onBatchSummary && (
|
||||
{onBatchSummary && IS_CE_EDITION && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-x-0.5 px-3"
|
||||
|
||||
@ -3,8 +3,6 @@ import type { FC, ReactNode } from 'react'
|
||||
import type { SliceProps } from './type'
|
||||
import { autoUpdate, flip, FloatingFocusManager, offset, shift, useDismiss, useFloating, useHover, useInteractions, useRole } from '@floating-ui/react'
|
||||
import { RiDeleteBinLine } from '@remixicon/react'
|
||||
// @ts-expect-error no types available
|
||||
import lineClamp from 'line-clamp'
|
||||
import { useState } from 'react'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@ -58,12 +56,8 @@ export const EditSlice: FC<EditSliceProps> = (props) => {
|
||||
<>
|
||||
<SliceContainer
|
||||
{...rest}
|
||||
className={cn('mr-0 block', className)}
|
||||
ref={(ref) => {
|
||||
refs.setReference(ref)
|
||||
if (ref)
|
||||
lineClamp(ref, 4)
|
||||
}}
|
||||
className={cn('mr-0 line-clamp-4 block', className)}
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
<SliceLabel
|
||||
|
||||
@ -21,6 +21,7 @@ import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-me
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
@ -359,7 +360,7 @@ const Form = () => {
|
||||
{
|
||||
indexMethod === IndexingType.QUALIFIED
|
||||
&& [ChunkingMode.text, ChunkingMode.parentChild].includes(currentDataset?.doc_form as ChunkingMode)
|
||||
&& (
|
||||
&& IS_CE_EDITION && (
|
||||
<>
|
||||
<Divider
|
||||
type="horizontal"
|
||||
|
||||
@ -74,11 +74,15 @@ const AppCard = ({
|
||||
</div>
|
||||
{isExplore && (canCreate || isTrialApp) && (
|
||||
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
|
||||
<div className={cn('grid h-8 w-full grid-cols-2 space-x-2')}>
|
||||
<Button variant="primary" className="h-7" onClick={() => onCreate()}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
<span className="text-xs">{t('appCard.addToWorkspace', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 space-x-2', canCreate && 'grid-cols-2')}>
|
||||
{
|
||||
canCreate && (
|
||||
<Button variant="primary" className="h-7" onClick={() => onCreate()}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
<span className="text-xs">{t('appCard.addToWorkspace', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<Button className="h-7" onClick={showTryAPPPanel(app.app_id)}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
|
||||
@ -16,6 +16,14 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal() as object
|
||||
return {
|
||||
...actual,
|
||||
IS_CLOUD_EDITION: true,
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseGetTryAppInfo = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-try-app', () => ({
|
||||
|
||||
@ -14,6 +14,14 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal() as object
|
||||
return {
|
||||
...actual,
|
||||
IS_CLOUD_EDITION: true,
|
||||
}
|
||||
})
|
||||
|
||||
describe('Tab', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
|
||||
@ -308,7 +308,7 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText:
|
||||
}, [plugins, collectionPlugins, exclude])
|
||||
|
||||
return {
|
||||
plugins: searchText ? plugins : allPlugins,
|
||||
plugins: allPlugins,
|
||||
isLoading: isCollectionLoading || isPluginsLoading,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,27 +1,19 @@
|
||||
'use client'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { App } from '@/types/app'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import AppInputsForm from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form'
|
||||
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { useAppDetail } from '@/service/use-apps'
|
||||
import { useFileUploadConfig } from '@/service/use-common'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { AppModeEnum, Resolution } from '@/types/app'
|
||||
|
||||
import { useAppInputsFormSchema } from '@/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
value?: {
|
||||
app_id: string
|
||||
inputs: Record<string, any>
|
||||
inputs: Record<string, unknown>
|
||||
}
|
||||
appDetail: App
|
||||
onFormChange: (value: Record<string, any>) => void
|
||||
onFormChange: (value: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
const AppInputsPanel = ({
|
||||
@ -30,155 +22,33 @@ const AppInputsPanel = ({
|
||||
onFormChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const inputsRef = useRef<any>(value?.inputs || {})
|
||||
const isBasicApp = appDetail.mode !== AppModeEnum.ADVANCED_CHAT && appDetail.mode !== AppModeEnum.WORKFLOW
|
||||
const { data: fileUploadConfig } = useFileUploadConfig()
|
||||
const { data: currentApp, isFetching: isAppLoading } = useAppDetail(appDetail.id)
|
||||
const { data: currentWorkflow, isFetching: isWorkflowLoading } = useAppWorkflow(isBasicApp ? '' : appDetail.id)
|
||||
const isLoading = isAppLoading || isWorkflowLoading
|
||||
const inputsRef = useRef<Record<string, unknown>>(value?.inputs || {})
|
||||
|
||||
const basicAppFileConfig = useMemo(() => {
|
||||
let fileConfig: FileUpload
|
||||
if (isBasicApp)
|
||||
fileConfig = currentApp?.model_config?.file_upload as FileUpload
|
||||
else
|
||||
fileConfig = currentWorkflow?.features?.file_upload as FileUpload
|
||||
return {
|
||||
image: {
|
||||
detail: fileConfig?.image?.detail || Resolution.high,
|
||||
enabled: !!fileConfig?.image?.enabled,
|
||||
number_limits: fileConfig?.image?.number_limits || 3,
|
||||
transfer_methods: fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
},
|
||||
enabled: !!(fileConfig?.enabled || fileConfig?.image?.enabled),
|
||||
allowed_file_types: fileConfig?.allowed_file_types || [SupportUploadFileTypes.image],
|
||||
allowed_file_extensions: fileConfig?.allowed_file_extensions || [...FILE_EXTS[SupportUploadFileTypes.image]].map(ext => `.${ext}`),
|
||||
allowed_file_upload_methods: fileConfig?.allowed_file_upload_methods || fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
number_limits: fileConfig?.number_limits || fileConfig?.image?.number_limits || 3,
|
||||
}
|
||||
}, [currentApp?.model_config?.file_upload, currentWorkflow?.features?.file_upload, isBasicApp])
|
||||
const { inputFormSchema, isLoading } = useAppInputsFormSchema({ appDetail })
|
||||
|
||||
const inputFormSchema = useMemo(() => {
|
||||
if (!currentApp)
|
||||
return []
|
||||
let inputFormSchema = []
|
||||
if (isBasicApp) {
|
||||
inputFormSchema = currentApp.model_config?.user_input_form?.filter((item: any) => !item.external_data_tool).map((item: any) => {
|
||||
if (item.paragraph) {
|
||||
return {
|
||||
...item.paragraph,
|
||||
type: 'paragraph',
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
if (item.number) {
|
||||
return {
|
||||
...item.number,
|
||||
type: 'number',
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
if (item.checkbox) {
|
||||
return {
|
||||
...item.checkbox,
|
||||
type: 'checkbox',
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
if (item.select) {
|
||||
return {
|
||||
...item.select,
|
||||
type: 'select',
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (item['file-list']) {
|
||||
return {
|
||||
...item['file-list'],
|
||||
type: 'file-list',
|
||||
required: false,
|
||||
fileUploadConfig,
|
||||
}
|
||||
}
|
||||
|
||||
if (item.file) {
|
||||
return {
|
||||
...item.file,
|
||||
type: 'file',
|
||||
required: false,
|
||||
fileUploadConfig,
|
||||
}
|
||||
}
|
||||
|
||||
if (item.json_object) {
|
||||
return {
|
||||
...item.json_object,
|
||||
type: 'json_object',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item['text-input'],
|
||||
type: 'text-input',
|
||||
required: false,
|
||||
}
|
||||
}) || []
|
||||
}
|
||||
else {
|
||||
const startNode = currentWorkflow?.graph?.nodes.find(node => node.data.type === BlockEnum.Start) as any
|
||||
inputFormSchema = startNode?.data.variables.map((variable: any) => {
|
||||
if (variable.type === InputVarType.multiFiles) {
|
||||
return {
|
||||
...variable,
|
||||
required: false,
|
||||
fileUploadConfig,
|
||||
}
|
||||
}
|
||||
|
||||
if (variable.type === InputVarType.singleFile) {
|
||||
return {
|
||||
...variable,
|
||||
required: false,
|
||||
fileUploadConfig,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...variable,
|
||||
required: false,
|
||||
}
|
||||
}) || []
|
||||
}
|
||||
if ((currentApp.mode === AppModeEnum.COMPLETION || currentApp.mode === AppModeEnum.WORKFLOW) && basicAppFileConfig.enabled) {
|
||||
inputFormSchema.push({
|
||||
label: 'Image Upload',
|
||||
variable: '#image#',
|
||||
type: InputVarType.singleFile,
|
||||
required: false,
|
||||
...basicAppFileConfig,
|
||||
fileUploadConfig,
|
||||
})
|
||||
}
|
||||
return inputFormSchema || []
|
||||
}, [basicAppFileConfig, currentApp, currentWorkflow, fileUploadConfig, isBasicApp])
|
||||
|
||||
const handleFormChange = (value: Record<string, any>) => {
|
||||
inputsRef.current = value
|
||||
onFormChange(value)
|
||||
const handleFormChange = (newValue: Record<string, unknown>) => {
|
||||
inputsRef.current = newValue
|
||||
onFormChange(newValue)
|
||||
}
|
||||
|
||||
const hasInputs = inputFormSchema.length > 0
|
||||
|
||||
return (
|
||||
<div className={cn('flex max-h-[240px] flex-col rounded-b-2xl border-t border-divider-subtle pb-4')}>
|
||||
{isLoading && <div className="pt-3"><Loading type="app" /></div>}
|
||||
{!isLoading && (
|
||||
<div className="system-sm-semibold mb-2 mt-3 flex h-6 shrink-0 items-center px-4 text-text-secondary">{t('appSelector.params', { ns: 'app' })}</div>
|
||||
)}
|
||||
{!isLoading && !inputFormSchema.length && (
|
||||
<div className="flex h-16 flex-col items-center justify-center">
|
||||
<div className="system-sm-regular text-text-tertiary">{t('appSelector.noParams', { ns: 'app' })}</div>
|
||||
<div className="system-sm-semibold mb-2 mt-3 flex h-6 shrink-0 items-center px-4 text-text-secondary">
|
||||
{t('appSelector.params', { ns: 'app' })}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !!inputFormSchema.length && (
|
||||
{!isLoading && !hasInputs && (
|
||||
<div className="flex h-16 flex-col items-center justify-center">
|
||||
<div className="system-sm-regular text-text-tertiary">
|
||||
{t('appSelector.noParams', { ns: 'app' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && hasInputs && (
|
||||
<div className="grow overflow-y-auto">
|
||||
<AppInputsForm
|
||||
inputs={value?.inputs || {}}
|
||||
|
||||
@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import type { App } from '@/types/app'
|
||||
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
|
||||
import { useMemo } from 'react'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { useAppDetail } from '@/service/use-apps'
|
||||
import { useFileUploadConfig } from '@/service/use-common'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { AppModeEnum, Resolution } from '@/types/app'
|
||||
|
||||
const BASIC_INPUT_TYPE_MAP: Record<string, string> = {
|
||||
'paragraph': 'paragraph',
|
||||
'number': 'number',
|
||||
'checkbox': 'checkbox',
|
||||
'select': 'select',
|
||||
'file-list': 'file-list',
|
||||
'file': 'file',
|
||||
'json_object': 'json_object',
|
||||
}
|
||||
|
||||
const FILE_INPUT_TYPES = new Set(['file-list', 'file'])
|
||||
|
||||
const WORKFLOW_FILE_VAR_TYPES = new Set([InputVarType.multiFiles, InputVarType.singleFile])
|
||||
|
||||
type InputSchemaItem = {
|
||||
label?: string
|
||||
variable?: string
|
||||
type: string
|
||||
required: boolean
|
||||
fileUploadConfig?: FileUploadConfigResponse
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function isBasicAppMode(mode: string): boolean {
|
||||
return mode !== AppModeEnum.ADVANCED_CHAT && mode !== AppModeEnum.WORKFLOW
|
||||
}
|
||||
|
||||
function supportsImageUpload(mode: string): boolean {
|
||||
return mode === AppModeEnum.COMPLETION || mode === AppModeEnum.WORKFLOW
|
||||
}
|
||||
|
||||
function buildFileConfig(fileConfig: FileUpload | undefined) {
|
||||
return {
|
||||
image: {
|
||||
detail: fileConfig?.image?.detail || Resolution.high,
|
||||
enabled: !!fileConfig?.image?.enabled,
|
||||
number_limits: fileConfig?.image?.number_limits || 3,
|
||||
transfer_methods: fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
},
|
||||
enabled: !!(fileConfig?.enabled || fileConfig?.image?.enabled),
|
||||
allowed_file_types: fileConfig?.allowed_file_types || [SupportUploadFileTypes.image],
|
||||
allowed_file_extensions: fileConfig?.allowed_file_extensions
|
||||
|| [...FILE_EXTS[SupportUploadFileTypes.image]].map(ext => `.${ext}`),
|
||||
allowed_file_upload_methods: fileConfig?.allowed_file_upload_methods
|
||||
|| fileConfig?.image?.transfer_methods
|
||||
|| ['local_file', 'remote_url'],
|
||||
number_limits: fileConfig?.number_limits || fileConfig?.image?.number_limits || 3,
|
||||
}
|
||||
}
|
||||
|
||||
function mapBasicAppInputItem(
|
||||
item: Record<string, unknown>,
|
||||
fileUploadConfig?: FileUploadConfigResponse,
|
||||
): InputSchemaItem | null {
|
||||
for (const [key, type] of Object.entries(BASIC_INPUT_TYPE_MAP)) {
|
||||
if (!item[key])
|
||||
continue
|
||||
|
||||
const inputData = item[key] as Record<string, unknown>
|
||||
const needsFileConfig = FILE_INPUT_TYPES.has(key)
|
||||
|
||||
return {
|
||||
...inputData,
|
||||
type,
|
||||
required: false,
|
||||
...(needsFileConfig && { fileUploadConfig }),
|
||||
}
|
||||
}
|
||||
|
||||
const textInput = item['text-input'] as Record<string, unknown> | undefined
|
||||
if (!textInput)
|
||||
return null
|
||||
|
||||
return {
|
||||
...textInput,
|
||||
type: 'text-input',
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkflowVariable(
|
||||
variable: Record<string, unknown>,
|
||||
fileUploadConfig?: FileUploadConfigResponse,
|
||||
): InputSchemaItem {
|
||||
const needsFileConfig = WORKFLOW_FILE_VAR_TYPES.has(variable.type as InputVarType)
|
||||
|
||||
return {
|
||||
...variable,
|
||||
type: variable.type as string,
|
||||
required: false,
|
||||
...(needsFileConfig && { fileUploadConfig }),
|
||||
}
|
||||
}
|
||||
|
||||
function createImageUploadSchema(
|
||||
basicFileConfig: ReturnType<typeof buildFileConfig>,
|
||||
fileUploadConfig?: FileUploadConfigResponse,
|
||||
): InputSchemaItem {
|
||||
return {
|
||||
label: 'Image Upload',
|
||||
variable: '#image#',
|
||||
type: InputVarType.singleFile,
|
||||
required: false,
|
||||
...basicFileConfig,
|
||||
fileUploadConfig,
|
||||
}
|
||||
}
|
||||
|
||||
function buildBasicAppSchema(
|
||||
currentApp: App,
|
||||
fileUploadConfig?: FileUploadConfigResponse,
|
||||
): InputSchemaItem[] {
|
||||
const userInputForm = currentApp.model_config?.user_input_form as Array<Record<string, unknown>> | undefined
|
||||
if (!userInputForm)
|
||||
return []
|
||||
|
||||
return userInputForm
|
||||
.filter((item: Record<string, unknown>) => !item.external_data_tool)
|
||||
.map((item: Record<string, unknown>) => mapBasicAppInputItem(item, fileUploadConfig))
|
||||
.filter((item): item is InputSchemaItem => item !== null)
|
||||
}
|
||||
|
||||
function buildWorkflowSchema(
|
||||
workflow: FetchWorkflowDraftResponse,
|
||||
fileUploadConfig?: FileUploadConfigResponse,
|
||||
): InputSchemaItem[] {
|
||||
const startNode = workflow.graph?.nodes.find(
|
||||
node => node.data.type === BlockEnum.Start,
|
||||
) as { data: { variables: Array<Record<string, unknown>> } } | undefined
|
||||
|
||||
if (!startNode?.data.variables)
|
||||
return []
|
||||
|
||||
return startNode.data.variables.map(
|
||||
variable => mapWorkflowVariable(variable, fileUploadConfig),
|
||||
)
|
||||
}
|
||||
|
||||
type UseAppInputsFormSchemaParams = {
|
||||
appDetail: App
|
||||
}
|
||||
|
||||
type UseAppInputsFormSchemaResult = {
|
||||
inputFormSchema: InputSchemaItem[]
|
||||
isLoading: boolean
|
||||
fileUploadConfig?: FileUploadConfigResponse
|
||||
}
|
||||
|
||||
export function useAppInputsFormSchema({
|
||||
appDetail,
|
||||
}: UseAppInputsFormSchemaParams): UseAppInputsFormSchemaResult {
|
||||
const isBasicApp = isBasicAppMode(appDetail.mode)
|
||||
|
||||
const { data: fileUploadConfig } = useFileUploadConfig()
|
||||
const { data: currentApp, isFetching: isAppLoading } = useAppDetail(appDetail.id)
|
||||
const { data: currentWorkflow, isFetching: isWorkflowLoading } = useAppWorkflow(
|
||||
isBasicApp ? '' : appDetail.id,
|
||||
)
|
||||
|
||||
const isLoading = isAppLoading || isWorkflowLoading
|
||||
|
||||
const inputFormSchema = useMemo(() => {
|
||||
if (!currentApp)
|
||||
return []
|
||||
|
||||
if (!isBasicApp && !currentWorkflow)
|
||||
return []
|
||||
|
||||
// Build base schema based on app type
|
||||
// Note: currentWorkflow is guaranteed to be defined here due to the early return above
|
||||
const baseSchema = isBasicApp
|
||||
? buildBasicAppSchema(currentApp, fileUploadConfig)
|
||||
: buildWorkflowSchema(currentWorkflow!, fileUploadConfig)
|
||||
|
||||
if (!supportsImageUpload(currentApp.mode))
|
||||
return baseSchema
|
||||
|
||||
const rawFileConfig = isBasicApp
|
||||
? currentApp.model_config?.file_upload as FileUpload
|
||||
: currentWorkflow?.features?.file_upload as FileUpload
|
||||
|
||||
const basicFileConfig = buildFileConfig(rawFileConfig)
|
||||
|
||||
if (!basicFileConfig.enabled)
|
||||
return baseSchema
|
||||
|
||||
return [
|
||||
...baseSchema,
|
||||
createImageUploadSchema(basicFileConfig, fileUploadConfig),
|
||||
]
|
||||
}, [currentApp, currentWorkflow, fileUploadConfig, isBasicApp])
|
||||
|
||||
return {
|
||||
inputFormSchema,
|
||||
isLoading,
|
||||
fileUploadConfig,
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,6 @@ import Toast from '@/app/components/base/toast'
|
||||
import { PluginSource } from '../types'
|
||||
import DetailHeader from './detail-header'
|
||||
|
||||
// Use vi.hoisted for mock functions used in vi.mock factories
|
||||
const {
|
||||
mockSetShowUpdatePluginModal,
|
||||
mockRefreshModelProviders,
|
||||
|
||||
@ -1,416 +1,2 @@
|
||||
import type { PluginDetail } from '../types'
|
||||
import {
|
||||
RiArrowLeftRightLine,
|
||||
RiBugLine,
|
||||
RiCloseLine,
|
||||
RiHardDrive3Line,
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { Github } from '@/app/components/base/icons/src/public/common'
|
||||
import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth'
|
||||
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
|
||||
import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info'
|
||||
import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place'
|
||||
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useGetLanguage, useLocale } from '@/context/i18n'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { uninstallPlugin } from '@/service/plugins'
|
||||
import { useAllToolProviders, useInvalidateAllToolProviders } from '@/service/use-tools'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { AutoUpdateLine } from '../../base/icons/src/vender/system'
|
||||
import Verified from '../base/badges/verified'
|
||||
import DeprecationNotice from '../base/deprecation-notice'
|
||||
import Icon from '../card/base/card-icon'
|
||||
import Description from '../card/base/description'
|
||||
import OrgInfo from '../card/base/org-info'
|
||||
import Title from '../card/base/title'
|
||||
import { useGitHubReleases } from '../install-plugin/hooks'
|
||||
import useReferenceSetting from '../plugin-page/use-reference-setting'
|
||||
import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting/types'
|
||||
import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils'
|
||||
import { PluginCategoryEnum, PluginSource } from '../types'
|
||||
|
||||
const i18nPrefix = 'action'
|
||||
|
||||
type Props = {
|
||||
detail: PluginDetail
|
||||
isReadmeView?: boolean
|
||||
onHide?: () => void
|
||||
onUpdate?: (isDelete?: boolean) => void
|
||||
}
|
||||
|
||||
const DetailHeader = ({
|
||||
detail,
|
||||
isReadmeView = false,
|
||||
onHide,
|
||||
onUpdate,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile: { timezone } } = useAppContext()
|
||||
|
||||
const { theme } = useTheme()
|
||||
const locale = useGetLanguage()
|
||||
const currentLocale = useLocale()
|
||||
const { checkForUpdates, fetchReleases } = useGitHubReleases()
|
||||
const { setShowUpdatePluginModal } = useModalContext()
|
||||
const { refreshModelProviders } = useProviderContext()
|
||||
const invalidateAllToolProviders = useInvalidateAllToolProviders()
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const {
|
||||
id,
|
||||
source,
|
||||
tenant_id,
|
||||
version,
|
||||
latest_unique_identifier,
|
||||
latest_version,
|
||||
meta,
|
||||
plugin_id,
|
||||
status,
|
||||
deprecated_reason,
|
||||
alternative_plugin_id,
|
||||
} = detail
|
||||
|
||||
const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail
|
||||
const isTool = category === PluginCategoryEnum.tool
|
||||
const providerBriefInfo = tool?.identity
|
||||
const providerKey = `${plugin_id}/${providerBriefInfo?.name}`
|
||||
const { data: collectionList = [] } = useAllToolProviders(isTool)
|
||||
const provider = useMemo(() => {
|
||||
return collectionList.find(collection => collection.name === providerKey)
|
||||
}, [collectionList, providerKey])
|
||||
const isFromGitHub = source === PluginSource.github
|
||||
const isFromMarketplace = source === PluginSource.marketplace
|
||||
|
||||
const [isShow, setIsShow] = useState(false)
|
||||
const [targetVersion, setTargetVersion] = useState({
|
||||
version: latest_version,
|
||||
unique_identifier: latest_unique_identifier,
|
||||
})
|
||||
const hasNewVersion = useMemo(() => {
|
||||
if (isFromMarketplace)
|
||||
return !!latest_version && latest_version !== version
|
||||
|
||||
return false
|
||||
}, [isFromMarketplace, latest_version, version])
|
||||
|
||||
const iconFileName = theme === 'dark' && icon_dark ? icon_dark : icon
|
||||
const iconSrc = iconFileName
|
||||
? (iconFileName.startsWith('http') ? iconFileName : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${iconFileName}`)
|
||||
: ''
|
||||
|
||||
const detailUrl = useMemo(() => {
|
||||
if (isFromGitHub)
|
||||
return `https://github.com/${meta!.repo}`
|
||||
if (isFromMarketplace)
|
||||
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: currentLocale, theme })
|
||||
return ''
|
||||
}, [author, isFromGitHub, isFromMarketplace, meta, name, theme])
|
||||
|
||||
const [isShowUpdateModal, {
|
||||
setTrue: showUpdateModal,
|
||||
setFalse: hideUpdateModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const { referenceSetting } = useReferenceSetting()
|
||||
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
|
||||
const isAutoUpgradeEnabled = useMemo(() => {
|
||||
if (!enable_marketplace)
|
||||
return false
|
||||
if (!autoUpgradeInfo || !isFromMarketplace)
|
||||
return false
|
||||
if (autoUpgradeInfo.strategy_setting === 'disabled')
|
||||
return false
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
|
||||
return true
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
|
||||
return true
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
|
||||
return true
|
||||
return false
|
||||
}, [autoUpgradeInfo, plugin_id, isFromMarketplace])
|
||||
|
||||
const [isDowngrade, setIsDowngrade] = useState(false)
|
||||
const handleUpdate = async (isDowngrade?: boolean) => {
|
||||
if (isFromMarketplace) {
|
||||
setIsDowngrade(!!isDowngrade)
|
||||
showUpdateModal()
|
||||
return
|
||||
}
|
||||
|
||||
const owner = meta!.repo.split('/')[0] || author
|
||||
const repo = meta!.repo.split('/')[1] || name
|
||||
const fetchedReleases = await fetchReleases(owner, repo)
|
||||
if (fetchedReleases.length === 0)
|
||||
return
|
||||
const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta!.version)
|
||||
Toast.notify(toastProps)
|
||||
if (needUpdate) {
|
||||
setShowUpdatePluginModal({
|
||||
onSaveCallback: () => {
|
||||
onUpdate?.()
|
||||
},
|
||||
payload: {
|
||||
type: PluginSource.github,
|
||||
category: detail.declaration.category,
|
||||
github: {
|
||||
originalPackageInfo: {
|
||||
id: detail.plugin_unique_identifier,
|
||||
repo: meta!.repo,
|
||||
version: meta!.version,
|
||||
package: meta!.package,
|
||||
releases: fetchedReleases,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdatedFromMarketplace = () => {
|
||||
onUpdate?.()
|
||||
hideUpdateModal()
|
||||
}
|
||||
|
||||
const [isShowPluginInfo, {
|
||||
setTrue: showPluginInfo,
|
||||
setFalse: hidePluginInfo,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const [isShowDeleteConfirm, {
|
||||
setTrue: showDeleteConfirm,
|
||||
setFalse: hideDeleteConfirm,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const [deleting, {
|
||||
setTrue: showDeleting,
|
||||
setFalse: hideDeleting,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
showDeleting()
|
||||
const res = await uninstallPlugin(id)
|
||||
hideDeleting()
|
||||
if (res.success) {
|
||||
hideDeleteConfirm()
|
||||
onUpdate?.(true)
|
||||
if (PluginCategoryEnum.model.includes(category))
|
||||
refreshModelProviders()
|
||||
if (PluginCategoryEnum.tool.includes(category))
|
||||
invalidateAllToolProviders()
|
||||
trackEvent('plugin_uninstalled', { plugin_id, plugin_name: name })
|
||||
}
|
||||
}, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders, plugin_id, name])
|
||||
|
||||
return (
|
||||
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}>
|
||||
<div className="flex">
|
||||
<div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}>
|
||||
<Icon src={iconSrc} />
|
||||
</div>
|
||||
<div className="ml-3 w-0 grow">
|
||||
<div className="flex h-5 items-center">
|
||||
<Title title={label[locale]} />
|
||||
{verified && !isReadmeView && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />}
|
||||
{!!version && (
|
||||
<PluginVersionPicker
|
||||
disabled={!isFromMarketplace || isReadmeView}
|
||||
isShow={isShow}
|
||||
onShowChange={setIsShow}
|
||||
pluginID={plugin_id}
|
||||
currentVersion={version}
|
||||
onSelect={(state) => {
|
||||
setTargetVersion(state)
|
||||
handleUpdate(state.isDowngrade)
|
||||
}}
|
||||
trigger={(
|
||||
<Badge
|
||||
className={cn(
|
||||
'mx-1',
|
||||
isShow && 'bg-state-base-hover',
|
||||
(isShow || isFromMarketplace) && 'hover:bg-state-base-hover',
|
||||
)}
|
||||
uppercase={false}
|
||||
text={(
|
||||
<>
|
||||
<div>{isFromGitHub ? meta!.version : version}</div>
|
||||
{isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />}
|
||||
</>
|
||||
)}
|
||||
hasRedCornerMark={hasNewVersion}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Auto update info */}
|
||||
{isAutoUpgradeEnabled && !isReadmeView && (
|
||||
<Tooltip popupContent={t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}>
|
||||
{/* add a a div to fix tooltip hover not show problem */}
|
||||
<div>
|
||||
<Badge className="mr-1 cursor-pointer px-1">
|
||||
<AutoUpdateLine className="size-3" />
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(hasNewVersion || isFromGitHub) && (
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
className="!h-5"
|
||||
onClick={() => {
|
||||
if (isFromMarketplace) {
|
||||
setTargetVersion({
|
||||
version: latest_version,
|
||||
unique_identifier: latest_unique_identifier,
|
||||
})
|
||||
}
|
||||
handleUpdate()
|
||||
}}
|
||||
>
|
||||
{t('detailPanel.operation.update', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-1 flex h-4 items-center justify-between">
|
||||
<div className="mt-0.5 flex items-center">
|
||||
<OrgInfo
|
||||
packageNameClassName="w-auto"
|
||||
orgName={author}
|
||||
packageName={name?.includes('/') ? (name.split('/').pop() || '') : name}
|
||||
/>
|
||||
{!!source && (
|
||||
<>
|
||||
<div className="system-xs-regular ml-1 mr-0.5 text-text-quaternary">·</div>
|
||||
{source === PluginSource.marketplace && (
|
||||
<Tooltip popupContent={t('detailPanel.categoryTip.marketplace', { ns: 'plugin' })}>
|
||||
<div><BoxSparkleFill className="h-3.5 w-3.5 text-text-tertiary hover:text-text-accent" /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{source === PluginSource.github && (
|
||||
<Tooltip popupContent={t('detailPanel.categoryTip.github', { ns: 'plugin' })}>
|
||||
<div><Github className="h-3.5 w-3.5 text-text-secondary hover:text-text-primary" /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{source === PluginSource.local && (
|
||||
<Tooltip popupContent={t('detailPanel.categoryTip.local', { ns: 'plugin' })}>
|
||||
<div><RiHardDrive3Line className="h-3.5 w-3.5 text-text-tertiary" /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{source === PluginSource.debugging && (
|
||||
<Tooltip popupContent={t('detailPanel.categoryTip.debugging', { ns: 'plugin' })}>
|
||||
<div><RiBugLine className="h-3.5 w-3.5 text-text-tertiary hover:text-text-warning" /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isReadmeView && (
|
||||
<div className="flex gap-1">
|
||||
<OperationDropdown
|
||||
source={source}
|
||||
onInfo={showPluginInfo}
|
||||
onCheckVersion={handleUpdate}
|
||||
onRemove={showDeleteConfirm}
|
||||
detailUrl={detailUrl}
|
||||
/>
|
||||
<ActionButton onClick={onHide}>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isFromMarketplace && (
|
||||
<DeprecationNotice
|
||||
status={status}
|
||||
deprecatedReason={deprecated_reason}
|
||||
alternativePluginId={alternative_plugin_id}
|
||||
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
{!isReadmeView && <Description className="mb-2 mt-3 h-auto" text={description[locale]} descriptionLineRows={2}></Description>}
|
||||
{
|
||||
category === PluginCategoryEnum.tool && !isReadmeView && (
|
||||
<PluginAuth
|
||||
pluginPayload={{
|
||||
provider: provider?.name || '',
|
||||
category: AuthCategory.tool,
|
||||
providerType: provider?.type || '',
|
||||
detail,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{isShowPluginInfo && (
|
||||
<PluginInfo
|
||||
repository={isFromGitHub ? meta?.repo : ''}
|
||||
release={version}
|
||||
packageName={meta?.package || ''}
|
||||
onHide={hidePluginInfo}
|
||||
/>
|
||||
)}
|
||||
{isShowDeleteConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
|
||||
content={(
|
||||
<div>
|
||||
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
|
||||
<span className="system-md-semibold">{label[locale]}</span>
|
||||
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
|
||||
<br />
|
||||
</div>
|
||||
)}
|
||||
onCancel={hideDeleteConfirm}
|
||||
onConfirm={handleDelete}
|
||||
isLoading={deleting}
|
||||
isDisabled={deleting}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
isShowUpdateModal && (
|
||||
<UpdateFromMarketplace
|
||||
pluginId={plugin_id}
|
||||
payload={{
|
||||
category: detail.declaration.category,
|
||||
originalPackageInfo: {
|
||||
id: detail.plugin_unique_identifier,
|
||||
payload: detail.declaration,
|
||||
},
|
||||
targetPackageInfo: {
|
||||
id: targetVersion.unique_identifier,
|
||||
version: targetVersion.version,
|
||||
},
|
||||
}}
|
||||
onCancel={hideUpdateModal}
|
||||
onSave={handleUpdatedFromMarketplace}
|
||||
isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetailHeader
|
||||
// Re-export from refactored module for backward compatibility
|
||||
export { default } from './detail-header/index'
|
||||
|
||||
@ -0,0 +1,539 @@
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import type { ModalStates, VersionTarget } from '../hooks'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginSource } from '../../../types'
|
||||
import HeaderModals from './header-modals'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, title, onCancel, onConfirm, isLoading }: {
|
||||
isShow: boolean
|
||||
title: string
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
isLoading: boolean
|
||||
}) => isShow
|
||||
? (
|
||||
<div data-testid="delete-confirm">
|
||||
<div data-testid="delete-title">{title}</div>
|
||||
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isLoading}>Confirm</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-page/plugin-info', () => ({
|
||||
default: ({ repository, release, packageName, onHide }: {
|
||||
repository: string
|
||||
release: string
|
||||
packageName: string
|
||||
onHide: () => void
|
||||
}) => (
|
||||
<div data-testid="plugin-info">
|
||||
<div data-testid="plugin-info-repo">{repository}</div>
|
||||
<div data-testid="plugin-info-release">{release}</div>
|
||||
<div data-testid="plugin-info-package">{packageName}</div>
|
||||
<button data-testid="plugin-info-close" onClick={onHide}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/update-plugin/from-market-place', () => ({
|
||||
default: ({ pluginId, onSave, onCancel, isShowDowngradeWarningModal }: {
|
||||
pluginId: string
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
isShowDowngradeWarningModal: boolean
|
||||
}) => (
|
||||
<div data-testid="update-modal">
|
||||
<div data-testid="update-plugin-id">{pluginId}</div>
|
||||
<div data-testid="update-downgrade-warning">{String(isShowDowngradeWarningModal)}</div>
|
||||
<button data-testid="update-modal-save" onClick={onSave}>Save</button>
|
||||
<button data-testid="update-modal-cancel" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'test-id',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-02',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin',
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
declaration: {
|
||||
author: 'test-author',
|
||||
name: 'test-plugin-name',
|
||||
category: 'tool',
|
||||
label: { en_US: 'Test Plugin Label' },
|
||||
description: { en_US: 'Test description' },
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
installation_id: 'install-1',
|
||||
tenant_id: 'tenant-1',
|
||||
endpoints_setups: 0,
|
||||
endpoints_active: 0,
|
||||
version: '1.0.0',
|
||||
latest_version: '2.0.0',
|
||||
latest_unique_identifier: 'new-uid',
|
||||
source: PluginSource.marketplace,
|
||||
meta: undefined,
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createModalStatesMock = (overrides: Partial<ModalStates> = {}): ModalStates => ({
|
||||
isShowUpdateModal: false,
|
||||
showUpdateModal: vi.fn<() => void>(),
|
||||
hideUpdateModal: vi.fn<() => void>(),
|
||||
isShowPluginInfo: false,
|
||||
showPluginInfo: vi.fn<() => void>(),
|
||||
hidePluginInfo: vi.fn<() => void>(),
|
||||
isShowDeleteConfirm: false,
|
||||
showDeleteConfirm: vi.fn<() => void>(),
|
||||
hideDeleteConfirm: vi.fn<() => void>(),
|
||||
deleting: false,
|
||||
showDeleting: vi.fn<() => void>(),
|
||||
hideDeleting: vi.fn<() => void>(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createTargetVersion = (overrides: Partial<VersionTarget> = {}): VersionTarget => ({
|
||||
version: '2.0.0',
|
||||
unique_identifier: 'new-uid',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('HeaderModals', () => {
|
||||
let mockOnUpdatedFromMarketplace: () => void
|
||||
let mockOnDelete: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockOnUpdatedFromMarketplace = vi.fn<() => void>()
|
||||
mockOnDelete = vi.fn<() => void>()
|
||||
})
|
||||
|
||||
describe('Plugin Info Modal', () => {
|
||||
it('should not render plugin info modal when isShowPluginInfo is false', () => {
|
||||
const modalStates = createModalStatesMock({ isShowPluginInfo: false })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('plugin-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin info modal when isShowPluginInfo is true', () => {
|
||||
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass GitHub repo to plugin info for GitHub source', () => {
|
||||
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'test-pkg' },
|
||||
})
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={detail}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('owner/repo')
|
||||
})
|
||||
|
||||
it('should pass empty string for repo for non-GitHub source', () => {
|
||||
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail({ source: PluginSource.marketplace })}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should call hidePluginInfo when close button is clicked', () => {
|
||||
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('plugin-info-close'))
|
||||
|
||||
expect(modalStates.hidePluginInfo).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete Confirm Modal', () => {
|
||||
it('should not render delete confirm when isShowDeleteConfirm is false', () => {
|
||||
const modalStates = createModalStatesMock({ isShowDeleteConfirm: false })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete confirm when isShowDeleteConfirm is true', () => {
|
||||
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show correct delete title', () => {
|
||||
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('delete-title')).toHaveTextContent('action.delete')
|
||||
})
|
||||
|
||||
it('should call hideDeleteConfirm when cancel is clicked', () => {
|
||||
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-cancel'))
|
||||
|
||||
expect(modalStates.hideDeleteConfirm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onDelete when confirm is clicked', () => {
|
||||
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
expect(mockOnDelete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable confirm button when deleting', () => {
|
||||
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true, deleting: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('confirm-ok')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update Modal', () => {
|
||||
it('should not render update modal when isShowUpdateModal is false', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: false })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('update-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render update modal when isShowUpdateModal is true', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass plugin id to update modal', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail({ plugin_id: 'my-plugin-id' })}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('update-plugin-id')).toHaveTextContent('my-plugin-id')
|
||||
})
|
||||
|
||||
it('should call onUpdatedFromMarketplace when save is clicked', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-modal-save'))
|
||||
|
||||
expect(mockOnUpdatedFromMarketplace).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call hideUpdateModal when cancel is clicked', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-modal-cancel'))
|
||||
|
||||
expect(modalStates.hideUpdateModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show downgrade warning when isDowngrade and isAutoUpgradeEnabled are true', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={true}
|
||||
isAutoUpgradeEnabled={true}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should not show downgrade warning when only isDowngrade is true', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={true}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should not show downgrade warning when only isAutoUpgradeEnabled is true', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={true}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple Modals', () => {
|
||||
it('should render multiple modals when multiple are open', () => {
|
||||
const modalStates = createModalStatesMock({
|
||||
isShowPluginInfo: true,
|
||||
isShowDeleteConfirm: true,
|
||||
isShowUpdateModal: true,
|
||||
})
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined target version values', () => {
|
||||
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={createPluginDetail()}
|
||||
modalStates={modalStates}
|
||||
targetVersion={{ version: undefined, unique_identifier: undefined }}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty meta for GitHub source', () => {
|
||||
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: undefined,
|
||||
})
|
||||
render(
|
||||
<HeaderModals
|
||||
detail={detail}
|
||||
modalStates={modalStates}
|
||||
targetVersion={createTargetVersion()}
|
||||
isDowngrade={false}
|
||||
isAutoUpgradeEnabled={false}
|
||||
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('')
|
||||
expect(screen.getByTestId('plugin-info-package')).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import type { ModalStates, VersionTarget } from '../hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info'
|
||||
import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { PluginSource } from '../../../types'
|
||||
|
||||
const i18nPrefix = 'action'
|
||||
|
||||
type HeaderModalsProps = {
|
||||
detail: PluginDetail
|
||||
modalStates: ModalStates
|
||||
targetVersion: VersionTarget
|
||||
isDowngrade: boolean
|
||||
isAutoUpgradeEnabled: boolean
|
||||
onUpdatedFromMarketplace: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const HeaderModals: FC<HeaderModalsProps> = ({
|
||||
detail,
|
||||
modalStates,
|
||||
targetVersion,
|
||||
isDowngrade,
|
||||
isAutoUpgradeEnabled,
|
||||
onUpdatedFromMarketplace,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useGetLanguage()
|
||||
|
||||
const { source, version, meta } = detail
|
||||
const { label } = detail.declaration || detail
|
||||
const isFromGitHub = source === PluginSource.github
|
||||
|
||||
const {
|
||||
isShowUpdateModal,
|
||||
hideUpdateModal,
|
||||
isShowPluginInfo,
|
||||
hidePluginInfo,
|
||||
isShowDeleteConfirm,
|
||||
hideDeleteConfirm,
|
||||
deleting,
|
||||
} = modalStates
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Plugin Info Modal */}
|
||||
{isShowPluginInfo && (
|
||||
<PluginInfo
|
||||
repository={isFromGitHub ? meta?.repo : ''}
|
||||
release={version}
|
||||
packageName={meta?.package || ''}
|
||||
onHide={hidePluginInfo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirm Modal */}
|
||||
{isShowDeleteConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
|
||||
content={(
|
||||
<div>
|
||||
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
|
||||
<span className="system-md-semibold">{label[locale]}</span>
|
||||
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
|
||||
<br />
|
||||
</div>
|
||||
)}
|
||||
onCancel={hideDeleteConfirm}
|
||||
onConfirm={onDelete}
|
||||
isLoading={deleting}
|
||||
isDisabled={deleting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Update from Marketplace Modal */}
|
||||
{isShowUpdateModal && (
|
||||
<UpdateFromMarketplace
|
||||
pluginId={detail.plugin_id}
|
||||
payload={{
|
||||
category: detail.declaration?.category ?? '',
|
||||
originalPackageInfo: {
|
||||
id: detail.plugin_unique_identifier,
|
||||
payload: detail.declaration ?? undefined,
|
||||
},
|
||||
targetPackageInfo: {
|
||||
id: targetVersion.unique_identifier || '',
|
||||
version: targetVersion.version || '',
|
||||
},
|
||||
}}
|
||||
onCancel={hideUpdateModal}
|
||||
onSave={onUpdatedFromMarketplace}
|
||||
isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderModals
|
||||
@ -0,0 +1,2 @@
|
||||
export { default as HeaderModals } from './header-modals'
|
||||
export { default as PluginSourceBadge } from './plugin-source-badge'
|
||||
@ -0,0 +1,200 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginSource } from '../../../types'
|
||||
import PluginSourceBadge from './plugin-source-badge'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
||||
<div data-testid="tooltip" data-content={popupContent}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('PluginSourceBadge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Source Icon Rendering', () => {
|
||||
it('should render marketplace source badge', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.marketplace} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
expect(tooltip).toBeInTheDocument()
|
||||
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.marketplace')
|
||||
})
|
||||
|
||||
it('should render github source badge', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.github} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
expect(tooltip).toBeInTheDocument()
|
||||
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.github')
|
||||
})
|
||||
|
||||
it('should render local source badge', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.local} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
expect(tooltip).toBeInTheDocument()
|
||||
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.local')
|
||||
})
|
||||
|
||||
it('should render debugging source badge', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.debugging} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
expect(tooltip).toBeInTheDocument()
|
||||
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.debugging')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Separator Rendering', () => {
|
||||
it('should render separator dot before marketplace badge', () => {
|
||||
const { container } = render(<PluginSourceBadge source={PluginSource.marketplace} />)
|
||||
|
||||
const separator = container.querySelector('.text-text-quaternary')
|
||||
expect(separator).toBeInTheDocument()
|
||||
expect(separator?.textContent).toBe('·')
|
||||
})
|
||||
|
||||
it('should render separator dot before github badge', () => {
|
||||
const { container } = render(<PluginSourceBadge source={PluginSource.github} />)
|
||||
|
||||
const separator = container.querySelector('.text-text-quaternary')
|
||||
expect(separator).toBeInTheDocument()
|
||||
expect(separator?.textContent).toBe('·')
|
||||
})
|
||||
|
||||
it('should render separator dot before local badge', () => {
|
||||
const { container } = render(<PluginSourceBadge source={PluginSource.local} />)
|
||||
|
||||
const separator = container.querySelector('.text-text-quaternary')
|
||||
expect(separator).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render separator dot before debugging badge', () => {
|
||||
const { container } = render(<PluginSourceBadge source={PluginSource.debugging} />)
|
||||
|
||||
const separator = container.querySelector('.text-text-quaternary')
|
||||
expect(separator).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip Content', () => {
|
||||
it('should show marketplace tooltip', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.marketplace} />)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute(
|
||||
'data-content',
|
||||
'detailPanel.categoryTip.marketplace',
|
||||
)
|
||||
})
|
||||
|
||||
it('should show github tooltip', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.github} />)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute(
|
||||
'data-content',
|
||||
'detailPanel.categoryTip.github',
|
||||
)
|
||||
})
|
||||
|
||||
it('should show local tooltip', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.local} />)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute(
|
||||
'data-content',
|
||||
'detailPanel.categoryTip.local',
|
||||
)
|
||||
})
|
||||
|
||||
it('should show debugging tooltip', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.debugging} />)
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute(
|
||||
'data-content',
|
||||
'detailPanel.categoryTip.debugging',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icon Element Structure', () => {
|
||||
it('should render icon inside tooltip for marketplace', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.marketplace} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
const iconWrapper = tooltip.querySelector('div')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon inside tooltip for github', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.github} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
const iconWrapper = tooltip.querySelector('div')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon inside tooltip for local', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.local} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
const iconWrapper = tooltip.querySelector('div')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon inside tooltip for debugging', () => {
|
||||
render(<PluginSourceBadge source={PluginSource.debugging} />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
const iconWrapper = tooltip.querySelector('div')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Lookup Table Coverage', () => {
|
||||
it('should handle all PluginSource enum values', () => {
|
||||
const allSources = Object.values(PluginSource)
|
||||
|
||||
allSources.forEach((source) => {
|
||||
const { container } = render(<PluginSourceBadge source={source} />)
|
||||
// Should render either tooltip or nothing
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invalid Source Handling', () => {
|
||||
it('should return null for unknown source type', () => {
|
||||
// Use type assertion to test invalid source value
|
||||
const invalidSource = 'unknown_source' as PluginSource
|
||||
const { container } = render(<PluginSourceBadge source={invalidSource} />)
|
||||
|
||||
// Should render nothing (empty container)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should not render separator for invalid source', () => {
|
||||
const invalidSource = 'invalid' as PluginSource
|
||||
const { container } = render(<PluginSourceBadge source={invalidSource} />)
|
||||
|
||||
const separator = container.querySelector('.text-text-quaternary')
|
||||
expect(separator).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip for invalid source', () => {
|
||||
const invalidSource = '' as PluginSource
|
||||
render(<PluginSourceBadge source={invalidSource} />)
|
||||
|
||||
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import {
|
||||
RiBugLine,
|
||||
RiHardDrive3Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Github } from '@/app/components/base/icons/src/public/common'
|
||||
import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { PluginSource } from '../../../types'
|
||||
|
||||
type SourceConfig = {
|
||||
icon: ReactNode
|
||||
tipKey: string
|
||||
}
|
||||
|
||||
type PluginSourceBadgeProps = {
|
||||
source: PluginSource
|
||||
}
|
||||
|
||||
const SOURCE_CONFIG_MAP: Record<PluginSource, SourceConfig | null> = {
|
||||
[PluginSource.marketplace]: {
|
||||
icon: <BoxSparkleFill className="h-3.5 w-3.5 text-text-tertiary hover:text-text-accent" />,
|
||||
tipKey: 'detailPanel.categoryTip.marketplace',
|
||||
},
|
||||
[PluginSource.github]: {
|
||||
icon: <Github className="h-3.5 w-3.5 text-text-secondary hover:text-text-primary" />,
|
||||
tipKey: 'detailPanel.categoryTip.github',
|
||||
},
|
||||
[PluginSource.local]: {
|
||||
icon: <RiHardDrive3Line className="h-3.5 w-3.5 text-text-tertiary" />,
|
||||
tipKey: 'detailPanel.categoryTip.local',
|
||||
},
|
||||
[PluginSource.debugging]: {
|
||||
icon: <RiBugLine className="h-3.5 w-3.5 text-text-tertiary hover:text-text-warning" />,
|
||||
tipKey: 'detailPanel.categoryTip.debugging',
|
||||
},
|
||||
}
|
||||
|
||||
const PluginSourceBadge: FC<PluginSourceBadgeProps> = ({ source }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const config = SOURCE_CONFIG_MAP[source]
|
||||
if (!config)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="system-xs-regular ml-1 mr-0.5 text-text-quaternary">·</div>
|
||||
<Tooltip popupContent={t(config.tipKey as never, { ns: 'plugin' })}>
|
||||
<div>{config.icon}</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginSourceBadge
|
||||
@ -0,0 +1,3 @@
|
||||
export { useDetailHeaderState } from './use-detail-header-state'
|
||||
export type { ModalStates, UseDetailHeaderStateReturn, VersionPickerState, VersionTarget } from './use-detail-header-state'
|
||||
export { usePluginOperations } from './use-plugin-operations'
|
||||
@ -0,0 +1,409 @@
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginSource } from '../../../types'
|
||||
import { useDetailHeaderState } from './use-detail-header-state'
|
||||
|
||||
let mockEnableMarketplace = true
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) =>
|
||||
selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }),
|
||||
}))
|
||||
|
||||
let mockAutoUpgradeInfo: {
|
||||
strategy_setting: string
|
||||
upgrade_mode: string
|
||||
include_plugins: string[]
|
||||
exclude_plugins: string[]
|
||||
upgrade_time_of_day: number
|
||||
} | null = null
|
||||
|
||||
vi.mock('../../../plugin-page/use-reference-setting', () => ({
|
||||
default: () => ({
|
||||
referenceSetting: mockAutoUpgradeInfo ? { auto_upgrade: mockAutoUpgradeInfo } : null,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../reference-setting-modal/auto-update-setting/types', () => ({
|
||||
AUTO_UPDATE_MODE: {
|
||||
update_all: 'update_all',
|
||||
partial: 'partial',
|
||||
exclude: 'exclude',
|
||||
},
|
||||
}))
|
||||
|
||||
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'test-id',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-02',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin',
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
declaration: {
|
||||
author: 'test-author',
|
||||
name: 'test-plugin-name',
|
||||
category: 'tool',
|
||||
label: { en_US: 'Test Plugin Label' },
|
||||
description: { en_US: 'Test description' },
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
installation_id: 'install-1',
|
||||
tenant_id: 'tenant-1',
|
||||
endpoints_setups: 0,
|
||||
endpoints_active: 0,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_unique_identifier: 'test-uid',
|
||||
source: PluginSource.marketplace,
|
||||
meta: undefined,
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useDetailHeaderState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAutoUpgradeInfo = null
|
||||
mockEnableMarketplace = true
|
||||
})
|
||||
|
||||
describe('Source Type Detection', () => {
|
||||
it('should detect marketplace source', () => {
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isFromMarketplace).toBe(true)
|
||||
expect(result.current.isFromGitHub).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect GitHub source', () => {
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isFromGitHub).toBe(true)
|
||||
expect(result.current.isFromMarketplace).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect local source', () => {
|
||||
const detail = createPluginDetail({ source: PluginSource.local })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isFromGitHub).toBe(false)
|
||||
expect(result.current.isFromMarketplace).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Version State', () => {
|
||||
it('should detect new version available for marketplace plugin', () => {
|
||||
const detail = createPluginDetail({
|
||||
version: '1.0.0',
|
||||
latest_version: '2.0.0',
|
||||
source: PluginSource.marketplace,
|
||||
})
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.hasNewVersion).toBe(true)
|
||||
})
|
||||
|
||||
it('should not detect new version when versions match', () => {
|
||||
const detail = createPluginDetail({
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
source: PluginSource.marketplace,
|
||||
})
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.hasNewVersion).toBe(false)
|
||||
})
|
||||
|
||||
it('should not detect new version for non-marketplace source', () => {
|
||||
const detail = createPluginDetail({
|
||||
version: '1.0.0',
|
||||
latest_version: '2.0.0',
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.hasNewVersion).toBe(false)
|
||||
})
|
||||
|
||||
it('should not detect new version when latest_version is empty', () => {
|
||||
const detail = createPluginDetail({
|
||||
version: '1.0.0',
|
||||
latest_version: '',
|
||||
source: PluginSource.marketplace,
|
||||
})
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.hasNewVersion).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Version Picker State', () => {
|
||||
it('should initialize version picker as hidden', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.versionPicker.isShow).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle version picker visibility', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.versionPicker.setIsShow(true)
|
||||
})
|
||||
expect(result.current.versionPicker.isShow).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.versionPicker.setIsShow(false)
|
||||
})
|
||||
expect(result.current.versionPicker.isShow).toBe(false)
|
||||
})
|
||||
|
||||
it('should update target version', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.versionPicker.setTargetVersion({
|
||||
version: '2.0.0',
|
||||
unique_identifier: 'new-uid',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.versionPicker.targetVersion.version).toBe('2.0.0')
|
||||
expect(result.current.versionPicker.targetVersion.unique_identifier).toBe('new-uid')
|
||||
})
|
||||
|
||||
it('should set isDowngrade when provided in target version', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.versionPicker.setTargetVersion({
|
||||
version: '0.5.0',
|
||||
unique_identifier: 'old-uid',
|
||||
isDowngrade: true,
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.versionPicker.isDowngrade).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal States', () => {
|
||||
it('should initialize all modals as hidden', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.modalStates.isShowUpdateModal).toBe(false)
|
||||
expect(result.current.modalStates.isShowPluginInfo).toBe(false)
|
||||
expect(result.current.modalStates.isShowDeleteConfirm).toBe(false)
|
||||
expect(result.current.modalStates.deleting).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle update modal', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.showUpdateModal()
|
||||
})
|
||||
expect(result.current.modalStates.isShowUpdateModal).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.hideUpdateModal()
|
||||
})
|
||||
expect(result.current.modalStates.isShowUpdateModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle plugin info modal', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.showPluginInfo()
|
||||
})
|
||||
expect(result.current.modalStates.isShowPluginInfo).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.hidePluginInfo()
|
||||
})
|
||||
expect(result.current.modalStates.isShowPluginInfo).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle delete confirm modal', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.showDeleteConfirm()
|
||||
})
|
||||
expect(result.current.modalStates.isShowDeleteConfirm).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.hideDeleteConfirm()
|
||||
})
|
||||
expect(result.current.modalStates.isShowDeleteConfirm).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle deleting state', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.showDeleting()
|
||||
})
|
||||
expect(result.current.modalStates.deleting).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.modalStates.hideDeleting()
|
||||
})
|
||||
expect(result.current.modalStates.deleting).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auto Upgrade Detection', () => {
|
||||
it('should disable auto upgrade when marketplace is disabled', () => {
|
||||
mockEnableMarketplace = false
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'update_all',
|
||||
include_plugins: [],
|
||||
exclude_plugins: [],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should disable auto upgrade when strategy is disabled', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'disabled',
|
||||
upgrade_mode: 'update_all',
|
||||
include_plugins: [],
|
||||
exclude_plugins: [],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should enable auto upgrade for update_all mode', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'update_all',
|
||||
include_plugins: [],
|
||||
exclude_plugins: [],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should enable auto upgrade for partial mode when plugin is included', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'partial',
|
||||
include_plugins: ['test-plugin'],
|
||||
exclude_plugins: [],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should disable auto upgrade for partial mode when plugin is not included', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'partial',
|
||||
include_plugins: ['other-plugin'],
|
||||
exclude_plugins: [],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should enable auto upgrade for exclude mode when plugin is not excluded', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'exclude',
|
||||
include_plugins: [],
|
||||
exclude_plugins: ['other-plugin'],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should disable auto upgrade for exclude mode when plugin is excluded', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'exclude',
|
||||
include_plugins: [],
|
||||
exclude_plugins: ['test-plugin'],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should disable auto upgrade for non-marketplace source', () => {
|
||||
mockAutoUpgradeInfo = {
|
||||
strategy_setting: 'enabled',
|
||||
upgrade_mode: 'update_all',
|
||||
include_plugins: [],
|
||||
exclude_plugins: [],
|
||||
upgrade_time_of_day: 36000,
|
||||
}
|
||||
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should disable auto upgrade when no auto upgrade info', () => {
|
||||
mockAutoUpgradeInfo = null
|
||||
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() => useDetailHeaderState(detail))
|
||||
|
||||
expect(result.current.isAutoUpgradeEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useReferenceSetting from '../../../plugin-page/use-reference-setting'
|
||||
import { AUTO_UPDATE_MODE } from '../../../reference-setting-modal/auto-update-setting/types'
|
||||
import { PluginSource } from '../../../types'
|
||||
|
||||
export type VersionTarget = {
|
||||
version: string | undefined
|
||||
unique_identifier: string | undefined
|
||||
isDowngrade?: boolean
|
||||
}
|
||||
|
||||
export type ModalStates = {
|
||||
isShowUpdateModal: boolean
|
||||
showUpdateModal: () => void
|
||||
hideUpdateModal: () => void
|
||||
isShowPluginInfo: boolean
|
||||
showPluginInfo: () => void
|
||||
hidePluginInfo: () => void
|
||||
isShowDeleteConfirm: boolean
|
||||
showDeleteConfirm: () => void
|
||||
hideDeleteConfirm: () => void
|
||||
deleting: boolean
|
||||
showDeleting: () => void
|
||||
hideDeleting: () => void
|
||||
}
|
||||
|
||||
export type VersionPickerState = {
|
||||
isShow: boolean
|
||||
setIsShow: (show: boolean) => void
|
||||
targetVersion: VersionTarget
|
||||
setTargetVersion: (version: VersionTarget) => void
|
||||
isDowngrade: boolean
|
||||
setIsDowngrade: (downgrade: boolean) => void
|
||||
}
|
||||
|
||||
export type UseDetailHeaderStateReturn = {
|
||||
modalStates: ModalStates
|
||||
versionPicker: VersionPickerState
|
||||
hasNewVersion: boolean
|
||||
isAutoUpgradeEnabled: boolean
|
||||
isFromGitHub: boolean
|
||||
isFromMarketplace: boolean
|
||||
}
|
||||
|
||||
export const useDetailHeaderState = (detail: PluginDetail): UseDetailHeaderStateReturn => {
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { referenceSetting } = useReferenceSetting()
|
||||
|
||||
const {
|
||||
source,
|
||||
version,
|
||||
latest_version,
|
||||
latest_unique_identifier,
|
||||
plugin_id,
|
||||
} = detail
|
||||
|
||||
const isFromGitHub = source === PluginSource.github
|
||||
const isFromMarketplace = source === PluginSource.marketplace
|
||||
const [isShow, setIsShow] = useState(false)
|
||||
const [targetVersion, setTargetVersion] = useState<VersionTarget>({
|
||||
version: latest_version,
|
||||
unique_identifier: latest_unique_identifier,
|
||||
})
|
||||
const [isDowngrade, setIsDowngrade] = useState(false)
|
||||
|
||||
const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = useBoolean(false)
|
||||
const [isShowPluginInfo, { setTrue: showPluginInfo, setFalse: hidePluginInfo }] = useBoolean(false)
|
||||
const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = useBoolean(false)
|
||||
const [deleting, { setTrue: showDeleting, setFalse: hideDeleting }] = useBoolean(false)
|
||||
|
||||
const hasNewVersion = useMemo(() => {
|
||||
if (isFromMarketplace)
|
||||
return !!latest_version && latest_version !== version
|
||||
return false
|
||||
}, [isFromMarketplace, latest_version, version])
|
||||
|
||||
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
|
||||
|
||||
const isAutoUpgradeEnabled = useMemo(() => {
|
||||
if (!enable_marketplace || !autoUpgradeInfo || !isFromMarketplace)
|
||||
return false
|
||||
if (autoUpgradeInfo.strategy_setting === 'disabled')
|
||||
return false
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
|
||||
return true
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
|
||||
return true
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
|
||||
return true
|
||||
return false
|
||||
}, [autoUpgradeInfo, plugin_id, isFromMarketplace, enable_marketplace])
|
||||
|
||||
const handleSetTargetVersion = useCallback((version: VersionTarget) => {
|
||||
setTargetVersion(version)
|
||||
if (version.isDowngrade !== undefined)
|
||||
setIsDowngrade(version.isDowngrade)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
modalStates: {
|
||||
isShowUpdateModal,
|
||||
showUpdateModal,
|
||||
hideUpdateModal,
|
||||
isShowPluginInfo,
|
||||
showPluginInfo,
|
||||
hidePluginInfo,
|
||||
isShowDeleteConfirm,
|
||||
showDeleteConfirm,
|
||||
hideDeleteConfirm,
|
||||
deleting,
|
||||
showDeleting,
|
||||
hideDeleting,
|
||||
},
|
||||
versionPicker: {
|
||||
isShow,
|
||||
setIsShow,
|
||||
targetVersion,
|
||||
setTargetVersion: handleSetTargetVersion,
|
||||
isDowngrade,
|
||||
setIsDowngrade,
|
||||
},
|
||||
hasNewVersion,
|
||||
isAutoUpgradeEnabled,
|
||||
isFromGitHub,
|
||||
isFromMarketplace,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,549 @@
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import type { ModalStates, VersionTarget } from './use-detail-header-state'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as amplitude from '@/app/components/base/amplitude'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { PluginSource } from '../../../types'
|
||||
import { usePluginOperations } from './use-plugin-operations'
|
||||
|
||||
type VersionPickerMock = {
|
||||
setTargetVersion: (version: VersionTarget) => void
|
||||
setIsDowngrade: (downgrade: boolean) => void
|
||||
}
|
||||
|
||||
const {
|
||||
mockSetShowUpdatePluginModal,
|
||||
mockRefreshModelProviders,
|
||||
mockInvalidateAllToolProviders,
|
||||
mockUninstallPlugin,
|
||||
mockFetchReleases,
|
||||
mockCheckForUpdates,
|
||||
} = vi.hoisted(() => {
|
||||
return {
|
||||
mockSetShowUpdatePluginModal: vi.fn(),
|
||||
mockRefreshModelProviders: vi.fn(),
|
||||
mockInvalidateAllToolProviders: vi.fn(),
|
||||
mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })),
|
||||
mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])),
|
||||
mockCheckForUpdates: vi.fn(() => ({ needUpdate: true, toastProps: { type: 'success', message: 'Update available' } })),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowUpdatePluginModal: mockSetShowUpdatePluginModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
refreshModelProviders: mockRefreshModelProviders,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uninstallPlugin: mockUninstallPlugin,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
|
||||
}))
|
||||
|
||||
vi.mock('../../../install-plugin/hooks', () => ({
|
||||
useGitHubReleases: () => ({
|
||||
checkForUpdates: mockCheckForUpdates,
|
||||
fetchReleases: mockFetchReleases,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'test-id',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-02',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin',
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
declaration: {
|
||||
author: 'test-author',
|
||||
name: 'test-plugin-name',
|
||||
category: 'tool',
|
||||
label: { en_US: 'Test Plugin Label' },
|
||||
description: { en_US: 'Test description' },
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
installation_id: 'install-1',
|
||||
tenant_id: 'tenant-1',
|
||||
endpoints_setups: 0,
|
||||
endpoints_active: 0,
|
||||
version: '1.0.0',
|
||||
latest_version: '2.0.0',
|
||||
latest_unique_identifier: 'new-uid',
|
||||
source: PluginSource.marketplace,
|
||||
meta: undefined,
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createModalStatesMock = (): ModalStates => ({
|
||||
isShowUpdateModal: false,
|
||||
showUpdateModal: vi.fn(),
|
||||
hideUpdateModal: vi.fn(),
|
||||
isShowPluginInfo: false,
|
||||
showPluginInfo: vi.fn(),
|
||||
hidePluginInfo: vi.fn(),
|
||||
isShowDeleteConfirm: false,
|
||||
showDeleteConfirm: vi.fn(),
|
||||
hideDeleteConfirm: vi.fn(),
|
||||
deleting: false,
|
||||
showDeleting: vi.fn(),
|
||||
hideDeleting: vi.fn(),
|
||||
})
|
||||
|
||||
const createVersionPickerMock = (): VersionPickerMock => ({
|
||||
setTargetVersion: vi.fn<(version: VersionTarget) => void>(),
|
||||
setIsDowngrade: vi.fn<(downgrade: boolean) => void>(),
|
||||
})
|
||||
|
||||
describe('usePluginOperations', () => {
|
||||
let modalStates: ModalStates
|
||||
let versionPicker: VersionPickerMock
|
||||
let mockOnUpdate: (isDelete?: boolean) => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
modalStates = createModalStatesMock()
|
||||
versionPicker = createVersionPickerMock()
|
||||
mockOnUpdate = vi.fn()
|
||||
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||
vi.spyOn(amplitude, 'trackEvent').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe('Marketplace Update Flow', () => {
|
||||
it('should show update modal for marketplace plugin', async () => {
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate()
|
||||
})
|
||||
|
||||
expect(modalStates.showUpdateModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set isDowngrade when downgrading', async () => {
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate(true)
|
||||
})
|
||||
|
||||
expect(versionPicker.setIsDowngrade).toHaveBeenCalledWith(true)
|
||||
expect(modalStates.showUpdateModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onUpdate and hide modal on successful marketplace update', () => {
|
||||
const detail = createPluginDetail({ source: PluginSource.marketplace })
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdatedFromMarketplace()
|
||||
})
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
expect(modalStates.hideUpdateModal).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GitHub Update Flow', () => {
|
||||
it('should fetch releases from GitHub', async () => {
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: false,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate()
|
||||
})
|
||||
|
||||
expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo')
|
||||
})
|
||||
|
||||
it('should check for updates after fetching releases', async () => {
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: false,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate()
|
||||
})
|
||||
|
||||
expect(mockCheckForUpdates).toHaveBeenCalled()
|
||||
expect(Toast.notify).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show update plugin modal when update is needed', async () => {
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: false,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate()
|
||||
})
|
||||
|
||||
expect(mockSetShowUpdatePluginModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not show modal when no releases found', async () => {
|
||||
mockFetchReleases.mockResolvedValueOnce([])
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: false,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate()
|
||||
})
|
||||
|
||||
expect(mockSetShowUpdatePluginModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not show modal when no update needed', async () => {
|
||||
mockCheckForUpdates.mockReturnValueOnce({
|
||||
needUpdate: false,
|
||||
toastProps: { type: 'info', message: 'Already up to date' },
|
||||
})
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: false,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate()
|
||||
})
|
||||
|
||||
expect(mockSetShowUpdatePluginModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use author and name as fallback for repo parsing', async () => {
|
||||
const detail = createPluginDetail({
|
||||
source: PluginSource.github,
|
||||
meta: { repo: '/', version: 'v1.0.0', package: 'pkg' },
|
||||
declaration: {
|
||||
author: 'fallback-author',
|
||||
name: 'fallback-name',
|
||||
category: 'tool',
|
||||
label: { en_US: 'Test' },
|
||||
description: { en_US: 'Test' },
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: false,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate()
|
||||
})
|
||||
|
||||
expect(mockFetchReleases).toHaveBeenCalledWith('fallback-author', 'fallback-name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete Flow', () => {
|
||||
it('should call uninstallPlugin with correct id', async () => {
|
||||
const detail = createPluginDetail({ id: 'plugin-to-delete' })
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('plugin-to-delete')
|
||||
})
|
||||
|
||||
it('should show and hide deleting state during delete', async () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(modalStates.showDeleting).toHaveBeenCalled()
|
||||
expect(modalStates.hideDeleting).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onUpdate with true after successful delete', async () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should hide delete confirm after successful delete', async () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(modalStates.hideDeleteConfirm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should refresh model providers when deleting model plugin', async () => {
|
||||
const detail = createPluginDetail({
|
||||
declaration: {
|
||||
author: 'test-author',
|
||||
name: 'test-plugin-name',
|
||||
category: 'model',
|
||||
label: { en_US: 'Test' },
|
||||
description: { en_US: 'Test' },
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(mockRefreshModelProviders).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invalidate tool providers when deleting tool plugin', async () => {
|
||||
const detail = createPluginDetail({
|
||||
declaration: {
|
||||
author: 'test-author',
|
||||
name: 'test-plugin-name',
|
||||
category: 'tool',
|
||||
label: { en_US: 'Test' },
|
||||
description: { en_US: 'Test' },
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(mockInvalidateAllToolProviders).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should track plugin uninstalled event', async () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(amplitude.trackEvent).toHaveBeenCalledWith('plugin_uninstalled', expect.objectContaining({
|
||||
plugin_id: 'test-plugin',
|
||||
plugin_name: 'test-plugin-name',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not call onUpdate when delete fails', async () => {
|
||||
mockUninstallPlugin.mockResolvedValueOnce({ success: false })
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
onUpdate: mockOnUpdate,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDelete()
|
||||
})
|
||||
|
||||
expect(mockOnUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Optional onUpdate Callback', () => {
|
||||
it('should not throw when onUpdate is not provided for marketplace update', () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(() => {
|
||||
result.current.handleUpdatedFromMarketplace()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when onUpdate is not provided for delete', async () => {
|
||||
const detail = createPluginDetail()
|
||||
const { result } = renderHook(() =>
|
||||
usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace: true,
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.handleDelete()
|
||||
}),
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginDetail } from '../../../types'
|
||||
import type { ModalStates, VersionTarget } from './use-detail-header-state'
|
||||
import { useCallback } from 'react'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { uninstallPlugin } from '@/service/plugins'
|
||||
import { useInvalidateAllToolProviders } from '@/service/use-tools'
|
||||
import { useGitHubReleases } from '../../../install-plugin/hooks'
|
||||
import { PluginCategoryEnum, PluginSource } from '../../../types'
|
||||
|
||||
type UsePluginOperationsParams = {
|
||||
detail: PluginDetail
|
||||
modalStates: ModalStates
|
||||
versionPicker: {
|
||||
setTargetVersion: (version: VersionTarget) => void
|
||||
setIsDowngrade: (downgrade: boolean) => void
|
||||
}
|
||||
isFromMarketplace: boolean
|
||||
onUpdate?: (isDelete?: boolean) => void
|
||||
}
|
||||
|
||||
type UsePluginOperationsReturn = {
|
||||
handleUpdate: (isDowngrade?: boolean) => Promise<void>
|
||||
handleUpdatedFromMarketplace: () => void
|
||||
handleDelete: () => Promise<void>
|
||||
}
|
||||
|
||||
export const usePluginOperations = ({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace,
|
||||
onUpdate,
|
||||
}: UsePluginOperationsParams): UsePluginOperationsReturn => {
|
||||
const { checkForUpdates, fetchReleases } = useGitHubReleases()
|
||||
const { setShowUpdatePluginModal } = useModalContext()
|
||||
const { refreshModelProviders } = useProviderContext()
|
||||
const invalidateAllToolProviders = useInvalidateAllToolProviders()
|
||||
|
||||
const { id, meta, plugin_id } = detail
|
||||
const { author, category, name } = detail.declaration || detail
|
||||
|
||||
const handleUpdate = useCallback(async (isDowngrade?: boolean) => {
|
||||
if (isFromMarketplace) {
|
||||
versionPicker.setIsDowngrade(!!isDowngrade)
|
||||
modalStates.showUpdateModal()
|
||||
return
|
||||
}
|
||||
|
||||
if (!meta?.repo || !meta?.version || !meta?.package) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Missing plugin metadata for GitHub update',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const owner = meta.repo.split('/')[0] || author
|
||||
const repo = meta.repo.split('/')[1] || name
|
||||
const fetchedReleases = await fetchReleases(owner, repo)
|
||||
if (fetchedReleases.length === 0)
|
||||
return
|
||||
|
||||
const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta.version)
|
||||
Toast.notify(toastProps)
|
||||
|
||||
if (needUpdate) {
|
||||
setShowUpdatePluginModal({
|
||||
onSaveCallback: () => {
|
||||
onUpdate?.()
|
||||
},
|
||||
payload: {
|
||||
type: PluginSource.github,
|
||||
category,
|
||||
github: {
|
||||
originalPackageInfo: {
|
||||
id: detail.plugin_unique_identifier,
|
||||
repo: meta.repo,
|
||||
version: meta.version,
|
||||
package: meta.package,
|
||||
releases: fetchedReleases,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [
|
||||
isFromMarketplace,
|
||||
meta,
|
||||
author,
|
||||
name,
|
||||
fetchReleases,
|
||||
checkForUpdates,
|
||||
setShowUpdatePluginModal,
|
||||
detail,
|
||||
onUpdate,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
])
|
||||
|
||||
const handleUpdatedFromMarketplace = useCallback(() => {
|
||||
onUpdate?.()
|
||||
modalStates.hideUpdateModal()
|
||||
}, [onUpdate, modalStates])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
modalStates.showDeleting()
|
||||
const res = await uninstallPlugin(id)
|
||||
modalStates.hideDeleting()
|
||||
|
||||
if (res.success) {
|
||||
modalStates.hideDeleteConfirm()
|
||||
onUpdate?.(true)
|
||||
|
||||
if (PluginCategoryEnum.model.includes(category))
|
||||
refreshModelProviders()
|
||||
|
||||
if (PluginCategoryEnum.tool.includes(category))
|
||||
invalidateAllToolProviders()
|
||||
|
||||
trackEvent('plugin_uninstalled', { plugin_id, plugin_name: name })
|
||||
}
|
||||
}, [
|
||||
id,
|
||||
category,
|
||||
plugin_id,
|
||||
name,
|
||||
modalStates,
|
||||
onUpdate,
|
||||
refreshModelProviders,
|
||||
invalidateAllToolProviders,
|
||||
])
|
||||
|
||||
return {
|
||||
handleUpdate,
|
||||
handleUpdatedFromMarketplace,
|
||||
handleDelete,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,286 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginDetail } from '../../types'
|
||||
import {
|
||||
RiArrowLeftRightLine,
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth'
|
||||
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
|
||||
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGetLanguage, useLocale } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useAllToolProviders } from '@/service/use-tools'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { AutoUpdateLine } from '../../../base/icons/src/vender/system'
|
||||
import Verified from '../../base/badges/verified'
|
||||
import DeprecationNotice from '../../base/deprecation-notice'
|
||||
import Icon from '../../card/base/card-icon'
|
||||
import Description from '../../card/base/description'
|
||||
import OrgInfo from '../../card/base/org-info'
|
||||
import Title from '../../card/base/title'
|
||||
import useReferenceSetting from '../../plugin-page/use-reference-setting'
|
||||
import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../../reference-setting-modal/auto-update-setting/utils'
|
||||
import { PluginCategoryEnum, PluginSource } from '../../types'
|
||||
import { HeaderModals, PluginSourceBadge } from './components'
|
||||
import { useDetailHeaderState, usePluginOperations } from './hooks'
|
||||
|
||||
type Props = {
|
||||
detail: PluginDetail
|
||||
isReadmeView?: boolean
|
||||
onHide?: () => void
|
||||
onUpdate?: (isDelete?: boolean) => void
|
||||
}
|
||||
|
||||
const getIconSrc = (icon: string | undefined, iconDark: string | undefined, theme: string, tenantId: string): string => {
|
||||
const iconFileName = theme === 'dark' && iconDark ? iconDark : icon
|
||||
if (!iconFileName)
|
||||
return ''
|
||||
return iconFileName.startsWith('http')
|
||||
? iconFileName
|
||||
: `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenantId}&filename=${iconFileName}`
|
||||
}
|
||||
|
||||
const getDetailUrl = (
|
||||
source: PluginSource,
|
||||
meta: PluginDetail['meta'],
|
||||
author: string,
|
||||
name: string,
|
||||
locale: string,
|
||||
theme: string,
|
||||
): string => {
|
||||
if (source === PluginSource.github) {
|
||||
const repo = meta?.repo
|
||||
if (!repo)
|
||||
return ''
|
||||
return `https://github.com/${repo}`
|
||||
}
|
||||
if (source === PluginSource.marketplace)
|
||||
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme })
|
||||
return ''
|
||||
}
|
||||
|
||||
const DetailHeader = ({
|
||||
detail,
|
||||
isReadmeView = false,
|
||||
onHide,
|
||||
onUpdate,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile: { timezone } } = useAppContext()
|
||||
const { theme } = useTheme()
|
||||
const locale = useGetLanguage()
|
||||
const currentLocale = useLocale()
|
||||
const { referenceSetting } = useReferenceSetting()
|
||||
|
||||
const {
|
||||
source,
|
||||
tenant_id,
|
||||
version,
|
||||
latest_version,
|
||||
latest_unique_identifier,
|
||||
meta,
|
||||
plugin_id,
|
||||
status,
|
||||
deprecated_reason,
|
||||
alternative_plugin_id,
|
||||
} = detail
|
||||
|
||||
const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail
|
||||
|
||||
const {
|
||||
modalStates,
|
||||
versionPicker,
|
||||
hasNewVersion,
|
||||
isAutoUpgradeEnabled,
|
||||
isFromGitHub,
|
||||
isFromMarketplace,
|
||||
} = useDetailHeaderState(detail)
|
||||
|
||||
const {
|
||||
handleUpdate,
|
||||
handleUpdatedFromMarketplace,
|
||||
handleDelete,
|
||||
} = usePluginOperations({
|
||||
detail,
|
||||
modalStates,
|
||||
versionPicker,
|
||||
isFromMarketplace,
|
||||
onUpdate,
|
||||
})
|
||||
|
||||
const isTool = category === PluginCategoryEnum.tool
|
||||
const providerBriefInfo = tool?.identity
|
||||
const providerKey = `${plugin_id}/${providerBriefInfo?.name}`
|
||||
const { data: collectionList = [] } = useAllToolProviders(isTool)
|
||||
const provider = useMemo(() => {
|
||||
return collectionList.find(collection => collection.name === providerKey)
|
||||
}, [collectionList, providerKey])
|
||||
|
||||
const iconSrc = getIconSrc(icon, icon_dark, theme, tenant_id)
|
||||
const detailUrl = getDetailUrl(source, meta, author, name, currentLocale, theme)
|
||||
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
|
||||
|
||||
const handleVersionSelect = (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => {
|
||||
versionPicker.setTargetVersion(state)
|
||||
handleUpdate(state.isDowngrade)
|
||||
}
|
||||
|
||||
const handleTriggerLatestUpdate = () => {
|
||||
if (isFromMarketplace) {
|
||||
versionPicker.setTargetVersion({
|
||||
version: latest_version,
|
||||
unique_identifier: latest_unique_identifier,
|
||||
})
|
||||
}
|
||||
handleUpdate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}>
|
||||
<div className="flex">
|
||||
{/* Plugin Icon */}
|
||||
<div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}>
|
||||
<Icon src={iconSrc} />
|
||||
</div>
|
||||
|
||||
{/* Plugin Info */}
|
||||
<div className="ml-3 w-0 grow">
|
||||
{/* Title Row */}
|
||||
<div className="flex h-5 items-center">
|
||||
<Title title={label[locale]} />
|
||||
{verified && !isReadmeView && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />}
|
||||
|
||||
{/* Version Picker */}
|
||||
{!!version && (
|
||||
<PluginVersionPicker
|
||||
disabled={!isFromMarketplace || isReadmeView}
|
||||
isShow={versionPicker.isShow}
|
||||
onShowChange={versionPicker.setIsShow}
|
||||
pluginID={plugin_id}
|
||||
currentVersion={version}
|
||||
onSelect={handleVersionSelect}
|
||||
trigger={(
|
||||
<Badge
|
||||
className={cn(
|
||||
'mx-1',
|
||||
versionPicker.isShow && 'bg-state-base-hover',
|
||||
(versionPicker.isShow || isFromMarketplace) && 'hover:bg-state-base-hover',
|
||||
)}
|
||||
uppercase={false}
|
||||
text={(
|
||||
<>
|
||||
<div>{isFromGitHub ? (meta?.version ?? version ?? '') : version}</div>
|
||||
{isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />}
|
||||
</>
|
||||
)}
|
||||
hasRedCornerMark={hasNewVersion}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Auto Update Badge */}
|
||||
{isAutoUpgradeEnabled && !isReadmeView && (
|
||||
<Tooltip popupContent={t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}>
|
||||
<div>
|
||||
<Badge className="mr-1 cursor-pointer px-1">
|
||||
<AutoUpdateLine className="size-3" />
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Update Button */}
|
||||
{(hasNewVersion || isFromGitHub) && (
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
className="!h-5"
|
||||
onClick={handleTriggerLatestUpdate}
|
||||
>
|
||||
{t('detailPanel.operation.update', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Org Info Row */}
|
||||
<div className="mb-1 flex h-4 items-center justify-between">
|
||||
<div className="mt-0.5 flex items-center">
|
||||
<OrgInfo
|
||||
packageNameClassName="w-auto"
|
||||
orgName={author}
|
||||
packageName={name?.includes('/') ? (name.split('/').pop() || '') : name}
|
||||
/>
|
||||
{!!source && <PluginSourceBadge source={source} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{!isReadmeView && (
|
||||
<div className="flex gap-1">
|
||||
<OperationDropdown
|
||||
source={source}
|
||||
onInfo={modalStates.showPluginInfo}
|
||||
onCheckVersion={handleUpdate}
|
||||
onRemove={modalStates.showDeleteConfirm}
|
||||
detailUrl={detailUrl}
|
||||
/>
|
||||
<ActionButton onClick={onHide}>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deprecation Notice */}
|
||||
{isFromMarketplace && (
|
||||
<DeprecationNotice
|
||||
status={status}
|
||||
deprecatedReason={deprecated_reason}
|
||||
alternativePluginId={alternative_plugin_id}
|
||||
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{!isReadmeView && <Description className="mb-2 mt-3 h-auto" text={description[locale]} descriptionLineRows={2} />}
|
||||
|
||||
{/* Plugin Auth for Tools */}
|
||||
{category === PluginCategoryEnum.tool && !isReadmeView && (
|
||||
<PluginAuth
|
||||
pluginPayload={{
|
||||
provider: provider?.name || '',
|
||||
category: AuthCategory.tool,
|
||||
providerType: provider?.type || '',
|
||||
detail,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<HeaderModals
|
||||
detail={detail}
|
||||
modalStates={modalStates}
|
||||
targetVersion={versionPicker.targetVersion}
|
||||
isDowngrade={versionPicker.isDowngrade}
|
||||
isAutoUpgradeEnabled={isAutoUpgradeEnabled}
|
||||
onUpdatedFromMarketplace={handleUpdatedFromMarketplace}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetailHeader
|
||||
@ -1,16 +1,11 @@
|
||||
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// Import after mocks
|
||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { CommonCreateModal } from './common-modal'
|
||||
|
||||
// ============================================================================
|
||||
// Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
type PluginDetail = {
|
||||
plugin_id: string
|
||||
provider: string
|
||||
@ -33,10 +28,6 @@ type TriggerLogEntity = {
|
||||
level: 'info' | 'warn' | 'error'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Factory Functions
|
||||
// ============================================================================
|
||||
|
||||
function createMockPluginDetail(overrides: Partial<PluginDetail> = {}): PluginDetail {
|
||||
return {
|
||||
plugin_id: 'test-plugin-id',
|
||||
@ -74,18 +65,12 @@ function createMockLogData(logs: TriggerLogEntity[] = []): { logs: TriggerLogEnt
|
||||
return { logs }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
// Mock plugin store
|
||||
const mockPluginDetail = createMockPluginDetail()
|
||||
const mockUsePluginStore = vi.fn(() => mockPluginDetail)
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => mockUsePluginStore(),
|
||||
}))
|
||||
|
||||
// Mock subscription list hook
|
||||
const mockRefetch = vi.fn()
|
||||
vi.mock('../use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({
|
||||
@ -93,13 +78,11 @@ vi.mock('../use-subscription-list', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
const mockVerifyCredentials = vi.fn()
|
||||
const mockCreateBuilder = vi.fn()
|
||||
const mockBuildSubscription = vi.fn()
|
||||
const mockUpdateBuilder = vi.fn()
|
||||
|
||||
// Configurable pending states
|
||||
let mockIsVerifyingCredentials = false
|
||||
let mockIsBuilding = false
|
||||
const setMockPendingStates = (verifying: boolean, building: boolean) => {
|
||||
@ -129,18 +112,15 @@ vi.mock('@/service/use-triggers', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock error parser
|
||||
const mockParsePluginErrorMessage = vi.fn().mockResolvedValue(null)
|
||||
vi.mock('@/utils/error-parser', () => ({
|
||||
parsePluginErrorMessage: (...args: unknown[]) => mockParsePluginErrorMessage(...args),
|
||||
}))
|
||||
|
||||
// Mock URL validation
|
||||
vi.mock('@/utils/urlValidation', () => ({
|
||||
isPrivateOrLocalAddress: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
// Mock toast
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
@ -148,7 +128,6 @@ vi.mock('@/app/components/base/toast', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Modal component
|
||||
vi.mock('@/app/components/base/modal/modal', () => ({
|
||||
default: ({
|
||||
children,
|
||||
@ -179,7 +158,6 @@ vi.mock('@/app/components/base/modal/modal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Configurable form mock values
|
||||
type MockFormValuesConfig = {
|
||||
values: Record<string, unknown>
|
||||
isCheckValidated: boolean
|
||||
@ -190,7 +168,6 @@ let mockFormValuesConfig: MockFormValuesConfig = {
|
||||
}
|
||||
let mockGetFormReturnsNull = false
|
||||
|
||||
// Separate validation configs for different forms
|
||||
let mockSubscriptionFormValidated = true
|
||||
let mockAutoParamsFormValidated = true
|
||||
let mockManualPropsFormValidated = true
|
||||
@ -207,7 +184,6 @@ const setMockFormValidation = (subscription: boolean, autoParams: boolean, manua
|
||||
mockManualPropsFormValidated = manualProps
|
||||
}
|
||||
|
||||
// Mock BaseForm component with ref support
|
||||
vi.mock('@/app/components/base/form/components/base', async () => {
|
||||
const React = await import('react')
|
||||
|
||||
@ -219,7 +195,6 @@ vi.mock('@/app/components/base/form/components/base', async () => {
|
||||
type MockBaseFormProps = { formSchemas: Array<{ name: string }>, onChange?: () => void }
|
||||
|
||||
function MockBaseFormInner({ formSchemas, onChange }: MockBaseFormProps, ref: React.ForwardedRef<MockFormRef>) {
|
||||
// Determine which form this is based on schema
|
||||
const isSubscriptionForm = formSchemas.some((s: { name: string }) => s.name === 'subscription_name')
|
||||
const isAutoParamsForm = formSchemas.some((s: { name: string }) =>
|
||||
['repo_name', 'branch', 'repo', 'text_field', 'dynamic_field', 'bool_field', 'text_input_field', 'unknown_field', 'count'].includes(s.name),
|
||||
@ -265,12 +240,10 @@ vi.mock('@/app/components/base/form/components/base', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Mock EncryptedBottom component
|
||||
vi.mock('@/app/components/base/encrypted-bottom', () => ({
|
||||
EncryptedBottom: () => <div data-testid="encrypted-bottom">Encrypted</div>,
|
||||
}))
|
||||
|
||||
// Mock LogViewer component
|
||||
vi.mock('../log-viewer', () => ({
|
||||
default: ({ logs }: { logs: TriggerLogEntity[] }) => (
|
||||
<div data-testid="log-viewer">
|
||||
@ -281,7 +254,6 @@ vi.mock('../log-viewer', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock debounce
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
debounce: (fn: (...args: unknown[]) => unknown) => {
|
||||
const debouncedFn = (...args: unknown[]) => fn(...args)
|
||||
@ -290,10 +262,6 @@ vi.mock('es-toolkit/compat', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Suites
|
||||
// ============================================================================
|
||||
|
||||
describe('CommonCreateModal', () => {
|
||||
const defaultProps = {
|
||||
onClose: vi.fn(),
|
||||
@ -441,7 +409,8 @@ describe('CommonCreateModal', () => {
|
||||
})
|
||||
|
||||
it('should call onConfirm handler when confirm button is clicked', () => {
|
||||
render(<CommonCreateModal {...defaultProps} />)
|
||||
// Provide builder so the guard passes and credentials check is reached
|
||||
render(<CommonCreateModal {...defaultProps} builder={createMockSubscriptionBuilder()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-confirm'))
|
||||
|
||||
@ -821,6 +790,9 @@ describe('CommonCreateModal', () => {
|
||||
expect(mockCreateBuilder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Flush pending state updates from createBuilder promise resolution
|
||||
await act(async () => {})
|
||||
|
||||
const input = screen.getByTestId('form-field-webhook_url')
|
||||
fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
|
||||
|
||||
@ -1240,13 +1212,22 @@ describe('CommonCreateModal', () => {
|
||||
|
||||
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />)
|
||||
|
||||
// Wait for createBuilder to complete and state to update
|
||||
await waitFor(() => {
|
||||
expect(mockCreateBuilder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Allow React to process the state update from createBuilder
|
||||
await act(async () => {})
|
||||
|
||||
const input = screen.getByTestId('form-field-webhook_url')
|
||||
fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
|
||||
|
||||
// Wait for updateBuilder to be called, then check the toast
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateBuilder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
@ -1447,7 +1428,8 @@ describe('CommonCreateModal', () => {
|
||||
})
|
||||
mockUsePluginStore.mockReturnValue(detailWithCredentials)
|
||||
|
||||
render(<CommonCreateModal {...defaultProps} />)
|
||||
// Provide builder so the guard passes and credentials check is reached
|
||||
render(<CommonCreateModal {...defaultProps} builder={createMockSubscriptionBuilder()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-confirm'))
|
||||
|
||||
|
||||
@ -1,32 +1,19 @@
|
||||
'use client'
|
||||
import type { FormRefObject } from '@/app/components/base/form/types'
|
||||
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
// import { CopyFeedbackNew } from '@/app/components/base/copy-feedback'
|
||||
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
|
||||
import { BaseForm } from '@/app/components/base/form/components/base'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import {
|
||||
useBuildTriggerSubscription,
|
||||
useCreateTriggerSubscriptionBuilder,
|
||||
useTriggerSubscriptionBuilderLogs,
|
||||
useUpdateTriggerSubscriptionBuilder,
|
||||
useVerifyAndUpdateTriggerSubscriptionBuilder,
|
||||
} from '@/service/use-triggers'
|
||||
import { parsePluginErrorMessage } from '@/utils/error-parser'
|
||||
import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
|
||||
import { usePluginStore } from '../../store'
|
||||
import LogViewer from '../log-viewer'
|
||||
import { useSubscriptionList } from '../use-subscription-list'
|
||||
ConfigurationStepContent,
|
||||
MultiSteps,
|
||||
VerifyStepContent,
|
||||
} from './components/modal-steps'
|
||||
import {
|
||||
ApiKeyStep,
|
||||
MODAL_TITLE_KEY_MAP,
|
||||
useCommonModalState,
|
||||
} from './hooks/use-common-modal-state'
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
@ -34,316 +21,33 @@ type Props = {
|
||||
builder?: TriggerSubscriptionBuilder
|
||||
}
|
||||
|
||||
const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = {
|
||||
[SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey,
|
||||
[SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2,
|
||||
[SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized,
|
||||
}
|
||||
|
||||
const MODAL_TITLE_KEY_MAP: Record<
|
||||
SupportedCreationMethods,
|
||||
'modal.apiKey.title' | 'modal.oauth.title' | 'modal.manual.title'
|
||||
> = {
|
||||
[SupportedCreationMethods.APIKEY]: 'modal.apiKey.title',
|
||||
[SupportedCreationMethods.OAUTH]: 'modal.oauth.title',
|
||||
[SupportedCreationMethods.MANUAL]: 'modal.manual.title',
|
||||
}
|
||||
|
||||
enum ApiKeyStep {
|
||||
Verify = 'verify',
|
||||
Configuration = 'configuration',
|
||||
}
|
||||
|
||||
const defaultFormValues = { values: {}, isCheckValidated: false }
|
||||
|
||||
const normalizeFormType = (type: FormTypeEnum | string): FormTypeEnum => {
|
||||
if (Object.values(FormTypeEnum).includes(type as FormTypeEnum))
|
||||
return type as FormTypeEnum
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
case 'text':
|
||||
return FormTypeEnum.textInput
|
||||
case 'password':
|
||||
case 'secret':
|
||||
return FormTypeEnum.secretInput
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return FormTypeEnum.textNumber
|
||||
case 'boolean':
|
||||
return FormTypeEnum.boolean
|
||||
default:
|
||||
return FormTypeEnum.textInput
|
||||
}
|
||||
}
|
||||
|
||||
const StatusStep = ({ isActive, text }: { isActive: boolean, text: string }) => {
|
||||
return (
|
||||
<div className={`system-2xs-semibold-uppercase flex items-center gap-1 ${isActive
|
||||
? 'text-state-accent-solid'
|
||||
: 'text-text-tertiary'}`}
|
||||
>
|
||||
{/* Active indicator dot */}
|
||||
{isActive && (
|
||||
<div className="h-1 w-1 rounded-full bg-state-accent-solid"></div>
|
||||
)}
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MultiSteps = ({ currentStep }: { currentStep: ApiKeyStep }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="mb-6 flex w-1/3 items-center gap-2">
|
||||
<StatusStep isActive={currentStep === ApiKeyStep.Verify} text={t('modal.steps.verify', { ns: 'pluginTrigger' })} />
|
||||
<div className="h-px w-3 shrink-0 bg-divider-deep"></div>
|
||||
<StatusStep isActive={currentStep === ApiKeyStep.Configuration} text={t('modal.steps.configuration', { ns: 'pluginTrigger' })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const detail = usePluginStore(state => state.detail)
|
||||
const { refetch } = useSubscriptionList()
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<ApiKeyStep>(createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration)
|
||||
const {
|
||||
currentStep,
|
||||
subscriptionBuilder,
|
||||
isVerifyingCredentials,
|
||||
isBuilding,
|
||||
formRefs,
|
||||
detail,
|
||||
manualPropertiesSchema,
|
||||
autoCommonParametersSchema,
|
||||
apiKeyCredentialsSchema,
|
||||
logData,
|
||||
confirmButtonText,
|
||||
handleConfirm,
|
||||
handleManualPropertiesChange,
|
||||
handleApiKeyCredentialsChange,
|
||||
} = useCommonModalState({
|
||||
createType,
|
||||
builder,
|
||||
onClose,
|
||||
})
|
||||
|
||||
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder)
|
||||
const isInitializedRef = useRef(false)
|
||||
|
||||
const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyAndUpdateTriggerSubscriptionBuilder()
|
||||
const { mutateAsync: createBuilder /* isPending: isCreatingBuilder */ } = useCreateTriggerSubscriptionBuilder()
|
||||
const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription()
|
||||
const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder()
|
||||
|
||||
const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || [] // manual
|
||||
const manualPropertiesFormRef = React.useRef<FormRefObject>(null)
|
||||
|
||||
const subscriptionFormRef = React.useRef<FormRefObject>(null)
|
||||
|
||||
const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || [] // apikey and oauth
|
||||
const autoCommonParametersFormRef = React.useRef<FormRefObject>(null)
|
||||
|
||||
const apiKeyCredentialsSchema = useMemo(() => {
|
||||
const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || []
|
||||
return rawSchema.map(schema => ({
|
||||
...schema,
|
||||
tooltip: schema.help,
|
||||
}))
|
||||
}, [detail?.declaration?.trigger?.subscription_constructor?.credentials_schema])
|
||||
const apiKeyCredentialsFormRef = React.useRef<FormRefObject>(null)
|
||||
|
||||
const { data: logData } = useTriggerSubscriptionBuilderLogs(
|
||||
detail?.provider || '',
|
||||
subscriptionBuilder?.id || '',
|
||||
{
|
||||
enabled: createType === SupportedCreationMethods.MANUAL,
|
||||
refetchInterval: 3000,
|
||||
},
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const initializeBuilder = async () => {
|
||||
isInitializedRef.current = true
|
||||
try {
|
||||
const response = await createBuilder({
|
||||
provider: detail?.provider || '',
|
||||
credential_type: CREDENTIAL_TYPE_MAP[createType],
|
||||
})
|
||||
setSubscriptionBuilder(response.subscription_builder)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('createBuilder error:', error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('modal.errors.createFailed', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider)
|
||||
initializeBuilder()
|
||||
}, [subscriptionBuilder, detail?.provider, createType, createBuilder, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (subscriptionBuilder?.endpoint && subscriptionFormRef.current && currentStep === ApiKeyStep.Configuration) {
|
||||
const form = subscriptionFormRef.current.getForm()
|
||||
if (form)
|
||||
form.setFieldValue('callback_url', subscriptionBuilder.endpoint)
|
||||
if (isPrivateOrLocalAddress(subscriptionBuilder.endpoint)) {
|
||||
console.warn('callback_url is private or local address', subscriptionBuilder.endpoint)
|
||||
subscriptionFormRef.current?.setFields([{
|
||||
name: 'callback_url',
|
||||
warnings: [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })],
|
||||
}])
|
||||
}
|
||||
else {
|
||||
subscriptionFormRef.current?.setFields([{
|
||||
name: 'callback_url',
|
||||
warnings: [],
|
||||
}])
|
||||
}
|
||||
}
|
||||
}, [subscriptionBuilder?.endpoint, currentStep, t])
|
||||
|
||||
const debouncedUpdate = useMemo(
|
||||
() => debounce((provider: string, builderId: string, properties: Record<string, unknown>) => {
|
||||
updateBuilder(
|
||||
{
|
||||
provider,
|
||||
subscriptionBuilderId: builderId,
|
||||
properties,
|
||||
},
|
||||
{
|
||||
onError: async (error: unknown) => {
|
||||
const errorMessage = await parsePluginErrorMessage(error) || t('modal.errors.updateFailed', { ns: 'pluginTrigger' })
|
||||
console.error('Failed to update subscription builder:', error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
}, 500),
|
||||
[updateBuilder, t],
|
||||
)
|
||||
|
||||
const handleManualPropertiesChange = useCallback(() => {
|
||||
if (!subscriptionBuilder || !detail?.provider)
|
||||
return
|
||||
|
||||
const formValues = manualPropertiesFormRef.current?.getFormValues({ needCheckValidatedValues: false }) || { values: {}, isCheckValidated: true }
|
||||
|
||||
debouncedUpdate(detail.provider, subscriptionBuilder.id, formValues.values)
|
||||
}, [subscriptionBuilder, detail?.provider, debouncedUpdate])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedUpdate.cancel()
|
||||
}
|
||||
}, [debouncedUpdate])
|
||||
|
||||
const handleVerify = () => {
|
||||
const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || defaultFormValues
|
||||
const credentials = apiKeyCredentialsFormValues.values
|
||||
|
||||
if (!Object.keys(credentials).length) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Please fill in all required credentials',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
apiKeyCredentialsFormRef.current?.setFields([{
|
||||
name: Object.keys(credentials)[0],
|
||||
errors: [],
|
||||
}])
|
||||
|
||||
verifyCredentials(
|
||||
{
|
||||
provider: detail?.provider || '',
|
||||
subscriptionBuilderId: subscriptionBuilder?.id || '',
|
||||
credentials,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('modal.apiKey.verify.success', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
setCurrentStep(ApiKeyStep.Configuration)
|
||||
},
|
||||
onError: async (error: unknown) => {
|
||||
const errorMessage = await parsePluginErrorMessage(error) || t('modal.apiKey.verify.error', { ns: 'pluginTrigger' })
|
||||
apiKeyCredentialsFormRef.current?.setFields([{
|
||||
name: Object.keys(credentials)[0],
|
||||
errors: [errorMessage],
|
||||
}])
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!subscriptionBuilder) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Subscription builder not found',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({})
|
||||
if (!subscriptionFormValues?.isCheckValidated)
|
||||
return
|
||||
|
||||
const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string
|
||||
|
||||
const params: BuildTriggerSubscriptionPayload = {
|
||||
provider: detail?.provider || '',
|
||||
subscriptionBuilderId: subscriptionBuilder.id,
|
||||
name: subscriptionNameValue,
|
||||
}
|
||||
|
||||
if (createType !== SupportedCreationMethods.MANUAL) {
|
||||
if (autoCommonParametersSchema.length > 0) {
|
||||
const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || defaultFormValues
|
||||
if (!autoCommonParametersFormValues?.isCheckValidated)
|
||||
return
|
||||
params.parameters = autoCommonParametersFormValues.values
|
||||
}
|
||||
}
|
||||
else if (manualPropertiesSchema.length > 0) {
|
||||
const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || defaultFormValues
|
||||
if (!manualFormValues?.isCheckValidated)
|
||||
return
|
||||
}
|
||||
|
||||
buildSubscription(
|
||||
params,
|
||||
{
|
||||
onSuccess: () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('subscription.createSuccess', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
onClose()
|
||||
refetch?.()
|
||||
},
|
||||
onError: async (error: unknown) => {
|
||||
const errorMessage = await parsePluginErrorMessage(error) || t('subscription.createFailed', { ns: 'pluginTrigger' })
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (currentStep === ApiKeyStep.Verify)
|
||||
handleVerify()
|
||||
else
|
||||
handleCreate()
|
||||
}
|
||||
|
||||
const handleApiKeyCredentialsChange = () => {
|
||||
apiKeyCredentialsFormRef.current?.setFields([{
|
||||
name: apiKeyCredentialsSchema[0].name,
|
||||
errors: [],
|
||||
}])
|
||||
}
|
||||
|
||||
const confirmButtonText = useMemo(() => {
|
||||
if (currentStep === ApiKeyStep.Verify)
|
||||
return isVerifyingCredentials ? t('modal.common.verifying', { ns: 'pluginTrigger' }) : t('modal.common.verify', { ns: 'pluginTrigger' })
|
||||
|
||||
return isBuilding ? t('modal.common.creating', { ns: 'pluginTrigger' }) : t('modal.common.create', { ns: 'pluginTrigger' })
|
||||
}, [currentStep, isVerifyingCredentials, isBuilding, t])
|
||||
const isApiKeyType = createType === SupportedCreationMethods.APIKEY
|
||||
const isVerifyStep = currentStep === ApiKeyStep.Verify
|
||||
const isConfigurationStep = currentStep === ApiKeyStep.Configuration
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -353,121 +57,36 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
|
||||
onCancel={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
disabled={isVerifyingCredentials || isBuilding}
|
||||
bottomSlot={currentStep === ApiKeyStep.Verify ? <EncryptedBottom /> : null}
|
||||
bottomSlot={isVerifyStep ? <EncryptedBottom /> : null}
|
||||
size={createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'}
|
||||
containerClassName="min-h-[360px]"
|
||||
clickOutsideNotClose
|
||||
>
|
||||
{createType === SupportedCreationMethods.APIKEY && <MultiSteps currentStep={currentStep} />}
|
||||
{currentStep === ApiKeyStep.Verify && (
|
||||
<>
|
||||
{apiKeyCredentialsSchema.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<BaseForm
|
||||
formSchemas={apiKeyCredentialsSchema}
|
||||
ref={apiKeyCredentialsFormRef}
|
||||
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
||||
preventDefaultSubmit={true}
|
||||
formClassName="space-y-4"
|
||||
onChange={handleApiKeyCredentialsChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentStep === ApiKeyStep.Configuration && (
|
||||
<div className="max-h-[70vh]">
|
||||
<BaseForm
|
||||
formSchemas={[
|
||||
{
|
||||
name: 'subscription_name',
|
||||
label: t('modal.form.subscriptionName.label', { ns: 'pluginTrigger' }),
|
||||
placeholder: t('modal.form.subscriptionName.placeholder', { ns: 'pluginTrigger' }),
|
||||
type: FormTypeEnum.textInput,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'callback_url',
|
||||
label: t('modal.form.callbackUrl.label', { ns: 'pluginTrigger' }),
|
||||
placeholder: t('modal.form.callbackUrl.placeholder', { ns: 'pluginTrigger' }),
|
||||
type: FormTypeEnum.textInput,
|
||||
required: false,
|
||||
default: subscriptionBuilder?.endpoint || '',
|
||||
disabled: true,
|
||||
tooltip: t('modal.form.callbackUrl.tooltip', { ns: 'pluginTrigger' }),
|
||||
showCopy: true,
|
||||
},
|
||||
]}
|
||||
ref={subscriptionFormRef}
|
||||
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
||||
formClassName="space-y-4 mb-4"
|
||||
/>
|
||||
{/* <div className='system-xs-regular mb-6 mt-[-1rem] text-text-tertiary'>
|
||||
{t('pluginTrigger.modal.form.callbackUrl.description')}
|
||||
</div> */}
|
||||
{createType !== SupportedCreationMethods.MANUAL && autoCommonParametersSchema.length > 0 && (
|
||||
<BaseForm
|
||||
formSchemas={autoCommonParametersSchema.map((schema) => {
|
||||
const normalizedType = normalizeFormType(schema.type as FormTypeEnum | string)
|
||||
return {
|
||||
...schema,
|
||||
tooltip: schema.description,
|
||||
type: normalizedType,
|
||||
dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect
|
||||
? {
|
||||
plugin_id: detail?.plugin_id || '',
|
||||
provider: detail?.provider || '',
|
||||
action: 'provider',
|
||||
parameter: schema.name,
|
||||
credential_id: subscriptionBuilder?.id || '',
|
||||
}
|
||||
: undefined,
|
||||
fieldClassName: schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined,
|
||||
labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined,
|
||||
}
|
||||
})}
|
||||
ref={autoCommonParametersFormRef}
|
||||
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
||||
formClassName="space-y-4"
|
||||
/>
|
||||
)}
|
||||
{createType === SupportedCreationMethods.MANUAL && (
|
||||
<>
|
||||
{manualPropertiesSchema.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<BaseForm
|
||||
formSchemas={manualPropertiesSchema.map(schema => ({
|
||||
...schema,
|
||||
tooltip: schema.description,
|
||||
}))}
|
||||
ref={manualPropertiesFormRef}
|
||||
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
||||
formClassName="space-y-4"
|
||||
onChange={handleManualPropertiesChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">
|
||||
{t('modal.manual.logs.title', { ns: 'pluginTrigger' })}
|
||||
</div>
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-divider-regular to-transparent" />
|
||||
</div>
|
||||
{isApiKeyType && <MultiSteps currentStep={currentStep} />}
|
||||
|
||||
<div className="mb-1 flex items-center justify-center gap-1 rounded-lg bg-background-section p-3">
|
||||
<div className="h-3.5 w-3.5">
|
||||
<RiLoader2Line className="h-full w-full animate-spin" />
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('modal.manual.logs.loading', { ns: 'pluginTrigger', pluginName: detail?.name || '' })}
|
||||
</div>
|
||||
</div>
|
||||
<LogViewer logs={logData?.logs || []} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isVerifyStep && (
|
||||
<VerifyStepContent
|
||||
apiKeyCredentialsSchema={apiKeyCredentialsSchema}
|
||||
apiKeyCredentialsFormRef={formRefs.apiKeyCredentialsFormRef}
|
||||
onChange={handleApiKeyCredentialsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isConfigurationStep && (
|
||||
<ConfigurationStepContent
|
||||
createType={createType}
|
||||
subscriptionBuilder={subscriptionBuilder}
|
||||
subscriptionFormRef={formRefs.subscriptionFormRef}
|
||||
autoCommonParametersSchema={autoCommonParametersSchema}
|
||||
autoCommonParametersFormRef={formRefs.autoCommonParametersFormRef}
|
||||
manualPropertiesSchema={manualPropertiesSchema}
|
||||
manualPropertiesFormRef={formRefs.manualPropertiesFormRef}
|
||||
onManualPropertiesChange={handleManualPropertiesChange}
|
||||
logs={logData?.logs || []}
|
||||
pluginId={detail?.plugin_id || ''}
|
||||
pluginName={detail?.name || ''}
|
||||
provider={detail?.provider || ''}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
|
||||
import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BaseForm } from '@/app/components/base/form/components/base'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import LogViewer from '../../log-viewer'
|
||||
import { ApiKeyStep } from '../hooks/use-common-modal-state'
|
||||
|
||||
export type SchemaItem = Partial<FormSchema> & Record<string, unknown> & {
|
||||
name: string
|
||||
}
|
||||
|
||||
type StatusStepProps = {
|
||||
isActive: boolean
|
||||
text: string
|
||||
}
|
||||
|
||||
export const StatusStep = ({ isActive, text }: StatusStepProps) => {
|
||||
return (
|
||||
<div className={`system-2xs-semibold-uppercase flex items-center gap-1 ${isActive
|
||||
? 'text-state-accent-solid'
|
||||
: 'text-text-tertiary'}`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="h-1 w-1 rounded-full bg-state-accent-solid"></div>
|
||||
)}
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MultiStepsProps = {
|
||||
currentStep: ApiKeyStep
|
||||
}
|
||||
|
||||
export const MultiSteps = ({ currentStep }: MultiStepsProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="mb-6 flex w-1/3 items-center gap-2">
|
||||
<StatusStep isActive={currentStep === ApiKeyStep.Verify} text={t('modal.steps.verify', { ns: 'pluginTrigger' })} />
|
||||
<div className="h-px w-3 shrink-0 bg-divider-deep"></div>
|
||||
<StatusStep isActive={currentStep === ApiKeyStep.Configuration} text={t('modal.steps.configuration', { ns: 'pluginTrigger' })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type VerifyStepContentProps = {
|
||||
apiKeyCredentialsSchema: SchemaItem[]
|
||||
apiKeyCredentialsFormRef: React.RefObject<FormRefObject | null>
|
||||
onChange: () => void
|
||||
}
|
||||
|
||||
export const VerifyStepContent = ({
|
||||
apiKeyCredentialsSchema,
|
||||
apiKeyCredentialsFormRef,
|
||||
onChange,
|
||||
}: VerifyStepContentProps) => {
|
||||
if (!apiKeyCredentialsSchema.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<BaseForm
|
||||
formSchemas={apiKeyCredentialsSchema as FormSchema[]}
|
||||
ref={apiKeyCredentialsFormRef}
|
||||
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
||||
preventDefaultSubmit={true}
|
||||
formClassName="space-y-4"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SubscriptionFormProps = {
|
||||
subscriptionFormRef: React.RefObject<FormRefObject | null>
|
||||
endpoint?: string
|
||||
}
|
||||
|
||||
export const SubscriptionForm = ({
|
||||
subscriptionFormRef,
|
||||
endpoint,
|
||||
}: SubscriptionFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const formSchemas = React.useMemo(() => [
|
||||
{
|
||||
name: 'subscription_name',
|
||||
label: t('modal.form.subscriptionName.label', { ns: 'pluginTrigger' }),
|
||||
placeholder: t('modal.form.subscriptionName.placeholder', { ns: 'pluginTrigger' }),
|
||||
type: FormTypeEnum.textInput,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'callback_url',
|
||||
label: t('modal.form.callbackUrl.label', { ns: 'pluginTrigger' }),
|
||||
placeholder: t('modal.form.callbackUrl.placeholder', { ns: 'pluginTrigger' }),
|
||||
type: FormTypeEnum.textInput,
|
||||
required: false,
|
||||
default: endpoint || '',
|
||||
disabled: true,
|
||||
tooltip: t('modal.form.callbackUrl.tooltip', { ns: 'pluginTrigger' }),
|
||||
showCopy: true,
|
||||
},
|
||||
], [endpoint, t])
|
||||
|
||||
return (
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
ref={subscriptionFormRef}
|
||||
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
||||
formClassName="space-y-4 mb-4"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const normalizeFormType = (type: FormTypeEnum | string): FormTypeEnum => {
|
||||
if (Object.values(FormTypeEnum).includes(type as FormTypeEnum))
|
||||
return type as FormTypeEnum
|
||||
|
||||
const TYPE_MAP: Record<string, FormTypeEnum> = {
|
||||
string: FormTypeEnum.textInput,
|
||||
text: FormTypeEnum.textInput,
|
||||
password: FormTypeEnum.secretInput,
|
||||
secret: FormTypeEnum.secretInput,
|
||||
number: FormTypeEnum.textNumber,
|
||||
integer: FormTypeEnum.textNumber,
|
||||
boolean: FormTypeEnum.boolean,
|
||||
}
|
||||
|
||||
return TYPE_MAP[type] || FormTypeEnum.textInput
|
||||
}
|
||||
|
||||
type AutoParametersFormProps = {
|
||||
schemas: SchemaItem[]
|
||||
formRef: React.RefObject<FormRefObject | null>
|
||||
pluginId: string
|
||||
provider: string
|
||||
credentialId: string
|
||||
}
|
||||
|
||||
export const AutoParametersForm = ({
|
||||
schemas,
|
||||
formRef,
|
||||
pluginId,
|
||||
provider,
|
||||
credentialId,
|
||||
}: AutoParametersFormProps) => {
|
||||
const formSchemas = React.useMemo(() =>
|
||||
schemas.map((schema) => {
|
||||
const normalizedType = normalizeFormType((schema.type || FormTypeEnum.textInput) as FormTypeEnum | string)
|
||||
return {
|
||||
...schema,
|
||||
tooltip: schema.description,
|
||||
type: normalizedType,
|
||||
dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect
|
||||
? {
|
||||
plugin_id: pluginId,
|
||||
provider,
|
||||
action: 'provider',
|
||||
parameter: schema.name,
|
||||
credential_id: credentialId,
|
||||
}
|
||||
: undefined,
|
||||
fieldClassName: normalizedType === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined,
|
||||
labelClassName: normalizedType === FormTypeEnum.boolean ? 'mb-0' : undefined,
|
||||
}
|
||||
}) as FormSchema[], [schemas, pluginId, provider, credentialId])
|
||||
|
||||
if (!schemas.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
ref={formRef}
|
||||
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
||||
formClassName="space-y-4"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ManualPropertiesSectionProps = {
|
||||
schemas: SchemaItem[]
|
||||
formRef: React.RefObject<FormRefObject | null>
|
||||
onChange: () => void
|
||||
logs: TriggerLogEntity[]
|
||||
pluginName: string
|
||||
}
|
||||
|
||||
export const ManualPropertiesSection = ({
|
||||
schemas,
|
||||
formRef,
|
||||
onChange,
|
||||
logs,
|
||||
pluginName,
|
||||
}: ManualPropertiesSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const formSchemas = React.useMemo(() =>
|
||||
schemas.map(schema => ({
|
||||
...schema,
|
||||
tooltip: schema.description,
|
||||
})) as FormSchema[], [schemas])
|
||||
|
||||
return (
|
||||
<>
|
||||
{schemas.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
ref={formRef}
|
||||
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
|
||||
formClassName="space-y-4"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">
|
||||
{t('modal.manual.logs.title', { ns: 'pluginTrigger' })}
|
||||
</div>
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-divider-regular to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center justify-center gap-1 rounded-lg bg-background-section p-3">
|
||||
<div className="h-3.5 w-3.5">
|
||||
<RiLoader2Line className="h-full w-full animate-spin" />
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('modal.manual.logs.loading', { ns: 'pluginTrigger', pluginName })}
|
||||
</div>
|
||||
</div>
|
||||
<LogViewer logs={logs} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ConfigurationStepContentProps = {
|
||||
createType: SupportedCreationMethods
|
||||
subscriptionBuilder?: TriggerSubscriptionBuilder
|
||||
subscriptionFormRef: React.RefObject<FormRefObject | null>
|
||||
autoCommonParametersSchema: SchemaItem[]
|
||||
autoCommonParametersFormRef: React.RefObject<FormRefObject | null>
|
||||
manualPropertiesSchema: SchemaItem[]
|
||||
manualPropertiesFormRef: React.RefObject<FormRefObject | null>
|
||||
onManualPropertiesChange: () => void
|
||||
logs: TriggerLogEntity[]
|
||||
pluginId: string
|
||||
pluginName: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export const ConfigurationStepContent = ({
|
||||
createType,
|
||||
subscriptionBuilder,
|
||||
subscriptionFormRef,
|
||||
autoCommonParametersSchema,
|
||||
autoCommonParametersFormRef,
|
||||
manualPropertiesSchema,
|
||||
manualPropertiesFormRef,
|
||||
onManualPropertiesChange,
|
||||
logs,
|
||||
pluginId,
|
||||
pluginName,
|
||||
provider,
|
||||
}: ConfigurationStepContentProps) => {
|
||||
const isManualType = createType === SupportedCreationMethods.MANUAL
|
||||
|
||||
return (
|
||||
<div className="max-h-[70vh]">
|
||||
<SubscriptionForm
|
||||
subscriptionFormRef={subscriptionFormRef}
|
||||
endpoint={subscriptionBuilder?.endpoint}
|
||||
/>
|
||||
|
||||
{!isManualType && autoCommonParametersSchema.length > 0 && (
|
||||
<AutoParametersForm
|
||||
schemas={autoCommonParametersSchema}
|
||||
formRef={autoCommonParametersFormRef}
|
||||
pluginId={pluginId}
|
||||
provider={provider}
|
||||
credentialId={subscriptionBuilder?.id || ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isManualType && (
|
||||
<ManualPropertiesSection
|
||||
schemas={manualPropertiesSchema}
|
||||
formRef={manualPropertiesFormRef}
|
||||
onChange={onManualPropertiesChange}
|
||||
logs={logs}
|
||||
pluginName={pluginName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,401 @@
|
||||
'use client'
|
||||
import type { SimpleDetail } from '../../../store'
|
||||
import type { SchemaItem } from '../components/modal-steps'
|
||||
import type { FormRefObject } from '@/app/components/base/form/types'
|
||||
import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import {
|
||||
useBuildTriggerSubscription,
|
||||
useCreateTriggerSubscriptionBuilder,
|
||||
useTriggerSubscriptionBuilderLogs,
|
||||
useUpdateTriggerSubscriptionBuilder,
|
||||
useVerifyAndUpdateTriggerSubscriptionBuilder,
|
||||
} from '@/service/use-triggers'
|
||||
import { parsePluginErrorMessage } from '@/utils/error-parser'
|
||||
import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
|
||||
import { usePluginStore } from '../../../store'
|
||||
import { useSubscriptionList } from '../../use-subscription-list'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export enum ApiKeyStep {
|
||||
Verify = 'verify',
|
||||
Configuration = 'configuration',
|
||||
}
|
||||
|
||||
export const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = {
|
||||
[SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey,
|
||||
[SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2,
|
||||
[SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized,
|
||||
}
|
||||
|
||||
export const MODAL_TITLE_KEY_MAP: Record<
|
||||
SupportedCreationMethods,
|
||||
'modal.apiKey.title' | 'modal.oauth.title' | 'modal.manual.title'
|
||||
> = {
|
||||
[SupportedCreationMethods.APIKEY]: 'modal.apiKey.title',
|
||||
[SupportedCreationMethods.OAUTH]: 'modal.oauth.title',
|
||||
[SupportedCreationMethods.MANUAL]: 'modal.manual.title',
|
||||
}
|
||||
|
||||
type UseCommonModalStateParams = {
|
||||
createType: SupportedCreationMethods
|
||||
builder?: TriggerSubscriptionBuilder
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type FormRefs = {
|
||||
manualPropertiesFormRef: React.RefObject<FormRefObject | null>
|
||||
subscriptionFormRef: React.RefObject<FormRefObject | null>
|
||||
autoCommonParametersFormRef: React.RefObject<FormRefObject | null>
|
||||
apiKeyCredentialsFormRef: React.RefObject<FormRefObject | null>
|
||||
}
|
||||
|
||||
type UseCommonModalStateReturn = {
|
||||
// State
|
||||
currentStep: ApiKeyStep
|
||||
subscriptionBuilder: TriggerSubscriptionBuilder | undefined
|
||||
isVerifyingCredentials: boolean
|
||||
isBuilding: boolean
|
||||
|
||||
// Form refs
|
||||
formRefs: FormRefs
|
||||
|
||||
// Computed values
|
||||
detail: SimpleDetail | undefined
|
||||
manualPropertiesSchema: SchemaItem[]
|
||||
autoCommonParametersSchema: SchemaItem[]
|
||||
apiKeyCredentialsSchema: SchemaItem[]
|
||||
logData: { logs: TriggerLogEntity[] } | undefined
|
||||
confirmButtonText: string
|
||||
|
||||
// Handlers
|
||||
handleVerify: () => void
|
||||
handleCreate: () => void
|
||||
handleConfirm: () => void
|
||||
handleManualPropertiesChange: () => void
|
||||
handleApiKeyCredentialsChange: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_FORM_VALUES = { values: {}, isCheckValidated: false }
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
|
||||
export const useCommonModalState = ({
|
||||
createType,
|
||||
builder,
|
||||
onClose,
|
||||
}: UseCommonModalStateParams): UseCommonModalStateReturn => {
|
||||
const { t } = useTranslation()
|
||||
const detail = usePluginStore(state => state.detail)
|
||||
const { refetch } = useSubscriptionList()
|
||||
|
||||
// State
|
||||
const [currentStep, setCurrentStep] = useState<ApiKeyStep>(
|
||||
createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration,
|
||||
)
|
||||
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder)
|
||||
const isInitializedRef = useRef(false)
|
||||
|
||||
// Form refs
|
||||
const manualPropertiesFormRef = useRef<FormRefObject>(null)
|
||||
const subscriptionFormRef = useRef<FormRefObject>(null)
|
||||
const autoCommonParametersFormRef = useRef<FormRefObject>(null)
|
||||
const apiKeyCredentialsFormRef = useRef<FormRefObject>(null)
|
||||
|
||||
// Mutations
|
||||
const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyAndUpdateTriggerSubscriptionBuilder()
|
||||
const { mutateAsync: createBuilder } = useCreateTriggerSubscriptionBuilder()
|
||||
const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription()
|
||||
const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder()
|
||||
|
||||
// Schemas
|
||||
const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || []
|
||||
const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || []
|
||||
|
||||
const apiKeyCredentialsSchema = useMemo(() => {
|
||||
const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || []
|
||||
return rawSchema.map(schema => ({
|
||||
...schema,
|
||||
tooltip: schema.help,
|
||||
}))
|
||||
}, [detail?.declaration?.trigger?.subscription_constructor?.credentials_schema])
|
||||
|
||||
// Log data for manual mode
|
||||
const { data: logData } = useTriggerSubscriptionBuilderLogs(
|
||||
detail?.provider || '',
|
||||
subscriptionBuilder?.id || '',
|
||||
{
|
||||
enabled: createType === SupportedCreationMethods.MANUAL,
|
||||
refetchInterval: 3000,
|
||||
},
|
||||
)
|
||||
|
||||
// Debounced update for manual properties
|
||||
const debouncedUpdate = useMemo(
|
||||
() => debounce((provider: string, builderId: string, properties: Record<string, unknown>) => {
|
||||
updateBuilder(
|
||||
{
|
||||
provider,
|
||||
subscriptionBuilderId: builderId,
|
||||
properties,
|
||||
},
|
||||
{
|
||||
onError: async (error: unknown) => {
|
||||
const errorMessage = await parsePluginErrorMessage(error) || t('modal.errors.updateFailed', { ns: 'pluginTrigger' })
|
||||
console.error('Failed to update subscription builder:', error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
}, 500),
|
||||
[updateBuilder, t],
|
||||
)
|
||||
|
||||
// Initialize builder
|
||||
useEffect(() => {
|
||||
const initializeBuilder = async () => {
|
||||
isInitializedRef.current = true
|
||||
try {
|
||||
const response = await createBuilder({
|
||||
provider: detail?.provider || '',
|
||||
credential_type: CREDENTIAL_TYPE_MAP[createType],
|
||||
})
|
||||
setSubscriptionBuilder(response.subscription_builder)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('createBuilder error:', error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('modal.errors.createFailed', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider)
|
||||
initializeBuilder()
|
||||
}, [subscriptionBuilder, detail?.provider, createType, createBuilder, t])
|
||||
|
||||
// Cleanup debounced function
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedUpdate.cancel()
|
||||
}
|
||||
}, [debouncedUpdate])
|
||||
|
||||
// Update endpoint in form when endpoint changes
|
||||
useEffect(() => {
|
||||
if (!subscriptionBuilder?.endpoint || !subscriptionFormRef.current || currentStep !== ApiKeyStep.Configuration)
|
||||
return
|
||||
|
||||
const form = subscriptionFormRef.current.getForm()
|
||||
if (form)
|
||||
form.setFieldValue('callback_url', subscriptionBuilder.endpoint)
|
||||
|
||||
const warnings = isPrivateOrLocalAddress(subscriptionBuilder.endpoint)
|
||||
? [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })]
|
||||
: []
|
||||
|
||||
subscriptionFormRef.current?.setFields([{
|
||||
name: 'callback_url',
|
||||
warnings,
|
||||
}])
|
||||
}, [subscriptionBuilder?.endpoint, currentStep, t])
|
||||
|
||||
// Handle manual properties change
|
||||
const handleManualPropertiesChange = useCallback(() => {
|
||||
if (!subscriptionBuilder || !detail?.provider)
|
||||
return
|
||||
|
||||
const formValues = manualPropertiesFormRef.current?.getFormValues({ needCheckValidatedValues: false })
|
||||
|| { values: {}, isCheckValidated: true }
|
||||
|
||||
debouncedUpdate(detail.provider, subscriptionBuilder.id, formValues.values)
|
||||
}, [subscriptionBuilder, detail?.provider, debouncedUpdate])
|
||||
|
||||
// Handle API key credentials change
|
||||
const handleApiKeyCredentialsChange = useCallback(() => {
|
||||
if (!apiKeyCredentialsSchema.length)
|
||||
return
|
||||
apiKeyCredentialsFormRef.current?.setFields([{
|
||||
name: apiKeyCredentialsSchema[0].name,
|
||||
errors: [],
|
||||
}])
|
||||
}, [apiKeyCredentialsSchema])
|
||||
|
||||
// Handle verify
|
||||
const handleVerify = useCallback(() => {
|
||||
// Guard against uninitialized state
|
||||
if (!detail?.provider || !subscriptionBuilder?.id) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Subscription builder not initialized',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
|
||||
const credentials = apiKeyCredentialsFormValues.values
|
||||
|
||||
if (!Object.keys(credentials).length) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Please fill in all required credentials',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
apiKeyCredentialsFormRef.current?.setFields([{
|
||||
name: Object.keys(credentials)[0],
|
||||
errors: [],
|
||||
}])
|
||||
|
||||
verifyCredentials(
|
||||
{
|
||||
provider: detail.provider,
|
||||
subscriptionBuilderId: subscriptionBuilder.id,
|
||||
credentials,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('modal.apiKey.verify.success', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
setCurrentStep(ApiKeyStep.Configuration)
|
||||
},
|
||||
onError: async (error: unknown) => {
|
||||
const errorMessage = await parsePluginErrorMessage(error) || t('modal.apiKey.verify.error', { ns: 'pluginTrigger' })
|
||||
apiKeyCredentialsFormRef.current?.setFields([{
|
||||
name: Object.keys(credentials)[0],
|
||||
errors: [errorMessage],
|
||||
}])
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [detail?.provider, subscriptionBuilder?.id, verifyCredentials, t])
|
||||
|
||||
// Handle create
|
||||
const handleCreate = useCallback(() => {
|
||||
if (!subscriptionBuilder) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Subscription builder not found',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({})
|
||||
if (!subscriptionFormValues?.isCheckValidated)
|
||||
return
|
||||
|
||||
const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string
|
||||
|
||||
const params: BuildTriggerSubscriptionPayload = {
|
||||
provider: detail?.provider || '',
|
||||
subscriptionBuilderId: subscriptionBuilder.id,
|
||||
name: subscriptionNameValue,
|
||||
}
|
||||
|
||||
if (createType !== SupportedCreationMethods.MANUAL) {
|
||||
if (autoCommonParametersSchema.length > 0) {
|
||||
const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
|
||||
if (!autoCommonParametersFormValues?.isCheckValidated)
|
||||
return
|
||||
params.parameters = autoCommonParametersFormValues.values
|
||||
}
|
||||
}
|
||||
else if (manualPropertiesSchema.length > 0) {
|
||||
const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
|
||||
if (!manualFormValues?.isCheckValidated)
|
||||
return
|
||||
}
|
||||
|
||||
buildSubscription(
|
||||
params,
|
||||
{
|
||||
onSuccess: () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('subscription.createSuccess', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
onClose()
|
||||
refetch?.()
|
||||
},
|
||||
onError: async (error: unknown) => {
|
||||
const errorMessage = await parsePluginErrorMessage(error) || t('subscription.createFailed', { ns: 'pluginTrigger' })
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [
|
||||
subscriptionBuilder,
|
||||
detail?.provider,
|
||||
createType,
|
||||
autoCommonParametersSchema.length,
|
||||
manualPropertiesSchema.length,
|
||||
buildSubscription,
|
||||
onClose,
|
||||
refetch,
|
||||
t,
|
||||
])
|
||||
|
||||
// Handle confirm (dispatch based on step)
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (currentStep === ApiKeyStep.Verify)
|
||||
handleVerify()
|
||||
else
|
||||
handleCreate()
|
||||
}, [currentStep, handleVerify, handleCreate])
|
||||
|
||||
// Confirm button text
|
||||
const confirmButtonText = useMemo(() => {
|
||||
if (currentStep === ApiKeyStep.Verify) {
|
||||
return isVerifyingCredentials
|
||||
? t('modal.common.verifying', { ns: 'pluginTrigger' })
|
||||
: t('modal.common.verify', { ns: 'pluginTrigger' })
|
||||
}
|
||||
return isBuilding
|
||||
? t('modal.common.creating', { ns: 'pluginTrigger' })
|
||||
: t('modal.common.create', { ns: 'pluginTrigger' })
|
||||
}, [currentStep, isVerifyingCredentials, isBuilding, t])
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
subscriptionBuilder,
|
||||
isVerifyingCredentials,
|
||||
isBuilding,
|
||||
formRefs: {
|
||||
manualPropertiesFormRef,
|
||||
subscriptionFormRef,
|
||||
autoCommonParametersFormRef,
|
||||
apiKeyCredentialsFormRef,
|
||||
},
|
||||
detail,
|
||||
manualPropertiesSchema,
|
||||
autoCommonParametersSchema,
|
||||
apiKeyCredentialsSchema,
|
||||
logData,
|
||||
confirmButtonText,
|
||||
handleVerify,
|
||||
handleCreate,
|
||||
handleConfirm,
|
||||
handleManualPropertiesChange,
|
||||
handleApiKeyCredentialsChange,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,719 @@
|
||||
import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import {
|
||||
AuthorizationStatusEnum,
|
||||
ClientTypeEnum,
|
||||
getErrorMessage,
|
||||
useOAuthClientState,
|
||||
} from './use-oauth-client-state'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Factory Functions
|
||||
// ============================================================================
|
||||
|
||||
function createMockOAuthConfig(overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig {
|
||||
return {
|
||||
configured: true,
|
||||
custom_configured: false,
|
||||
custom_enabled: false,
|
||||
system_configured: true,
|
||||
redirect_uri: 'https://example.com/oauth/callback',
|
||||
params: {
|
||||
client_id: 'default-client-id',
|
||||
client_secret: 'default-client-secret',
|
||||
},
|
||||
oauth_client_schema: [
|
||||
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
|
||||
{ name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown },
|
||||
] as TriggerOAuthConfig['oauth_client_schema'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBuilder> = {}): TriggerSubscriptionBuilder {
|
||||
return {
|
||||
id: 'builder-123',
|
||||
name: 'Test Builder',
|
||||
provider: 'test-provider',
|
||||
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com/callback',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
const mockInitiateOAuth = vi.fn()
|
||||
const mockVerifyBuilder = vi.fn()
|
||||
const mockConfigureOAuth = vi.fn()
|
||||
const mockDeleteOAuth = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useInitiateTriggerOAuth: () => ({
|
||||
mutate: mockInitiateOAuth,
|
||||
}),
|
||||
useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({
|
||||
mutate: mockVerifyBuilder,
|
||||
}),
|
||||
useConfigureTriggerOAuth: () => ({
|
||||
mutate: mockConfigureOAuth,
|
||||
}),
|
||||
useDeleteTriggerOAuth: () => ({
|
||||
mutate: mockDeleteOAuth,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockOpenOAuthPopup = vi.fn()
|
||||
vi.mock('@/hooks/use-oauth', () => ({
|
||||
openOAuthPopup: (url: string, callback: (data: unknown) => void) => mockOpenOAuthPopup(url, callback),
|
||||
}))
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (params: unknown) => mockToastNotify(params),
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Suites
|
||||
// ============================================================================
|
||||
|
||||
describe('getErrorMessage', () => {
|
||||
it('should extract message from Error instance', () => {
|
||||
const error = new Error('Test error message')
|
||||
expect(getErrorMessage(error, 'fallback')).toBe('Test error message')
|
||||
})
|
||||
|
||||
it('should extract message from object with message property', () => {
|
||||
const error = { message: 'Object error message' }
|
||||
expect(getErrorMessage(error, 'fallback')).toBe('Object error message')
|
||||
})
|
||||
|
||||
it('should return fallback when error is empty object', () => {
|
||||
expect(getErrorMessage({}, 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
it('should return fallback when error.message is not a string', () => {
|
||||
expect(getErrorMessage({ message: 123 }, 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
it('should return fallback when error.message is empty string', () => {
|
||||
expect(getErrorMessage({ message: '' }, 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
it('should return fallback when error is null', () => {
|
||||
expect(getErrorMessage(null, 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
it('should return fallback when error is undefined', () => {
|
||||
expect(getErrorMessage(undefined, 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
it('should return fallback when error is a primitive', () => {
|
||||
expect(getErrorMessage('string error', 'fallback')).toBe('fallback')
|
||||
expect(getErrorMessage(123, 'fallback')).toBe('fallback')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOAuthClientState', () => {
|
||||
const defaultParams = {
|
||||
oauthConfig: createMockOAuthConfig(),
|
||||
providerName: 'test-provider',
|
||||
onClose: vi.fn(),
|
||||
showOAuthCreateModal: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should default to Default client type when system_configured is true', () => {
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
expect(result.current.clientType).toBe(ClientTypeEnum.Default)
|
||||
})
|
||||
|
||||
it('should default to Custom client type when system_configured is false', () => {
|
||||
const config = createMockOAuthConfig({ system_configured: false })
|
||||
const { result } = renderHook(() => useOAuthClientState({
|
||||
...defaultParams,
|
||||
oauthConfig: config,
|
||||
}))
|
||||
|
||||
expect(result.current.clientType).toBe(ClientTypeEnum.Custom)
|
||||
})
|
||||
|
||||
it('should have undefined authorizationStatus initially', () => {
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
expect(result.current.authorizationStatus).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should provide clientFormRef', () => {
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
expect(result.current.clientFormRef).toBeDefined()
|
||||
expect(result.current.clientFormRef.current).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuth Client Schema', () => {
|
||||
it('should compute schema with default values from params', () => {
|
||||
const config = createMockOAuthConfig({
|
||||
params: {
|
||||
client_id: 'my-client-id',
|
||||
client_secret: 'my-secret',
|
||||
},
|
||||
})
|
||||
const { result } = renderHook(() => useOAuthClientState({
|
||||
...defaultParams,
|
||||
oauthConfig: config,
|
||||
}))
|
||||
|
||||
expect(result.current.oauthClientSchema).toHaveLength(2)
|
||||
expect(result.current.oauthClientSchema[0].default).toBe('my-client-id')
|
||||
expect(result.current.oauthClientSchema[1].default).toBe('my-secret')
|
||||
})
|
||||
|
||||
it('should return empty array when oauth_client_schema is empty', () => {
|
||||
const config = createMockOAuthConfig({
|
||||
oauth_client_schema: [],
|
||||
})
|
||||
const { result } = renderHook(() => useOAuthClientState({
|
||||
...defaultParams,
|
||||
oauthConfig: config,
|
||||
}))
|
||||
|
||||
expect(result.current.oauthClientSchema).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array when params is undefined', () => {
|
||||
const config = createMockOAuthConfig({
|
||||
params: undefined as unknown as TriggerOAuthConfig['params'],
|
||||
})
|
||||
const { result } = renderHook(() => useOAuthClientState({
|
||||
...defaultParams,
|
||||
oauthConfig: config,
|
||||
}))
|
||||
|
||||
expect(result.current.oauthClientSchema).toEqual([])
|
||||
})
|
||||
|
||||
it('should preserve original schema default when param key not found', () => {
|
||||
const config = createMockOAuthConfig({
|
||||
params: {
|
||||
client_id: 'only-client-id',
|
||||
client_secret: '', // empty
|
||||
},
|
||||
oauth_client_schema: [
|
||||
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: {} as unknown, default: 'original-default' },
|
||||
{ name: 'extra_field', type: 'text-input' as unknown, required: false, label: {} as unknown, default: 'extra-default' },
|
||||
] as TriggerOAuthConfig['oauth_client_schema'],
|
||||
})
|
||||
const { result } = renderHook(() => useOAuthClientState({
|
||||
...defaultParams,
|
||||
oauthConfig: config,
|
||||
}))
|
||||
|
||||
// client_id should be overridden
|
||||
expect(result.current.oauthClientSchema[0].default).toBe('only-client-id')
|
||||
// extra_field should keep original default since key not in params
|
||||
expect(result.current.oauthClientSchema[1].default).toBe('extra-default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Confirm Button Text', () => {
|
||||
it('should show saveAndAuth text by default', () => {
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
expect(result.current.confirmButtonText).toBe('plugin.auth.saveAndAuth')
|
||||
})
|
||||
|
||||
it('should show authorizing text when status is Pending', async () => {
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
mockInitiateOAuth.mockImplementation(() => {
|
||||
// Don't resolve - stays pending
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.confirmButtonText).toBe('pluginTrigger.modal.common.authorizing')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('setClientType', () => {
|
||||
it('should update client type when called', () => {
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.setClientType(ClientTypeEnum.Custom)
|
||||
})
|
||||
|
||||
expect(result.current.clientType).toBe(ClientTypeEnum.Custom)
|
||||
})
|
||||
|
||||
it('should toggle between client types', () => {
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.setClientType(ClientTypeEnum.Custom)
|
||||
})
|
||||
expect(result.current.clientType).toBe(ClientTypeEnum.Custom)
|
||||
|
||||
act(() => {
|
||||
result.current.setClientType(ClientTypeEnum.Default)
|
||||
})
|
||||
expect(result.current.clientType).toBe(ClientTypeEnum.Default)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRemove', () => {
|
||||
it('should call deleteOAuth with provider name', () => {
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.handleRemove()
|
||||
})
|
||||
|
||||
expect(mockDeleteOAuth).toHaveBeenCalledWith(
|
||||
'test-provider',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onClose and show success toast on success', () => {
|
||||
mockDeleteOAuth.mockImplementation((provider, { onSuccess }) => onSuccess())
|
||||
|
||||
const onClose = vi.fn()
|
||||
const { result } = renderHook(() => useOAuthClientState({
|
||||
...defaultParams,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleRemove()
|
||||
})
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'pluginTrigger.modal.oauth.remove.success',
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast with error message on failure', () => {
|
||||
mockDeleteOAuth.mockImplementation((provider, { onError }) => {
|
||||
onError(new Error('Delete failed'))
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.handleRemove()
|
||||
})
|
||||
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Delete failed',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSave', () => {
|
||||
it('should call configureOAuth with enabled: false for Default type', () => {
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(false)
|
||||
})
|
||||
|
||||
expect(mockConfigureOAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'test-provider',
|
||||
enabled: false,
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call configureOAuth with enabled: true for Custom type', () => {
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
|
||||
const config = createMockOAuthConfig({ system_configured: false })
|
||||
const { result } = renderHook(() => useOAuthClientState({
|
||||
...defaultParams,
|
||||
oauthConfig: config,
|
||||
}))
|
||||
|
||||
// Mock the form ref
|
||||
const mockFormRef = {
|
||||
getFormValues: () => ({
|
||||
values: { client_id: 'new-id', client_secret: 'new-secret' },
|
||||
isCheckValidated: true,
|
||||
}),
|
||||
}
|
||||
// @ts-expect-error - mocking ref
|
||||
result.current.clientFormRef.current = mockFormRef
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(false)
|
||||
})
|
||||
|
||||
expect(mockConfigureOAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show success toast and call onClose when needAuth is false', () => {
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
const onClose = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState({
|
||||
...defaultParams,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(false)
|
||||
})
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'pluginTrigger.modal.oauth.save.success',
|
||||
})
|
||||
})
|
||||
|
||||
it('should trigger authorization when needAuth is true', () => {
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||
onSuccess({
|
||||
authorization_url: 'https://oauth.example.com/authorize',
|
||||
subscription_builder: createMockSubscriptionBuilder(),
|
||||
})
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(true)
|
||||
})
|
||||
|
||||
expect(mockInitiateOAuth).toHaveBeenCalledWith(
|
||||
'test-provider',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleAuthorization', () => {
|
||||
it('should set status to Pending and call initiateOAuth', () => {
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
mockInitiateOAuth.mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(true)
|
||||
})
|
||||
|
||||
expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Pending)
|
||||
expect(mockInitiateOAuth).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open OAuth popup on success', () => {
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||
onSuccess({
|
||||
authorization_url: 'https://oauth.example.com/authorize',
|
||||
subscription_builder: createMockSubscriptionBuilder(),
|
||||
})
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(true)
|
||||
})
|
||||
|
||||
expect(mockOpenOAuthPopup).toHaveBeenCalledWith(
|
||||
'https://oauth.example.com/authorize',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('should set status to Failed and show error toast on error', () => {
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
mockInitiateOAuth.mockImplementation((provider, { onError }) => {
|
||||
onError(new Error('OAuth failed'))
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(true)
|
||||
})
|
||||
|
||||
expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Failed)
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'pluginTrigger.modal.oauth.authorization.authFailed',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onClose and showOAuthCreateModal on callback success', () => {
|
||||
const onClose = vi.fn()
|
||||
const showOAuthCreateModal = vi.fn()
|
||||
const builder = createMockSubscriptionBuilder()
|
||||
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||
onSuccess({
|
||||
authorization_url: 'https://oauth.example.com/authorize',
|
||||
subscription_builder: builder,
|
||||
})
|
||||
})
|
||||
mockOpenOAuthPopup.mockImplementation((url, callback) => {
|
||||
callback({ success: true })
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState({
|
||||
...defaultParams,
|
||||
onClose,
|
||||
showOAuthCreateModal,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(true)
|
||||
})
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
expect(showOAuthCreateModal).toHaveBeenCalledWith(builder)
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'pluginTrigger.modal.oauth.authorization.authSuccess',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call callbacks when OAuth callback returns falsy', () => {
|
||||
const onClose = vi.fn()
|
||||
const showOAuthCreateModal = vi.fn()
|
||||
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||
onSuccess({
|
||||
authorization_url: 'https://oauth.example.com/authorize',
|
||||
subscription_builder: createMockSubscriptionBuilder(),
|
||||
})
|
||||
})
|
||||
mockOpenOAuthPopup.mockImplementation((url, callback) => {
|
||||
callback(null)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState({
|
||||
...defaultParams,
|
||||
onClose,
|
||||
showOAuthCreateModal,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(true)
|
||||
})
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(showOAuthCreateModal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Polling Effect', () => {
|
||||
it('should start polling after authorization starts', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||
onSuccess({
|
||||
authorization_url: 'https://oauth.example.com/authorize',
|
||||
subscription_builder: createMockSubscriptionBuilder(),
|
||||
})
|
||||
})
|
||||
mockVerifyBuilder.mockImplementation((params, { onSuccess }) => {
|
||||
onSuccess({ verified: false })
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(true)
|
||||
})
|
||||
|
||||
// Advance timer to trigger first poll
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000)
|
||||
})
|
||||
|
||||
expect(mockVerifyBuilder).toHaveBeenCalled()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should set status to Success when verified', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||
onSuccess({
|
||||
authorization_url: 'https://oauth.example.com/authorize',
|
||||
subscription_builder: createMockSubscriptionBuilder(),
|
||||
})
|
||||
})
|
||||
mockVerifyBuilder.mockImplementation((params, { onSuccess }) => {
|
||||
onSuccess({ verified: true })
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(true)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Success)
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should continue polling on error', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||
onSuccess({
|
||||
authorization_url: 'https://oauth.example.com/authorize',
|
||||
subscription_builder: createMockSubscriptionBuilder(),
|
||||
})
|
||||
})
|
||||
mockVerifyBuilder.mockImplementation((params, { onError }) => {
|
||||
onError(new Error('Verify failed'))
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(true)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000)
|
||||
})
|
||||
|
||||
expect(mockVerifyBuilder).toHaveBeenCalled()
|
||||
// Status should still be Pending
|
||||
expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Pending)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should stop polling when verified', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
|
||||
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
|
||||
onSuccess({
|
||||
authorization_url: 'https://oauth.example.com/authorize',
|
||||
subscription_builder: createMockSubscriptionBuilder(),
|
||||
})
|
||||
})
|
||||
mockVerifyBuilder.mockImplementation((params, { onSuccess }) => {
|
||||
onSuccess({ verified: true })
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOAuthClientState(defaultParams))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave(true)
|
||||
})
|
||||
|
||||
// First poll - should verify
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000)
|
||||
})
|
||||
|
||||
expect(mockVerifyBuilder).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second poll - should not happen as interval is cleared
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000)
|
||||
})
|
||||
|
||||
// Still only 1 call because polling stopped
|
||||
expect(mockVerifyBuilder).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined oauthConfig', () => {
|
||||
const { result } = renderHook(() => useOAuthClientState({
|
||||
...defaultParams,
|
||||
oauthConfig: undefined,
|
||||
}))
|
||||
|
||||
expect(result.current.clientType).toBe(ClientTypeEnum.Custom)
|
||||
expect(result.current.oauthClientSchema).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle empty providerName', () => {
|
||||
const { result } = renderHook(() => useOAuthClientState({
|
||||
...defaultParams,
|
||||
providerName: '',
|
||||
}))
|
||||
|
||||
// Should not throw
|
||||
expect(result.current.clientType).toBe(ClientTypeEnum.Default)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enum Exports', () => {
|
||||
it('should export AuthorizationStatusEnum', () => {
|
||||
expect(AuthorizationStatusEnum.Pending).toBe('pending')
|
||||
expect(AuthorizationStatusEnum.Success).toBe('success')
|
||||
expect(AuthorizationStatusEnum.Failed).toBe('failed')
|
||||
})
|
||||
|
||||
it('should export ClientTypeEnum', () => {
|
||||
expect(ClientTypeEnum.Default).toBe('default')
|
||||
expect(ClientTypeEnum.Custom).toBe('custom')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
import type { FormRefObject } from '@/app/components/base/form/types'
|
||||
import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import type { ConfigureTriggerOAuthPayload } from '@/service/use-triggers'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||
import {
|
||||
useConfigureTriggerOAuth,
|
||||
useDeleteTriggerOAuth,
|
||||
useInitiateTriggerOAuth,
|
||||
useVerifyAndUpdateTriggerSubscriptionBuilder,
|
||||
} from '@/service/use-triggers'
|
||||
|
||||
export enum AuthorizationStatusEnum {
|
||||
Pending = 'pending',
|
||||
Success = 'success',
|
||||
Failed = 'failed',
|
||||
}
|
||||
|
||||
export enum ClientTypeEnum {
|
||||
Default = 'default',
|
||||
Custom = 'custom',
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
|
||||
// Extract error message from various error formats
|
||||
export const getErrorMessage = (error: unknown, fallback: string): string => {
|
||||
if (error instanceof Error && error.message)
|
||||
return error.message
|
||||
if (typeof error === 'object' && error && 'message' in error) {
|
||||
const message = (error as { message?: string }).message
|
||||
if (typeof message === 'string' && message)
|
||||
return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
type UseOAuthClientStateParams = {
|
||||
oauthConfig?: TriggerOAuthConfig
|
||||
providerName: string
|
||||
onClose: () => void
|
||||
showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void
|
||||
}
|
||||
|
||||
type UseOAuthClientStateReturn = {
|
||||
// State
|
||||
clientType: ClientTypeEnum
|
||||
setClientType: (type: ClientTypeEnum) => void
|
||||
authorizationStatus: AuthorizationStatusEnum | undefined
|
||||
|
||||
// Refs
|
||||
clientFormRef: React.RefObject<FormRefObject | null>
|
||||
|
||||
// Computed values
|
||||
oauthClientSchema: TriggerOAuthConfig['oauth_client_schema']
|
||||
confirmButtonText: string
|
||||
|
||||
// Handlers
|
||||
handleAuthorization: () => void
|
||||
handleRemove: () => void
|
||||
handleSave: (needAuth: boolean) => void
|
||||
}
|
||||
|
||||
export const useOAuthClientState = ({
|
||||
oauthConfig,
|
||||
providerName,
|
||||
onClose,
|
||||
showOAuthCreateModal,
|
||||
}: UseOAuthClientStateParams): UseOAuthClientStateReturn => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// State management
|
||||
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>()
|
||||
const [authorizationStatus, setAuthorizationStatus] = useState<AuthorizationStatusEnum>()
|
||||
const [clientType, setClientType] = useState<ClientTypeEnum>(
|
||||
oauthConfig?.system_configured ? ClientTypeEnum.Default : ClientTypeEnum.Custom,
|
||||
)
|
||||
|
||||
const clientFormRef = useRef<FormRefObject>(null)
|
||||
|
||||
// Mutations
|
||||
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
|
||||
const { mutate: verifyBuilder } = useVerifyAndUpdateTriggerSubscriptionBuilder()
|
||||
const { mutate: configureOAuth } = useConfigureTriggerOAuth()
|
||||
const { mutate: deleteOAuth } = useDeleteTriggerOAuth()
|
||||
|
||||
// Compute OAuth client schema with default values
|
||||
const oauthClientSchema = useMemo(() => {
|
||||
const { oauth_client_schema, params } = oauthConfig || {}
|
||||
if (!oauth_client_schema?.length || !params)
|
||||
return []
|
||||
|
||||
const paramKeys = Object.keys(params)
|
||||
return oauth_client_schema.map(schema => ({
|
||||
...schema,
|
||||
default: paramKeys.includes(schema.name) ? params[schema.name] : schema.default,
|
||||
}))
|
||||
}, [oauthConfig])
|
||||
|
||||
// Compute confirm button text based on authorization status
|
||||
const confirmButtonText = useMemo(() => {
|
||||
if (authorizationStatus === AuthorizationStatusEnum.Pending)
|
||||
return t('modal.common.authorizing', { ns: 'pluginTrigger' })
|
||||
if (authorizationStatus === AuthorizationStatusEnum.Success)
|
||||
return t('modal.oauth.authorization.waitingJump', { ns: 'pluginTrigger' })
|
||||
return t('auth.saveAndAuth', { ns: 'plugin' })
|
||||
}, [authorizationStatus, t])
|
||||
|
||||
// Authorization handler
|
||||
const handleAuthorization = useCallback(() => {
|
||||
setAuthorizationStatus(AuthorizationStatusEnum.Pending)
|
||||
initiateOAuth(providerName, {
|
||||
onSuccess: (response) => {
|
||||
setSubscriptionBuilder(response.subscription_builder)
|
||||
openOAuthPopup(response.authorization_url, (callbackData) => {
|
||||
if (!callbackData)
|
||||
return
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('modal.oauth.authorization.authSuccess', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
onClose()
|
||||
showOAuthCreateModal(response.subscription_builder)
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
setAuthorizationStatus(AuthorizationStatusEnum.Failed)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('modal.oauth.authorization.authFailed', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [providerName, initiateOAuth, onClose, showOAuthCreateModal, t])
|
||||
|
||||
// Remove handler
|
||||
const handleRemove = useCallback(() => {
|
||||
deleteOAuth(providerName, {
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('modal.oauth.remove.success', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: getErrorMessage(error, t('modal.oauth.remove.failed', { ns: 'pluginTrigger' })),
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [providerName, deleteOAuth, onClose, t])
|
||||
|
||||
// Save handler
|
||||
const handleSave = useCallback((needAuth: boolean) => {
|
||||
const isCustom = clientType === ClientTypeEnum.Custom
|
||||
const params: ConfigureTriggerOAuthPayload = {
|
||||
provider: providerName,
|
||||
enabled: isCustom,
|
||||
}
|
||||
|
||||
if (isCustom && oauthClientSchema?.length) {
|
||||
const clientFormValues = clientFormRef.current?.getFormValues({}) as {
|
||||
values: TriggerOAuthClientParams
|
||||
isCheckValidated: boolean
|
||||
} | undefined
|
||||
// Handle missing ref or form values
|
||||
if (!clientFormValues || !clientFormValues.isCheckValidated)
|
||||
return
|
||||
const clientParams = { ...clientFormValues.values }
|
||||
// Preserve hidden values if unchanged
|
||||
if (clientParams.client_id === oauthConfig?.params.client_id)
|
||||
clientParams.client_id = '[__HIDDEN__]'
|
||||
if (clientParams.client_secret === oauthConfig?.params.client_secret)
|
||||
clientParams.client_secret = '[__HIDDEN__]'
|
||||
params.client_params = clientParams
|
||||
}
|
||||
|
||||
configureOAuth(params, {
|
||||
onSuccess: () => {
|
||||
if (needAuth) {
|
||||
handleAuthorization()
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('modal.oauth.save.success', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [clientType, providerName, oauthClientSchema, oauthConfig?.params, configureOAuth, handleAuthorization, onClose, t])
|
||||
|
||||
// Polling effect for authorization verification
|
||||
useEffect(() => {
|
||||
const shouldPoll = providerName
|
||||
&& subscriptionBuilder
|
||||
&& authorizationStatus === AuthorizationStatusEnum.Pending
|
||||
|
||||
if (!shouldPoll)
|
||||
return
|
||||
|
||||
const pollInterval = setInterval(() => {
|
||||
verifyBuilder(
|
||||
{
|
||||
provider: providerName,
|
||||
subscriptionBuilderId: subscriptionBuilder.id,
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (response.verified) {
|
||||
setAuthorizationStatus(AuthorizationStatusEnum.Success)
|
||||
clearInterval(pollInterval)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
// Continue polling on error - auth might still be in progress
|
||||
},
|
||||
},
|
||||
)
|
||||
}, POLL_INTERVAL_MS)
|
||||
|
||||
return () => clearInterval(pollInterval)
|
||||
}, [subscriptionBuilder, authorizationStatus, verifyBuilder, providerName])
|
||||
|
||||
return {
|
||||
clientType,
|
||||
setClientType,
|
||||
authorizationStatus,
|
||||
clientFormRef,
|
||||
oauthClientSchema,
|
||||
confirmButtonText,
|
||||
handleAuthorization,
|
||||
handleRemove,
|
||||
handleSave,
|
||||
}
|
||||
}
|
||||
@ -6,9 +6,6 @@ import { SupportedCreationMethods } from '@/app/components/plugins/types'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { CreateButtonType, CreateSubscriptionButton, DEFAULT_METHOD } from './index'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
// Mock shared state for portal
|
||||
let mockPortalOpenState = false
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
@ -36,21 +33,18 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock zustand store
|
||||
let mockStoreDetail: SimpleDetail | undefined
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) =>
|
||||
selector({ detail: mockStoreDetail }),
|
||||
}))
|
||||
|
||||
// Mock subscription list hook
|
||||
const mockSubscriptions: TriggerSubscription[] = []
|
||||
const mockRefetch = vi.fn()
|
||||
vi.mock('../use-subscription-list', () => ({
|
||||
@ -60,7 +54,6 @@ vi.mock('../use-subscription-list', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock trigger service hooks
|
||||
let mockProviderInfo: { data: TriggerProviderApiEntity | undefined } = { data: undefined }
|
||||
let mockOAuthConfig: { data: TriggerOAuthConfig | undefined, refetch: () => void } = { data: undefined, refetch: vi.fn() }
|
||||
const mockInitiateOAuth = vi.fn()
|
||||
@ -73,14 +66,12 @@ vi.mock('@/service/use-triggers', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock OAuth popup
|
||||
vi.mock('@/hooks/use-oauth', () => ({
|
||||
openOAuthPopup: vi.fn((url: string, callback: (data?: unknown) => void) => {
|
||||
callback({ success: true, subscriptionId: 'test-subscription' })
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock child modals
|
||||
vi.mock('./common-modal', () => ({
|
||||
CommonCreateModal: ({ createType, onClose, builder }: {
|
||||
createType: SupportedCreationMethods
|
||||
@ -128,7 +119,6 @@ vi.mock('./oauth-client', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock CustomSelect
|
||||
vi.mock('@/app/components/base/select/custom', () => ({
|
||||
default: ({ options, value, onChange, CustomTrigger, CustomOption, containerProps }: {
|
||||
options: Array<{ value: string, label: string, show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
|
||||
@ -160,11 +150,6 @@ vi.mock('@/app/components/base/select/custom', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
/**
|
||||
* Factory function to create a TriggerProviderApiEntity with defaults
|
||||
*/
|
||||
const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}): TriggerProviderApiEntity => ({
|
||||
author: 'test-author',
|
||||
name: 'test-provider',
|
||||
@ -179,9 +164,6 @@ const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}):
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Factory function to create a TriggerOAuthConfig with defaults
|
||||
*/
|
||||
const createOAuthConfig = (overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig => ({
|
||||
configured: false,
|
||||
custom_configured: false,
|
||||
@ -196,9 +178,6 @@ const createOAuthConfig = (overrides: Partial<TriggerOAuthConfig> = {}): Trigger
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Factory function to create a SimpleDetail with defaults
|
||||
*/
|
||||
const createStoreDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail => ({
|
||||
plugin_id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
@ -209,9 +188,6 @@ const createStoreDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Factory function to create a TriggerSubscription with defaults
|
||||
*/
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'test-subscription',
|
||||
name: 'Test Subscription',
|
||||
@ -225,16 +201,10 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Factory function to create default props
|
||||
*/
|
||||
const createDefaultProps = (overrides: Partial<Parameters<typeof CreateSubscriptionButton>[0]> = {}) => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper to set up mock data for testing
|
||||
*/
|
||||
const setupMocks = (config: {
|
||||
providerInfo?: TriggerProviderApiEntity
|
||||
oauthConfig?: TriggerOAuthConfig
|
||||
@ -249,8 +219,6 @@ const setupMocks = (config: {
|
||||
mockSubscriptions.push(...config.subscriptions)
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('CreateSubscriptionButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -258,7 +226,6 @@ describe('CreateSubscriptionButton', () => {
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
describe('Rendering', () => {
|
||||
it('should render null when supportedMethods is empty', () => {
|
||||
// Arrange
|
||||
@ -322,7 +289,6 @@ describe('CreateSubscriptionButton', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Props Testing ====================
|
||||
describe('Props', () => {
|
||||
it('should apply default buttonType as FULL_BUTTON', () => {
|
||||
// Arrange
|
||||
@ -355,7 +321,6 @@ describe('CreateSubscriptionButton', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== State Management ====================
|
||||
describe('State Management', () => {
|
||||
it('should show CommonCreateModal when selectedCreateInfo is set', async () => {
|
||||
// Arrange
|
||||
@ -474,7 +439,6 @@ describe('CreateSubscriptionButton', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Memoization Logic ====================
|
||||
describe('Memoization - buttonTextMap', () => {
|
||||
it('should display correct button text for OAUTH method', () => {
|
||||
// Arrange
|
||||
|
||||
@ -2,7 +2,7 @@ import type { Option } from '@/app/components/base/select/custom'
|
||||
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import { RiAddLine, RiEqualizer2Line } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ActionButton, ActionButtonState } from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
@ -18,11 +18,7 @@ import { usePluginStore } from '../../store'
|
||||
import { useSubscriptionList } from '../use-subscription-list'
|
||||
import { CommonCreateModal } from './common-modal'
|
||||
import { OAuthClientSettingsModal } from './oauth-client'
|
||||
|
||||
export enum CreateButtonType {
|
||||
FULL_BUTTON = 'full-button',
|
||||
ICON_BUTTON = 'icon-button',
|
||||
}
|
||||
import { CreateButtonType, DEFAULT_METHOD } from './types'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
@ -32,8 +28,6 @@ type Props = {
|
||||
|
||||
const MAX_COUNT = 10
|
||||
|
||||
export const DEFAULT_METHOD = 'default'
|
||||
|
||||
export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON, shape = 'square' }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { subscriptions } = useSubscriptionList()
|
||||
@ -43,7 +37,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
const detail = usePluginStore(state => state.detail)
|
||||
|
||||
const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '')
|
||||
const supportedMethods = providerInfo?.supported_creation_methods || []
|
||||
const supportedMethods = useMemo(() => providerInfo?.supported_creation_methods || [], [providerInfo?.supported_creation_methods])
|
||||
const { data: oauthConfig, refetch: refetchOAuthConfig } = useTriggerOAuthConfig(detail?.provider || '', supportedMethods.includes(SupportedCreationMethods.OAUTH))
|
||||
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
|
||||
|
||||
@ -63,11 +57,11 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const onClickClientSettings = (e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => {
|
||||
const onClickClientSettings = useCallback((e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
showClientSettingsModal()
|
||||
}
|
||||
}, [showClientSettingsModal])
|
||||
|
||||
const allOptions = useMemo(() => {
|
||||
const showCustomBadge = oauthConfig?.custom_enabled && oauthConfig?.custom_configured
|
||||
@ -104,7 +98,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
show: supportedMethods.includes(SupportedCreationMethods.MANUAL),
|
||||
},
|
||||
]
|
||||
}, [t, oauthConfig, supportedMethods, methodType])
|
||||
}, [t, oauthConfig, supportedMethods, methodType, onClickClientSettings])
|
||||
|
||||
const onChooseCreateType = async (type: SupportedCreationMethods) => {
|
||||
if (type === SupportedCreationMethods.OAUTH) {
|
||||
@ -160,7 +154,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
<CustomSelect<Option & { show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
|
||||
options={allOptions.filter(option => option.show)}
|
||||
value={methodType}
|
||||
onChange={value => onChooseCreateType(value as any)}
|
||||
onChange={value => onChooseCreateType(value as SupportedCreationMethods)}
|
||||
containerProps={{
|
||||
open: (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) ? undefined : false,
|
||||
placement: 'bottom-start',
|
||||
@ -254,3 +248,5 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { CreateButtonType, DEFAULT_METHOD } from './types'
|
||||
|
||||
@ -3,24 +3,14 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
|
||||
// Import after mocks
|
||||
import { OAuthClientSettingsModal } from './oauth-client'
|
||||
|
||||
// ============================================================================
|
||||
// Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
type PluginDetail = {
|
||||
plugin_id: string
|
||||
provider: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Factory Functions
|
||||
// ============================================================================
|
||||
|
||||
function createMockOAuthConfig(overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig {
|
||||
return {
|
||||
configured: true,
|
||||
@ -64,18 +54,12 @@ function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBui
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
// Mock plugin store
|
||||
const mockPluginDetail = createMockPluginDetail()
|
||||
const mockUsePluginStore = vi.fn(() => mockPluginDetail)
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => mockUsePluginStore(),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
const mockInitiateOAuth = vi.fn()
|
||||
const mockVerifyBuilder = vi.fn()
|
||||
const mockConfigureOAuth = vi.fn()
|
||||
@ -96,13 +80,11 @@ vi.mock('@/service/use-triggers', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock OAuth popup
|
||||
const mockOpenOAuthPopup = vi.fn()
|
||||
vi.mock('@/hooks/use-oauth', () => ({
|
||||
openOAuthPopup: (url: string, callback: (data: unknown) => void) => mockOpenOAuthPopup(url, callback),
|
||||
}))
|
||||
|
||||
// Mock toast
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
@ -110,7 +92,6 @@ vi.mock('@/app/components/base/toast', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock clipboard API
|
||||
const mockClipboardWriteText = vi.fn()
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
@ -118,7 +99,6 @@ Object.assign(navigator, {
|
||||
},
|
||||
})
|
||||
|
||||
// Mock Modal component
|
||||
vi.mock('@/app/components/base/modal/modal', () => ({
|
||||
default: ({
|
||||
children,
|
||||
@ -161,24 +141,6 @@ vi.mock('@/app/components/base/modal/modal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Button component
|
||||
vi.mock('@/app/components/base/button', () => ({
|
||||
default: ({ children, onClick, variant, className }: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
variant?: string
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
data-testid={`button-${variant || 'default'}`}
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
// Configurable form mock values
|
||||
let mockFormValues: { values: Record<string, string>, isCheckValidated: boolean } = {
|
||||
values: { client_id: 'test-client-id', client_secret: 'test-client-secret' },
|
||||
isCheckValidated: true,
|
||||
@ -210,29 +172,6 @@ vi.mock('@/app/components/base/form/components/base', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock OptionCard component
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({
|
||||
default: ({ title, onSelect, selected, className }: {
|
||||
title: string
|
||||
onSelect: () => void
|
||||
selected: boolean
|
||||
className?: string
|
||||
}) => (
|
||||
<div
|
||||
data-testid={`option-card-${title}`}
|
||||
onClick={onSelect}
|
||||
className={`${className} ${selected ? 'selected' : ''}`}
|
||||
data-selected={selected}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Suites
|
||||
// ============================================================================
|
||||
|
||||
describe('OAuthClientSettingsModal', () => {
|
||||
const defaultProps = {
|
||||
oauthConfig: createMockOAuthConfig(),
|
||||
@ -244,7 +183,6 @@ describe('OAuthClientSettingsModal', () => {
|
||||
vi.clearAllMocks()
|
||||
mockUsePluginStore.mockReturnValue(mockPluginDetail)
|
||||
mockClipboardWriteText.mockResolvedValue(undefined)
|
||||
// Reset form values to default
|
||||
setMockFormValues({
|
||||
values: { client_id: 'test-client-id', client_secret: 'test-client-secret' },
|
||||
isCheckValidated: true,
|
||||
@ -265,8 +203,8 @@ describe('OAuthClientSettingsModal', () => {
|
||||
it('should render client type selector when system_configured is true', () => {
|
||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')).toBeInTheDocument()
|
||||
expect(screen.getByText('pluginTrigger.subscription.addType.options.oauth.default')).toBeInTheDocument()
|
||||
expect(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render client type selector when system_configured is false', () => {
|
||||
@ -276,7 +214,7 @@ describe('OAuthClientSettingsModal', () => {
|
||||
|
||||
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithoutSystemConfigured} />)
|
||||
|
||||
expect(screen.queryByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('pluginTrigger.subscription.addType.options.oauth.default')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render redirect URI info when custom client type is selected', () => {
|
||||
@ -319,29 +257,29 @@ describe('OAuthClientSettingsModal', () => {
|
||||
it('should default to Default client type when system_configured is true', () => {
|
||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||
|
||||
const defaultCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')
|
||||
expect(defaultCard).toHaveAttribute('data-selected', 'true')
|
||||
const defaultCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.default').closest('div')
|
||||
expect(defaultCard).toHaveClass('border-[1.5px]')
|
||||
})
|
||||
|
||||
it('should switch to Custom client type when Custom card is clicked', () => {
|
||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||
|
||||
const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
|
||||
fireEvent.click(customCard)
|
||||
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')
|
||||
fireEvent.click(customCard!)
|
||||
|
||||
expect(customCard).toHaveAttribute('data-selected', 'true')
|
||||
expect(customCard).toHaveClass('border-[1.5px]')
|
||||
})
|
||||
|
||||
it('should switch back to Default client type when Default card is clicked', () => {
|
||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||
|
||||
const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
|
||||
fireEvent.click(customCard)
|
||||
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')
|
||||
fireEvent.click(customCard!)
|
||||
|
||||
const defaultCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')
|
||||
fireEvent.click(defaultCard)
|
||||
const defaultCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.default').closest('div')
|
||||
fireEvent.click(defaultCard!)
|
||||
|
||||
expect(defaultCard).toHaveAttribute('data-selected', 'true')
|
||||
expect(defaultCard).toHaveClass('border-[1.5px]')
|
||||
})
|
||||
})
|
||||
|
||||
@ -852,8 +790,8 @@ describe('OAuthClientSettingsModal', () => {
|
||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||
|
||||
// Switch to custom
|
||||
const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
|
||||
fireEvent.click(customCard)
|
||||
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')
|
||||
fireEvent.click(customCard!)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
|
||||
@ -1054,7 +992,7 @@ describe('OAuthClientSettingsModal', () => {
|
||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||
|
||||
// Switch to custom type
|
||||
const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
|
||||
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!
|
||||
fireEvent.click(customCard)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
@ -1077,7 +1015,7 @@ describe('OAuthClientSettingsModal', () => {
|
||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||
|
||||
// Switch to custom type
|
||||
fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
|
||||
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
|
||||
@ -1104,7 +1042,7 @@ describe('OAuthClientSettingsModal', () => {
|
||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||
|
||||
// Switch to custom type
|
||||
fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
|
||||
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
|
||||
@ -1131,7 +1069,7 @@ describe('OAuthClientSettingsModal', () => {
|
||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||
|
||||
// Switch to custom type
|
||||
fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
|
||||
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
|
||||
@ -1158,7 +1096,7 @@ describe('OAuthClientSettingsModal', () => {
|
||||
render(<OAuthClientSettingsModal {...defaultProps} />)
|
||||
|
||||
// Switch to custom type
|
||||
fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
|
||||
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
|
||||
|
||||
@ -1,27 +1,17 @@
|
||||
'use client'
|
||||
import type { FormRefObject } from '@/app/components/base/form/types'
|
||||
import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import type { ConfigureTriggerOAuthPayload } from '@/service/use-triggers'
|
||||
import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
|
||||
import {
|
||||
RiClipboardLine,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { BaseForm } from '@/app/components/base/form/components/base'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
|
||||
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||
import {
|
||||
useConfigureTriggerOAuth,
|
||||
useDeleteTriggerOAuth,
|
||||
useInitiateTriggerOAuth,
|
||||
useVerifyAndUpdateTriggerSubscriptionBuilder,
|
||||
} from '@/service/use-triggers'
|
||||
import { usePluginStore } from '../../store'
|
||||
import { ClientTypeEnum, useOAuthClientState } from './hooks/use-oauth-client-state'
|
||||
|
||||
type Props = {
|
||||
oauthConfig?: TriggerOAuthConfig
|
||||
@ -29,169 +19,38 @@ type Props = {
|
||||
showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void
|
||||
}
|
||||
|
||||
enum AuthorizationStatusEnum {
|
||||
Pending = 'pending',
|
||||
Success = 'success',
|
||||
Failed = 'failed',
|
||||
}
|
||||
|
||||
enum ClientTypeEnum {
|
||||
Default = 'default',
|
||||
Custom = 'custom',
|
||||
}
|
||||
const CLIENT_TYPE_OPTIONS = [ClientTypeEnum.Default, ClientTypeEnum.Custom] as const
|
||||
|
||||
export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreateModal }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const detail = usePluginStore(state => state.detail)
|
||||
const { system_configured, params, oauth_client_schema } = oauthConfig || {}
|
||||
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>()
|
||||
const [authorizationStatus, setAuthorizationStatus] = useState<AuthorizationStatusEnum>()
|
||||
|
||||
const [clientType, setClientType] = useState<ClientTypeEnum>(system_configured ? ClientTypeEnum.Default : ClientTypeEnum.Custom)
|
||||
|
||||
const clientFormRef = React.useRef<FormRefObject>(null)
|
||||
|
||||
const oauthClientSchema = useMemo(() => {
|
||||
if (oauth_client_schema && oauth_client_schema.length > 0 && params) {
|
||||
const oauthConfigPramaKeys = Object.keys(params || {})
|
||||
for (const schema of oauth_client_schema) {
|
||||
if (oauthConfigPramaKeys.includes(schema.name))
|
||||
schema.default = params?.[schema.name]
|
||||
}
|
||||
return oauth_client_schema
|
||||
}
|
||||
return []
|
||||
}, [oauth_client_schema, params])
|
||||
|
||||
const providerName = detail?.provider || ''
|
||||
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
|
||||
const { mutate: verifyBuilder } = useVerifyAndUpdateTriggerSubscriptionBuilder()
|
||||
const { mutate: configureOAuth } = useConfigureTriggerOAuth()
|
||||
const { mutate: deleteOAuth } = useDeleteTriggerOAuth()
|
||||
|
||||
const confirmButtonText = useMemo(() => {
|
||||
if (authorizationStatus === AuthorizationStatusEnum.Pending)
|
||||
return t('modal.common.authorizing', { ns: 'pluginTrigger' })
|
||||
if (authorizationStatus === AuthorizationStatusEnum.Success)
|
||||
return t('modal.oauth.authorization.waitingJump', { ns: 'pluginTrigger' })
|
||||
return t('auth.saveAndAuth', { ns: 'plugin' })
|
||||
}, [authorizationStatus, t])
|
||||
const {
|
||||
clientType,
|
||||
setClientType,
|
||||
clientFormRef,
|
||||
oauthClientSchema,
|
||||
confirmButtonText,
|
||||
handleRemove,
|
||||
handleSave,
|
||||
} = useOAuthClientState({
|
||||
oauthConfig,
|
||||
providerName,
|
||||
onClose,
|
||||
showOAuthCreateModal,
|
||||
})
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (error instanceof Error && error.message)
|
||||
return error.message
|
||||
if (typeof error === 'object' && error && 'message' in error) {
|
||||
const message = (error as { message?: string }).message
|
||||
if (typeof message === 'string' && message)
|
||||
return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
const isCustomClient = clientType === ClientTypeEnum.Custom
|
||||
const showRemoveButton = oauthConfig?.custom_enabled && oauthConfig?.params && isCustomClient
|
||||
const showRedirectInfo = isCustomClient && oauthConfig?.redirect_uri
|
||||
const showClientForm = isCustomClient && oauthClientSchema.length > 0
|
||||
|
||||
const handleAuthorization = () => {
|
||||
setAuthorizationStatus(AuthorizationStatusEnum.Pending)
|
||||
initiateOAuth(providerName, {
|
||||
onSuccess: (response) => {
|
||||
setSubscriptionBuilder(response.subscription_builder)
|
||||
openOAuthPopup(response.authorization_url, (callbackData) => {
|
||||
if (callbackData) {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('modal.oauth.authorization.authSuccess', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
onClose()
|
||||
showOAuthCreateModal(response.subscription_builder)
|
||||
}
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
setAuthorizationStatus(AuthorizationStatusEnum.Failed)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('modal.oauth.authorization.authFailed', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (providerName && subscriptionBuilder && authorizationStatus === AuthorizationStatusEnum.Pending) {
|
||||
const pollInterval = setInterval(() => {
|
||||
verifyBuilder(
|
||||
{
|
||||
provider: providerName,
|
||||
subscriptionBuilderId: subscriptionBuilder.id,
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (response.verified) {
|
||||
setAuthorizationStatus(AuthorizationStatusEnum.Success)
|
||||
clearInterval(pollInterval)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
// Continue polling - auth might still be in progress
|
||||
},
|
||||
},
|
||||
)
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(pollInterval)
|
||||
}
|
||||
}, [subscriptionBuilder, authorizationStatus, verifyBuilder, providerName, t])
|
||||
|
||||
const handleRemove = () => {
|
||||
deleteOAuth(providerName, {
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('modal.oauth.remove.success', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: getErrorMessage(error, t('modal.oauth.remove.failed', { ns: 'pluginTrigger' })),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = (needAuth: boolean) => {
|
||||
const isCustom = clientType === ClientTypeEnum.Custom
|
||||
const params: ConfigureTriggerOAuthPayload = {
|
||||
provider: providerName,
|
||||
enabled: isCustom,
|
||||
}
|
||||
|
||||
if (isCustom) {
|
||||
const clientFormValues = clientFormRef.current?.getFormValues({}) as { values: TriggerOAuthClientParams, isCheckValidated: boolean }
|
||||
if (!clientFormValues.isCheckValidated)
|
||||
return
|
||||
const clientParams = clientFormValues.values
|
||||
if (clientParams.client_id === oauthConfig?.params.client_id)
|
||||
clientParams.client_id = '[__HIDDEN__]'
|
||||
|
||||
if (clientParams.client_secret === oauthConfig?.params.client_secret)
|
||||
clientParams.client_secret = '[__HIDDEN__]'
|
||||
|
||||
params.client_params = clientParams
|
||||
}
|
||||
|
||||
configureOAuth(params, {
|
||||
onSuccess: () => {
|
||||
if (needAuth) {
|
||||
handleAuthorization()
|
||||
}
|
||||
else {
|
||||
onClose()
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('modal.oauth.save.success', { ns: 'pluginTrigger' }),
|
||||
})
|
||||
}
|
||||
},
|
||||
const handleCopyRedirectUri = () => {
|
||||
navigator.clipboard.writeText(oauthConfig?.redirect_uri || '')
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('actionMsg.copySuccessfully', { ns: 'common' }),
|
||||
})
|
||||
}
|
||||
|
||||
@ -208,25 +67,25 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
|
||||
onClose={onClose}
|
||||
onCancel={() => handleSave(false)}
|
||||
onConfirm={() => handleSave(true)}
|
||||
footerSlot={
|
||||
oauthConfig?.custom_enabled && oauthConfig?.params && clientType === ClientTypeEnum.Custom && (
|
||||
<div className="grow">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="text-components-button-destructive-secondary-text"
|
||||
// disabled={disabled || doingAction || !editValues}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
footerSlot={showRemoveButton && (
|
||||
<div className="grow">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="text-components-button-destructive-secondary-text"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="system-sm-medium mb-2 text-text-secondary">{t('subscription.addType.options.oauth.clientTitle', { ns: 'pluginTrigger' })}</div>
|
||||
<div className="system-sm-medium mb-2 text-text-secondary">
|
||||
{t('subscription.addType.options.oauth.clientTitle', { ns: 'pluginTrigger' })}
|
||||
</div>
|
||||
|
||||
{oauthConfig?.system_configured && (
|
||||
<div className="mb-4 flex w-full items-start justify-between gap-2">
|
||||
{[ClientTypeEnum.Default, ClientTypeEnum.Custom].map(option => (
|
||||
{CLIENT_TYPE_OPTIONS.map(option => (
|
||||
<OptionCard
|
||||
key={option}
|
||||
title={t(`subscription.addType.options.oauth.${option}`, { ns: 'pluginTrigger' })}
|
||||
@ -237,7 +96,8 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{clientType === ClientTypeEnum.Custom && oauthConfig?.redirect_uri && (
|
||||
|
||||
{showRedirectInfo && (
|
||||
<div className="mb-4 flex items-start gap-3 rounded-xl bg-background-section-burn p-4">
|
||||
<div className="rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg p-2 shadow-xs shadow-shadow-shadow-3">
|
||||
<RiInformation2Fill className="h-5 w-5 shrink-0 text-text-accent" />
|
||||
@ -247,18 +107,12 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
|
||||
{t('modal.oauthRedirectInfo', { ns: 'pluginTrigger' })}
|
||||
</div>
|
||||
<div className="system-sm-medium my-1.5 break-all leading-4">
|
||||
{oauthConfig.redirect_uri}
|
||||
{oauthConfig?.redirect_uri}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(oauthConfig.redirect_uri)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('actionMsg.copySuccessfully', { ns: 'common' }),
|
||||
})
|
||||
}}
|
||||
onClick={handleCopyRedirectUri}
|
||||
>
|
||||
<RiClipboardLine className="mr-1 h-[14px] w-[14px]" />
|
||||
{t('operation.copy', { ns: 'common' })}
|
||||
@ -266,7 +120,8 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{clientType === ClientTypeEnum.Custom && oauthClientSchema.length > 0 && (
|
||||
|
||||
{showClientForm && (
|
||||
<BaseForm
|
||||
formSchemas={oauthClientSchema}
|
||||
ref={clientFormRef}
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
export enum CreateButtonType {
|
||||
FULL_BUTTON = 'full-button',
|
||||
ICON_BUTTON = 'icon-button',
|
||||
}
|
||||
|
||||
export const DEFAULT_METHOD = 'default'
|
||||
@ -1,5 +1,5 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DSLImportStatus } from '@/models/app'
|
||||
import UpdateDSLModal from './update-dsl-modal'
|
||||
@ -140,13 +140,13 @@ class MockFileReader {
|
||||
onload: ((e: { target: { result: string | null } }) => void) | null = null
|
||||
|
||||
readAsText(_file: File) {
|
||||
// Simulate async file reading
|
||||
setTimeout(() => {
|
||||
// Simulate async file reading using queueMicrotask for more reliable async behavior
|
||||
queueMicrotask(() => {
|
||||
this.result = 'test file content'
|
||||
if (this.onload) {
|
||||
this.onload({ target: { result: this.result } })
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,6 +174,7 @@ describe('UpdateDSLModal', () => {
|
||||
status: DSLImportStatus.COMPLETED,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
})
|
||||
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
|
||||
|
||||
// Mock FileReader
|
||||
originalFileReader = globalThis.FileReader
|
||||
@ -472,14 +473,14 @@ describe('UpdateDSLModal', () => {
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
}, { timeout: 1000 })
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnImport).toHaveBeenCalled()
|
||||
})
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('should show warning notification on import with warnings', async () => {
|
||||
@ -647,6 +648,8 @@ describe('UpdateDSLModal', () => {
|
||||
})
|
||||
|
||||
it('should show error modal when import status is PENDING', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-id',
|
||||
status: DSLImportStatus.PENDING,
|
||||
@ -659,20 +662,29 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
const fileInput = screen.getByTestId('file-input')
|
||||
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
// Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask)
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
expect(importButton).not.toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(importButton)
|
||||
// Flush the promise resolution from mockImportDSL
|
||||
await Promise.resolve()
|
||||
// Advance past the 300ms setTimeout in the component
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
})
|
||||
|
||||
// Wait for the error modal to be shown after setTimeout
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 500 })
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should show version info in error modal', async () => {
|
||||
|
||||
@ -61,6 +61,12 @@ vi.mock('@/service/use-pipeline', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock download utility
|
||||
const mockDownloadBlob = vi.fn()
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
|
||||
}))
|
||||
|
||||
// Mock workflow service
|
||||
const mockFetchWorkflowDraft = vi.fn()
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
@ -77,33 +83,9 @@ vi.mock('@/app/components/workflow/constants', () => ({
|
||||
// ============================================================================
|
||||
|
||||
describe('useDSL', () => {
|
||||
let mockLink: { href: string, download: string, click: ReturnType<typeof vi.fn> }
|
||||
let originalCreateElement: typeof document.createElement
|
||||
let mockCreateObjectURL: ReturnType<typeof vi.spyOn>
|
||||
let mockRevokeObjectURL: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create a proper mock link element
|
||||
mockLink = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: vi.fn(),
|
||||
}
|
||||
|
||||
// Save original and mock selectively - only intercept 'a' elements
|
||||
originalCreateElement = document.createElement.bind(document)
|
||||
document.createElement = vi.fn((tagName: string) => {
|
||||
if (tagName === 'a') {
|
||||
return mockLink as unknown as HTMLElement
|
||||
}
|
||||
return originalCreateElement(tagName)
|
||||
}) as typeof document.createElement
|
||||
|
||||
mockCreateObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url')
|
||||
mockRevokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
|
||||
// Default store state
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
pipelineId: 'test-pipeline-id',
|
||||
@ -118,9 +100,6 @@ describe('useDSL', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.createElement = originalCreateElement
|
||||
mockCreateObjectURL.mockRestore()
|
||||
mockRevokeObjectURL.mockRestore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@ -187,9 +166,7 @@ describe('useDSL', () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(document.createElement).toHaveBeenCalledWith('a')
|
||||
expect(mockCreateObjectURL).toHaveBeenCalled()
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url')
|
||||
expect(mockDownloadBlob).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use correct file extension for download', async () => {
|
||||
@ -199,17 +176,25 @@ describe('useDSL', () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockLink.download).toBe('Test Knowledge Base.pipeline')
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileName: 'Test Knowledge Base.pipeline',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should trigger download click', async () => {
|
||||
it('should pass blob data to downloadBlob', async () => {
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockLink.click).toHaveBeenCalled()
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.any(Blob),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show error notification on export failure', async () => {
|
||||
|
||||
@ -172,6 +172,9 @@ describe('EditCustomCollectionModal', () => {
|
||||
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}')
|
||||
})
|
||||
|
||||
// Flush pending state updates from parseParamsSchema promise resolution
|
||||
await act(async () => {})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
})
|
||||
@ -184,6 +187,10 @@ describe('EditCustomCollectionModal', () => {
|
||||
credentials: {
|
||||
auth_type: 'none',
|
||||
},
|
||||
icon: {
|
||||
content: '🕵️',
|
||||
background: '#FEF7C3',
|
||||
},
|
||||
labels: [],
|
||||
}))
|
||||
expect(toastNotifySpy).not.toHaveBeenCalled()
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
Group,
|
||||
} from '@/app/components/workflow/nodes/_base/components/layout'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import Split from '../_base/components/split'
|
||||
import ChunkStructure from './components/chunk-structure'
|
||||
import EmbeddingModel from './components/embedding-model'
|
||||
@ -172,7 +173,7 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
|
||||
{
|
||||
data.indexing_technique === IndexMethodEnum.QUALIFIED
|
||||
&& [ChunkStructureEnum.general, ChunkStructureEnum.parent_child].includes(data.chunk_structure)
|
||||
&& (
|
||||
&& IS_CE_EDITION && (
|
||||
<>
|
||||
<SummaryIndexSetting
|
||||
summaryIndexSetting={data.summary_index_setting}
|
||||
|
||||
@ -196,19 +196,19 @@ describe('useDocLink', () => {
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/api-reference/annotations/create-annotation')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/api-reference/annotations/create-annotation`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/api-reference/annotations/create-annotation`)
|
||||
})
|
||||
|
||||
it('should keep original path when no translation exists for non-English locale', () => {
|
||||
vi.mocked(useTranslation).mockReturnValue({
|
||||
i18n: { language: 'ja-JP' },
|
||||
i18n: { language: 'zh-Hans' },
|
||||
} as ReturnType<typeof useTranslation>)
|
||||
vi.mocked(getDocLanguage).mockReturnValue('ja')
|
||||
vi.mocked(getDocLanguage).mockReturnValue('zh')
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
// This path has no Japanese translation
|
||||
const url = result.current('/api-reference/annotations/create-annotation')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/ja/api-reference/annotations/create-annotation`)
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/api-reference/标注管理/创建标注`)
|
||||
})
|
||||
|
||||
it('should remove language prefix when translation is applied', () => {
|
||||
|
||||
@ -35,12 +35,13 @@ export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathM
|
||||
let targetPath = (pathMap) ? pathMap[locale] || pathUrl : pathUrl
|
||||
let languagePrefix = `/${docLanguage}`
|
||||
|
||||
// Translate API reference paths for non-English locales
|
||||
if (targetPath.startsWith('/api-reference/') && docLanguage !== 'en') {
|
||||
const translatedPath = apiReferencePathTranslations[targetPath]?.[docLanguage as 'zh' | 'ja']
|
||||
if (translatedPath) {
|
||||
targetPath = translatedPath
|
||||
languagePrefix = ''
|
||||
if (targetPath.startsWith('/api-reference/')) {
|
||||
languagePrefix = ''
|
||||
if (docLanguage !== 'en') {
|
||||
const translatedPath = apiReferencePathTranslations[targetPath]?.[docLanguage]
|
||||
if (translatedPath) {
|
||||
targetPath = translatedPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2445,11 +2445,6 @@
|
||||
"count": 8
|
||||
}
|
||||
},
|
||||
"app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 8
|
||||
}
|
||||
},
|
||||
"app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -2503,14 +2498,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.11.4",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a",
|
||||
"imports": {
|
||||
@ -117,7 +117,6 @@
|
||||
"ky": "1.12.0",
|
||||
"lamejs": "1.2.1",
|
||||
"lexical": "0.38.2",
|
||||
"line-clamp": "1.0.0",
|
||||
"mermaid": "11.11.0",
|
||||
"mime": "4.1.0",
|
||||
"mitt": "3.0.1",
|
||||
|
||||
8
web/pnpm-lock.yaml
generated
8
web/pnpm-lock.yaml
generated
@ -233,9 +233,6 @@ importers:
|
||||
lexical:
|
||||
specifier: 0.38.2
|
||||
version: 0.38.2
|
||||
line-clamp:
|
||||
specifier: 1.0.0
|
||||
version: 1.0.0
|
||||
mermaid:
|
||||
specifier: 11.11.0
|
||||
version: 11.11.0
|
||||
@ -5403,9 +5400,6 @@ packages:
|
||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
line-clamp@1.0.0:
|
||||
resolution: {integrity: sha512-dCDlvMj572RIRBQ3x9aIX0DTdt2St1bMdpi64jVTAi5vqBck7wf+J97//+J7+pS80rFJaYa8HiyXCTp0flpnBA==}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
@ -12913,8 +12907,6 @@ snapshots:
|
||||
|
||||
lilconfig@3.1.3: {}
|
||||
|
||||
line-clamp@1.0.0: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
lint-staged@15.5.2:
|
||||
|
||||
80
web/service/client.spec.ts
Normal file
80
web/service/client.spec.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const loadGetBaseURL = async (isClientValue: boolean) => {
|
||||
vi.resetModules()
|
||||
vi.doMock('@/utils/client', () => ({ isClient: isClientValue }))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// eslint-disable-next-line next/no-assign-module-variable
|
||||
const module = await import('./client')
|
||||
warnSpy.mockClear()
|
||||
return { getBaseURL: module.getBaseURL, warnSpy }
|
||||
}
|
||||
|
||||
// Scenario: base URL selection and warnings.
|
||||
describe('getBaseURL', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Scenario: client environment uses window origin.
|
||||
it('should use window origin when running on the client', async () => {
|
||||
// Arrange
|
||||
const { origin } = window.location
|
||||
const { getBaseURL, warnSpy } = await loadGetBaseURL(true)
|
||||
|
||||
// Act
|
||||
const url = getBaseURL('/api')
|
||||
|
||||
// Assert
|
||||
expect(url.href).toBe(`${origin}/api`)
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Scenario: server environment falls back to localhost with warning.
|
||||
it('should fall back to localhost and warn on the server', async () => {
|
||||
// Arrange
|
||||
const { getBaseURL, warnSpy } = await loadGetBaseURL(false)
|
||||
|
||||
// Act
|
||||
const url = getBaseURL('/api')
|
||||
|
||||
// Assert
|
||||
expect(url.href).toBe('http://localhost/api')
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy).toHaveBeenCalledWith('Using localhost as base URL in server environment, please configure accordingly.')
|
||||
})
|
||||
|
||||
// Scenario: non-http protocols surface warnings.
|
||||
it('should warn when protocol is not http or https', async () => {
|
||||
// Arrange
|
||||
const { getBaseURL, warnSpy } = await loadGetBaseURL(true)
|
||||
|
||||
// Act
|
||||
const url = getBaseURL('localhost:5001/console/api')
|
||||
|
||||
// Assert
|
||||
expect(url.protocol).toBe('localhost:')
|
||||
expect(url.href).toBe('localhost:5001/console/api')
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Unexpected protocol for API requests, expected http or https. Current protocol: localhost:. Please configure accordingly.',
|
||||
)
|
||||
})
|
||||
|
||||
// Scenario: absolute http URLs are preserved.
|
||||
it('should keep absolute http URLs intact', async () => {
|
||||
// Arrange
|
||||
const { getBaseURL, warnSpy } = await loadGetBaseURL(true)
|
||||
|
||||
// Act
|
||||
const url = getBaseURL('https://api.example.com/console/api')
|
||||
|
||||
// Assert
|
||||
expect(url.href).toBe('https://api.example.com/console/api')
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -13,12 +13,38 @@ import {
|
||||
consoleRouterContract,
|
||||
marketplaceRouterContract,
|
||||
} from '@/contract/router'
|
||||
import { isClient } from '@/utils/client'
|
||||
import { request } from './base'
|
||||
|
||||
const getMarketplaceHeaders = () => new Headers({
|
||||
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0',
|
||||
})
|
||||
|
||||
function isURL(path: string) {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(path)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getBaseURL(path: string) {
|
||||
const url = new URL(path, isURL(path) ? undefined : isClient ? window.location.origin : 'http://localhost')
|
||||
|
||||
if (!isClient && !isURL(path)) {
|
||||
console.warn('Using localhost as base URL in server environment, please configure accordingly.')
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
console.warn(`Unexpected protocol for API requests, expected http or https. Current protocol: ${url.protocol}. Please configure accordingly.`)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
const marketplaceLink = new OpenAPILink(marketplaceRouterContract, {
|
||||
url: MARKETPLACE_API_PREFIX,
|
||||
headers: () => (getMarketplaceHeaders()),
|
||||
@ -39,7 +65,7 @@ export const marketplaceClient: JsonifiedClient<ContractRouterClient<typeof mark
|
||||
export const marketplaceQuery = createTanstackQueryUtils(marketplaceClient, { path: ['marketplace'] })
|
||||
|
||||
const consoleLink = new OpenAPILink(consoleRouterContract, {
|
||||
url: API_PREFIX,
|
||||
url: getBaseURL(API_PREFIX),
|
||||
fetch: (input, init) => {
|
||||
return request(
|
||||
input.url,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { App, AppCategory } from '@/models/explore'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { fetchAppList, fetchBanners, fetchInstalledAppList, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore'
|
||||
import { AppSourceType, fetchAppMeta, fetchAppParams } from './share'
|
||||
@ -13,8 +14,9 @@ type ExploreAppListData = {
|
||||
}
|
||||
|
||||
export const useExploreAppList = () => {
|
||||
const locale = useLocale()
|
||||
return useQuery<ExploreAppListData>({
|
||||
queryKey: [NAME_SPACE, 'appList'],
|
||||
queryKey: [NAME_SPACE, 'appList', locale],
|
||||
queryFn: async () => {
|
||||
const { categories, recommended_apps } = await fetchAppList()
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user