mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 13:51:25 +08:00
refactor(plugins): migrate endpoint queries to generated contracts (#42685)
This commit is contained in:
26 files changed
+1240
-1689
No files matched your search
@@ -9,12 +9,11 @@ marks only the legacy paths as deprecated.
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.fields import SuccessResponse
|
||||
from controllers.common.rbac import RBACCheck, Workspace
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@@ -40,6 +39,10 @@ from libs.login import login_required
|
||||
from services.plugin.endpoint_service import EndpointService
|
||||
|
||||
|
||||
class EndpointMutationResponse(ResponseModel):
|
||||
success: Literal[True] = Field(description="Always true on success. Failures are returned as HTTP errors.")
|
||||
|
||||
|
||||
class EndpointCreatePayload(BaseModel):
|
||||
plugin_unique_identifier: str
|
||||
settings: dict[str, Any]
|
||||
@@ -156,7 +159,7 @@ register_schema_models(
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
SuccessResponse,
|
||||
EndpointMutationResponse,
|
||||
EndpointProviderConfigOptionResponse,
|
||||
EndpointProviderConfigResponse,
|
||||
EndpointDeclarationResponse,
|
||||
@@ -166,7 +169,7 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _create_endpoint(tenant_id: str, user_id: str, req_data: EndpointCreatePayload) -> bool:
|
||||
def _create_endpoint(tenant_id: str, user_id: str, req_data: EndpointCreatePayload) -> Literal[True]:
|
||||
"""Create a plugin endpoint for the injected workspace and user."""
|
||||
try:
|
||||
return EndpointService.create_endpoint(
|
||||
@@ -180,7 +183,7 @@ def _create_endpoint(tenant_id: str, user_id: str, req_data: EndpointCreatePaylo
|
||||
raise ValueError(e.description) from e
|
||||
|
||||
|
||||
def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str, req_data: EndpointUpdatePayload) -> bool:
|
||||
def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str, req_data: EndpointUpdatePayload) -> Literal[True]:
|
||||
"""Update a plugin endpoint identified by the canonical path parameter."""
|
||||
return EndpointService.update_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
@@ -191,7 +194,7 @@ def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str, req_data: E
|
||||
)
|
||||
|
||||
|
||||
def _legacy_update_endpoint(tenant_id: str, user_id: str, req_data: LegacyEndpointUpdatePayload) -> bool:
|
||||
def _legacy_update_endpoint(tenant_id: str, user_id: str, req_data: LegacyEndpointUpdatePayload) -> Literal[True]:
|
||||
return EndpointService.update_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
@@ -201,7 +204,7 @@ def _legacy_update_endpoint(tenant_id: str, user_id: str, req_data: LegacyEndpoi
|
||||
)
|
||||
|
||||
|
||||
def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> bool:
|
||||
def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]:
|
||||
"""Delete a plugin endpoint identified by the canonical path parameter."""
|
||||
return EndpointService.delete_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
@@ -210,11 +213,11 @@ def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _delete_endpoint_from_payload(tenant_id: str, user_id: str, req_data: EndpointIdPayload) -> bool:
|
||||
def _delete_endpoint_from_payload(tenant_id: str, user_id: str, req_data: EndpointIdPayload) -> Literal[True]:
|
||||
return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=req_data.endpoint_id)
|
||||
|
||||
|
||||
def _set_endpoint_enabled(tenant_id: str, user_id: str, req_data: EndpointIdPayload, *, enabled: bool) -> bool:
|
||||
def _set_endpoint_enabled(tenant_id: str, user_id: str, req_data: EndpointIdPayload, *, enabled: bool) -> Literal[True]:
|
||||
action = EndpointService.enable_endpoint if enabled else EndpointService.disable_endpoint
|
||||
return action(tenant_id=tenant_id, user_id=user_id, endpoint_id=req_data.endpoint_id)
|
||||
|
||||
@@ -229,7 +232,7 @@ class EndpointCollectionApi(Resource):
|
||||
@console_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Endpoint created successfully",
|
||||
console_ns.models[SuccessResponse.__name__],
|
||||
console_ns.models[EndpointMutationResponse.__name__],
|
||||
)
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
|
||||
@setup_required
|
||||
@@ -241,7 +244,7 @@ class EndpointCollectionApi(Resource):
|
||||
@with_current_tenant_id
|
||||
@model_validate(EndpointCreatePayload)
|
||||
def post(self, req_data: EndpointCreatePayload, tenant_id: str, user_id: str):
|
||||
return SuccessResponse(
|
||||
return EndpointMutationResponse(
|
||||
success=_create_endpoint(tenant_id=tenant_id, user_id=user_id, req_data=req_data)
|
||||
).model_dump(mode="json")
|
||||
|
||||
@@ -261,7 +264,7 @@ class DeprecatedEndpointCreateApi(Resource):
|
||||
@console_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Endpoint created successfully",
|
||||
console_ns.models[SuccessResponse.__name__],
|
||||
console_ns.models[EndpointMutationResponse.__name__],
|
||||
)
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
|
||||
@setup_required
|
||||
@@ -273,7 +276,7 @@ class DeprecatedEndpointCreateApi(Resource):
|
||||
@with_current_tenant_id
|
||||
@model_validate(EndpointCreatePayload)
|
||||
def post(self, req_data: EndpointCreatePayload, tenant_id: str, user_id: str):
|
||||
return SuccessResponse(
|
||||
return EndpointMutationResponse(
|
||||
success=_create_endpoint(tenant_id=tenant_id, user_id=user_id, req_data=req_data)
|
||||
).model_dump(mode="json")
|
||||
|
||||
@@ -349,7 +352,7 @@ class EndpointItemApi(Resource):
|
||||
@console_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Endpoint deleted successfully",
|
||||
console_ns.models[SuccessResponse.__name__],
|
||||
console_ns.models[EndpointMutationResponse.__name__],
|
||||
)
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
|
||||
@setup_required
|
||||
@@ -360,7 +363,7 @@ class EndpointItemApi(Resource):
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, user_id: str, id: str):
|
||||
return SuccessResponse(
|
||||
return EndpointMutationResponse(
|
||||
success=_delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
|
||||
).model_dump(mode="json")
|
||||
|
||||
@@ -371,7 +374,7 @@ class EndpointItemApi(Resource):
|
||||
@console_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Endpoint updated successfully",
|
||||
console_ns.models[SuccessResponse.__name__],
|
||||
console_ns.models[EndpointMutationResponse.__name__],
|
||||
)
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
|
||||
@setup_required
|
||||
@@ -383,7 +386,7 @@ class EndpointItemApi(Resource):
|
||||
@with_current_tenant_id
|
||||
@model_validate(EndpointUpdatePayload)
|
||||
def patch(self, req_data: EndpointUpdatePayload, tenant_id: str, user_id: str, id: str):
|
||||
return SuccessResponse(
|
||||
return EndpointMutationResponse(
|
||||
success=_update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id, req_data=req_data)
|
||||
).model_dump(mode="json")
|
||||
|
||||
@@ -404,7 +407,7 @@ class DeprecatedEndpointDeleteApi(Resource):
|
||||
@console_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Endpoint deleted successfully",
|
||||
console_ns.models[SuccessResponse.__name__],
|
||||
console_ns.models[EndpointMutationResponse.__name__],
|
||||
)
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
|
||||
@setup_required
|
||||
@@ -416,7 +419,7 @@ class DeprecatedEndpointDeleteApi(Resource):
|
||||
@with_current_tenant_id
|
||||
@model_validate(EndpointIdPayload)
|
||||
def post(self, req_data: EndpointIdPayload, tenant_id: str, user_id: str):
|
||||
return SuccessResponse(
|
||||
return EndpointMutationResponse(
|
||||
success=_delete_endpoint_from_payload(tenant_id=tenant_id, user_id=user_id, req_data=req_data)
|
||||
).model_dump(mode="json")
|
||||
|
||||
@@ -437,7 +440,7 @@ class DeprecatedEndpointUpdateApi(Resource):
|
||||
@console_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Endpoint updated successfully",
|
||||
console_ns.models[SuccessResponse.__name__],
|
||||
console_ns.models[EndpointMutationResponse.__name__],
|
||||
)
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
|
||||
@setup_required
|
||||
@@ -449,7 +452,7 @@ class DeprecatedEndpointUpdateApi(Resource):
|
||||
@with_current_tenant_id
|
||||
@model_validate(LegacyEndpointUpdatePayload)
|
||||
def post(self, req_data: LegacyEndpointUpdatePayload, tenant_id: str, user_id: str):
|
||||
return SuccessResponse(
|
||||
return EndpointMutationResponse(
|
||||
success=_legacy_update_endpoint(tenant_id=tenant_id, user_id=user_id, req_data=req_data)
|
||||
).model_dump(mode="json")
|
||||
|
||||
@@ -462,7 +465,7 @@ class EndpointEnableApi(Resource):
|
||||
@console_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Endpoint enabled successfully",
|
||||
console_ns.models[SuccessResponse.__name__],
|
||||
console_ns.models[EndpointMutationResponse.__name__],
|
||||
)
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
|
||||
@setup_required
|
||||
@@ -474,7 +477,7 @@ class EndpointEnableApi(Resource):
|
||||
@with_current_tenant_id
|
||||
@model_validate(EndpointIdPayload)
|
||||
def post(self, req_data: EndpointIdPayload, tenant_id: str, user_id: str):
|
||||
return SuccessResponse(
|
||||
return EndpointMutationResponse(
|
||||
success=_set_endpoint_enabled(tenant_id=tenant_id, user_id=user_id, req_data=req_data, enabled=True)
|
||||
).model_dump(mode="json")
|
||||
|
||||
@@ -487,7 +490,7 @@ class EndpointDisableApi(Resource):
|
||||
@console_ns.response(
|
||||
HTTPStatus.OK,
|
||||
"Endpoint disabled successfully",
|
||||
console_ns.models[SuccessResponse.__name__],
|
||||
console_ns.models[EndpointMutationResponse.__name__],
|
||||
)
|
||||
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
|
||||
@setup_required
|
||||
@@ -499,6 +502,6 @@ class EndpointDisableApi(Resource):
|
||||
@with_current_tenant_id
|
||||
@model_validate(EndpointIdPayload)
|
||||
def post(self, req_data: EndpointIdPayload, tenant_id: str, user_id: str):
|
||||
return SuccessResponse(
|
||||
return EndpointMutationResponse(
|
||||
success=_set_endpoint_enabled(tenant_id=tenant_id, user_id=user_id, req_data=req_data, enabled=False)
|
||||
).model_dump(mode="json")
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from core.plugin.entities.endpoint import EndpointEntityWithInstance
|
||||
from core.plugin.impl.base import BasePluginClient
|
||||
@@ -13,13 +13,13 @@ class PluginEndpointClient(BasePluginClient):
|
||||
plugin_unique_identifier: str,
|
||||
name: str,
|
||||
settings: dict[str, Any],
|
||||
) -> bool:
|
||||
) -> Literal[True]:
|
||||
"""
|
||||
Create an endpoint for the given plugin.
|
||||
|
||||
Errors will be raised if any error occurs.
|
||||
"""
|
||||
return self._request_with_plugin_daemon_response(
|
||||
success = self._request_with_plugin_daemon_response(
|
||||
"POST",
|
||||
f"plugin/{tenant_id}/endpoint/setup",
|
||||
bool,
|
||||
@@ -33,6 +33,9 @@ class PluginEndpointClient(BasePluginClient):
|
||||
"name": name,
|
||||
},
|
||||
)
|
||||
if not success:
|
||||
raise PluginDaemonInternalServerError("Plugin daemon failed to create the endpoint")
|
||||
return success
|
||||
|
||||
def list_endpoints(self, tenant_id: str, user_id: str, page: int, page_size: int):
|
||||
"""
|
||||
@@ -64,11 +67,11 @@ class PluginEndpointClient(BasePluginClient):
|
||||
|
||||
def update_endpoint(
|
||||
self, tenant_id: str, user_id: str, endpoint_id: str, name: str, settings: dict[str, Any]
|
||||
) -> bool:
|
||||
) -> Literal[True]:
|
||||
"""
|
||||
Update the settings of the given endpoint.
|
||||
"""
|
||||
return self._request_with_plugin_daemon_response(
|
||||
success = self._request_with_plugin_daemon_response(
|
||||
"POST",
|
||||
f"plugin/{tenant_id}/endpoint/update",
|
||||
bool,
|
||||
@@ -82,8 +85,11 @@ class PluginEndpointClient(BasePluginClient):
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
if not success:
|
||||
raise PluginDaemonInternalServerError("Plugin daemon failed to update the endpoint")
|
||||
return success
|
||||
|
||||
def delete_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str):
|
||||
def delete_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]:
|
||||
"""
|
||||
Delete the given endpoint.
|
||||
|
||||
@@ -91,7 +97,7 @@ class PluginEndpointClient(BasePluginClient):
|
||||
it will return True instead of raising an error.
|
||||
"""
|
||||
try:
|
||||
return self._request_with_plugin_daemon_response(
|
||||
success = self._request_with_plugin_daemon_response(
|
||||
"POST",
|
||||
f"plugin/{tenant_id}/endpoint/remove",
|
||||
bool,
|
||||
@@ -107,12 +113,15 @@ class PluginEndpointClient(BasePluginClient):
|
||||
if "record not found" in str(e.description).lower():
|
||||
return True
|
||||
raise
|
||||
if not success:
|
||||
raise PluginDaemonInternalServerError("Plugin daemon failed to delete the endpoint")
|
||||
return success
|
||||
|
||||
def enable_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str):
|
||||
def enable_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]:
|
||||
"""
|
||||
Enable the given endpoint.
|
||||
"""
|
||||
return self._request_with_plugin_daemon_response(
|
||||
success = self._request_with_plugin_daemon_response(
|
||||
"POST",
|
||||
f"plugin/{tenant_id}/endpoint/enable",
|
||||
bool,
|
||||
@@ -123,12 +132,15 @@ class PluginEndpointClient(BasePluginClient):
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
if not success:
|
||||
raise PluginDaemonInternalServerError("Plugin daemon failed to enable the endpoint")
|
||||
return success
|
||||
|
||||
def disable_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str):
|
||||
def disable_endpoint(self, tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]:
|
||||
"""
|
||||
Disable the given endpoint.
|
||||
"""
|
||||
return self._request_with_plugin_daemon_response(
|
||||
success = self._request_with_plugin_daemon_response(
|
||||
"POST",
|
||||
f"plugin/{tenant_id}/endpoint/disable",
|
||||
bool,
|
||||
@@ -139,3 +151,6 @@ class PluginEndpointClient(BasePluginClient):
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
if not success:
|
||||
raise PluginDaemonInternalServerError("Plugin daemon failed to disable the endpoint")
|
||||
return success
|
||||
@@ -10067,7 +10067,7 @@ Create a new plugin endpoint
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Endpoint created successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
|
||||
| 200 | Endpoint created successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
|
||||
| 403 | Admin privileges required | |
|
||||
|
||||
### ~~[POST] /workspaces/current/endpoints/create~~
|
||||
@@ -10086,7 +10086,7 @@ Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/cur
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Endpoint created successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
|
||||
| 200 | Endpoint created successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
|
||||
| 403 | Admin privileges required | |
|
||||
|
||||
### ~~[POST] /workspaces/current/endpoints/delete~~
|
||||
@@ -10105,7 +10105,7 @@ Deprecated legacy alias for deleting a plugin endpoint. Use DELETE /workspaces/c
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Endpoint deleted successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
|
||||
| 200 | Endpoint deleted successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
|
||||
| 403 | Admin privileges required | |
|
||||
|
||||
### [POST] /workspaces/current/endpoints/disable
|
||||
@@ -10121,7 +10121,7 @@ Disable a plugin endpoint
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Endpoint disabled successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
|
||||
| 200 | Endpoint disabled successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
|
||||
| 403 | Admin privileges required | |
|
||||
|
||||
### [POST] /workspaces/current/endpoints/enable
|
||||
@@ -10137,7 +10137,7 @@ Enable a plugin endpoint
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Endpoint enabled successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
|
||||
| 200 | Endpoint enabled successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
|
||||
| 403 | Admin privileges required | |
|
||||
|
||||
### [GET] /workspaces/current/endpoints/list
|
||||
@@ -10189,7 +10189,7 @@ Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/cu
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Endpoint updated successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
|
||||
| 200 | Endpoint updated successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
|
||||
| 403 | Admin privileges required | |
|
||||
|
||||
### [DELETE] /workspaces/current/endpoints/{id}
|
||||
@@ -10205,7 +10205,7 @@ Delete a plugin endpoint
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Endpoint deleted successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
|
||||
| 200 | Endpoint deleted successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
|
||||
| 403 | Admin privileges required | |
|
||||
|
||||
### [PATCH] /workspaces/current/endpoints/{id}
|
||||
@@ -10227,7 +10227,7 @@ Update a plugin endpoint
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Endpoint updated successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
|
||||
| 200 | Endpoint updated successfully | **application/json**: [EndpointMutationResponse](#endpointmutationresponse)<br> |
|
||||
| 403 | Admin privileges required | |
|
||||
|
||||
### [GET] /workspaces/current/members
|
||||
@@ -18178,6 +18178,12 @@ declaration of an endpoint
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| endpoints | [ [EndpointListItemResponse](#endpointlistitemresponse) ] | Endpoint information | Yes |
|
||||
|
||||
#### EndpointMutationResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| success | boolean | Always true on success. Failures are returned as HTTP errors. | Yes |
|
||||
|
||||
#### EndpointProviderConfigI18nResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from core.plugin.impl.endpoint import PluginEndpointClient
|
||||
|
||||
@@ -7,7 +7,7 @@ class EndpointService:
|
||||
@classmethod
|
||||
def create_endpoint(
|
||||
cls, tenant_id: str, user_id: str, plugin_unique_identifier: str, name: str, settings: dict[str, Any]
|
||||
):
|
||||
) -> Literal[True]:
|
||||
return PluginEndpointClient().create_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
@@ -36,7 +36,9 @@ class EndpointService:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str, name: str, settings: dict[str, Any]):
|
||||
def update_endpoint(
|
||||
cls, tenant_id: str, user_id: str, endpoint_id: str, name: str, settings: dict[str, Any]
|
||||
) -> Literal[True]:
|
||||
return PluginEndpointClient().update_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
@@ -46,7 +48,7 @@ class EndpointService:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def delete_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str):
|
||||
def delete_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]:
|
||||
return PluginEndpointClient().delete_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
@@ -54,7 +56,7 @@ class EndpointService:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def enable_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str):
|
||||
def enable_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]:
|
||||
return PluginEndpointClient().enable_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
@@ -62,7 +64,7 @@ class EndpointService:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def disable_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str):
|
||||
def disable_endpoint(cls, tenant_id: str, user_id: str, endpoint_id: str) -> Literal[True]:
|
||||
return PluginEndpointClient().disable_endpoint(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
|
||||
@@ -3,8 +3,10 @@ from datetime import UTC, datetime
|
||||
from types import FunctionType
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.rbac import Workspace
|
||||
from controllers.console import console_ns
|
||||
@@ -22,13 +24,15 @@ from controllers.console.workspace.endpoint import (
|
||||
EndpointListForPluginQuery,
|
||||
EndpointListForSinglePluginApi,
|
||||
EndpointListQuery,
|
||||
EndpointMutationResponse,
|
||||
EndpointUpdatePayload,
|
||||
LegacyEndpointUpdatePayload,
|
||||
)
|
||||
from controllers.console.wraps import RBACPermission
|
||||
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
|
||||
from core.plugin.entities.endpoint import EndpointEntityWithInstance, EndpointProviderDeclaration
|
||||
from core.plugin.impl.exc import PluginPermissionDeniedError
|
||||
from core.plugin.impl.exc import PluginDaemonInternalServerError, PluginPermissionDeniedError
|
||||
from libs.external_api import ExternalApi
|
||||
from tests.unit_tests.controllers.rbac_introspection import rbac_checks
|
||||
|
||||
|
||||
@@ -73,6 +77,36 @@ def test_endpoint_lists_require_management_permission(method: FunctionType) -> N
|
||||
|
||||
|
||||
class TestEndpointCollectionApi:
|
||||
@pytest.mark.parametrize("success", [True, False])
|
||||
def test_daemon_result_uses_http_success_or_error(self, success: bool) -> None:
|
||||
app = Flask(__name__)
|
||||
api = ExternalApi(app)
|
||||
method = inspect.unwrap(EndpointCollectionApi.post)
|
||||
payload = EndpointCreatePayload(plugin_unique_identifier="plugin-1", name="endpoint", settings={})
|
||||
|
||||
@api.route("/workspaces/current/endpoints")
|
||||
class EndpointMutationApi(Resource):
|
||||
def post(self) -> dict[str, object]:
|
||||
return method(EndpointCollectionApi(), payload, "t1", "u1")
|
||||
|
||||
with patch(
|
||||
"core.plugin.impl.base.BasePluginClient._request",
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
request=httpx.Request("POST", "https://daemon.test/endpoint/setup"),
|
||||
json={"code": 0, "message": "success", "data": success},
|
||||
),
|
||||
):
|
||||
response = app.test_client().post("/workspaces/current/endpoints")
|
||||
|
||||
if success:
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"success": True}
|
||||
else:
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()["status"] == 500
|
||||
assert "success" not in response.get_json()
|
||||
|
||||
def test_create_success(self, app: Flask):
|
||||
api = EndpointCollectionApi()
|
||||
method = inspect.unwrap(api.post)
|
||||
@@ -253,11 +287,13 @@ class TestEndpointItemApi:
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="DELETE"),
|
||||
patch("controllers.console.workspace.endpoint.EndpointService.delete_endpoint", return_value=False),
|
||||
patch(
|
||||
"controllers.console.workspace.endpoint.EndpointService.delete_endpoint",
|
||||
side_effect=PluginDaemonInternalServerError("failed"),
|
||||
),
|
||||
):
|
||||
result = method(api, "t1", "u1", "e1")
|
||||
|
||||
assert result["success"] is False
|
||||
with pytest.raises(PluginDaemonInternalServerError):
|
||||
method(api, "t1", "u1", "e1")
|
||||
|
||||
def test_update_success(self, app: Flask):
|
||||
api = EndpointItemApi()
|
||||
@@ -311,11 +347,13 @@ class TestEndpointItemApi:
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="PATCH", json=payload),
|
||||
patch("controllers.console.workspace.endpoint.EndpointService.update_endpoint", return_value=False),
|
||||
patch(
|
||||
"controllers.console.workspace.endpoint.EndpointService.update_endpoint",
|
||||
side_effect=PluginDaemonInternalServerError("failed"),
|
||||
),
|
||||
):
|
||||
result = method(api, req_data, "t1", "u1", "e1")
|
||||
|
||||
assert result["success"] is False
|
||||
with pytest.raises(PluginDaemonInternalServerError):
|
||||
method(api, req_data, "t1", "u1", "e1")
|
||||
|
||||
|
||||
class TestDeprecatedEndpointDeleteApi:
|
||||
@@ -353,11 +391,13 @@ class TestDeprecatedEndpointDeleteApi:
|
||||
|
||||
with (
|
||||
app.test_request_context("/", json=payload),
|
||||
patch("controllers.console.workspace.endpoint.EndpointService.delete_endpoint", return_value=False),
|
||||
patch(
|
||||
"controllers.console.workspace.endpoint.EndpointService.delete_endpoint",
|
||||
side_effect=PluginDaemonInternalServerError("failed"),
|
||||
),
|
||||
):
|
||||
result = method(api, req_data, "t1", "u1")
|
||||
|
||||
assert result["success"] is False
|
||||
with pytest.raises(PluginDaemonInternalServerError):
|
||||
method(api, req_data, "t1", "u1")
|
||||
|
||||
|
||||
class TestDeprecatedEndpointUpdateApi:
|
||||
@@ -405,14 +445,22 @@ class TestDeprecatedEndpointUpdateApi:
|
||||
|
||||
with (
|
||||
app.test_request_context("/", json=payload),
|
||||
patch("controllers.console.workspace.endpoint.EndpointService.update_endpoint", return_value=False),
|
||||
patch(
|
||||
"controllers.console.workspace.endpoint.EndpointService.update_endpoint",
|
||||
side_effect=PluginDaemonInternalServerError("failed"),
|
||||
),
|
||||
):
|
||||
result = method(api, req_data, "t1", "u1")
|
||||
|
||||
assert result["success"] is False
|
||||
with pytest.raises(PluginDaemonInternalServerError):
|
||||
method(api, req_data, "t1", "u1")
|
||||
|
||||
|
||||
class TestEndpointRouteMetadata:
|
||||
def test_mutation_response_only_documents_success(self) -> None:
|
||||
success_schema = EndpointMutationResponse.model_json_schema()["properties"]["success"]
|
||||
|
||||
assert success_schema["const"] is True
|
||||
assert success_schema["type"] == "boolean"
|
||||
|
||||
def test_legacy_write_routes_are_marked_deprecated(self):
|
||||
assert DeprecatedEndpointCreateApi.post.__apidoc__["deprecated"] is True
|
||||
assert DeprecatedEndpointDeleteApi.post.__apidoc__["deprecated"] is True
|
||||
@@ -477,11 +525,13 @@ class TestEndpointEnableApi:
|
||||
|
||||
with (
|
||||
app.test_request_context("/", json=payload),
|
||||
patch("controllers.console.workspace.endpoint.EndpointService.enable_endpoint", return_value=False),
|
||||
patch(
|
||||
"controllers.console.workspace.endpoint.EndpointService.enable_endpoint",
|
||||
side_effect=PluginDaemonInternalServerError("failed"),
|
||||
),
|
||||
):
|
||||
result = method(api, req_data, "t1", "u1")
|
||||
|
||||
assert result["success"] is False
|
||||
with pytest.raises(PluginDaemonInternalServerError):
|
||||
method(api, req_data, "t1", "u1")
|
||||
|
||||
|
||||
class TestEndpointDisableApi:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
@@ -6,13 +9,46 @@ from core.plugin.impl.exc import PluginDaemonInternalServerError
|
||||
|
||||
|
||||
class TestPluginEndpointClientImpl:
|
||||
@pytest.mark.parametrize(
|
||||
"mutate",
|
||||
[
|
||||
pytest.param(
|
||||
lambda client: client.create_endpoint("tenant-1", "user-1", "org/plugin:1", "endpoint-a", {}),
|
||||
id="create",
|
||||
),
|
||||
pytest.param(
|
||||
lambda client: client.update_endpoint("tenant-1", "user-1", "endpoint-1", "renamed", {}),
|
||||
id="update",
|
||||
),
|
||||
pytest.param(lambda client: client.delete_endpoint("tenant-1", "user-1", "endpoint-1"), id="delete"),
|
||||
pytest.param(lambda client: client.enable_endpoint("tenant-1", "user-1", "endpoint-1"), id="enable"),
|
||||
pytest.param(lambda client: client.disable_endpoint("tenant-1", "user-1", "endpoint-1"), id="disable"),
|
||||
],
|
||||
)
|
||||
def test_mutation_rejects_false_daemon_result(
|
||||
self, mocker: MockerFixture, mutate: Callable[[PluginEndpointClient], bool]
|
||||
) -> None:
|
||||
client = PluginEndpointClient()
|
||||
mocker.patch.object(
|
||||
client,
|
||||
"_request",
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
request=httpx.Request("POST", "https://daemon.test/endpoint"),
|
||||
json={"code": 0, "message": "success", "data": False},
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(PluginDaemonInternalServerError, match="Plugin daemon failed to"):
|
||||
mutate(client)
|
||||
|
||||
def test_create_endpoint(self, mocker: MockerFixture):
|
||||
client = PluginEndpointClient()
|
||||
request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=True)
|
||||
|
||||
result = client.create_endpoint("tenant-1", "user-1", "org/plugin:1", "endpoint-a", {"k": "v"})
|
||||
|
||||
assert result is True
|
||||
assert result
|
||||
assert request_mock.call_count == 1
|
||||
args = request_mock.call_args.args
|
||||
kwargs = request_mock.call_args.kwargs
|
||||
@@ -49,15 +85,15 @@ class TestPluginEndpointClientImpl:
|
||||
|
||||
result = client.update_endpoint("tenant-1", "user-1", "endpoint-1", "renamed", {"x": 1})
|
||||
|
||||
assert result is True
|
||||
assert result
|
||||
assert request_mock.call_args.args[:3] == ("POST", "plugin/tenant-1/endpoint/update", bool)
|
||||
|
||||
def test_enable_and_disable_endpoint(self, mocker: MockerFixture):
|
||||
client = PluginEndpointClient()
|
||||
request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=True)
|
||||
|
||||
assert client.enable_endpoint("tenant-1", "user-1", "endpoint-1") is True
|
||||
assert client.disable_endpoint("tenant-1", "user-1", "endpoint-1") is True
|
||||
assert client.enable_endpoint("tenant-1", "user-1", "endpoint-1")
|
||||
assert client.disable_endpoint("tenant-1", "user-1", "endpoint-1")
|
||||
|
||||
calls = request_mock.call_args_list
|
||||
assert calls[0].args[1] == "plugin/tenant-1/endpoint/enable"
|
||||
@@ -68,7 +104,7 @@ class TestPluginEndpointClientImpl:
|
||||
request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response")
|
||||
|
||||
request_mock.side_effect = PluginDaemonInternalServerError("record not found")
|
||||
assert client.delete_endpoint("tenant-1", "user-1", "endpoint-1") is True
|
||||
assert client.delete_endpoint("tenant-1", "user-1", "endpoint-1")
|
||||
|
||||
request_mock.side_effect = PluginDaemonInternalServerError("permission denied")
|
||||
with pytest.raises(PluginDaemonInternalServerError) as exc_info:
|
||||
|
||||
@@ -2582,16 +2582,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/model-list.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -2689,7 +2679,7 @@
|
||||
},
|
||||
"web/app/components/plugins/types.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 25
|
||||
"count": 24
|
||||
}
|
||||
},
|
||||
"web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx": {
|
||||
@@ -5038,17 +5028,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-endpoints.ts": {
|
||||
"@tanstack/query/prefer-query-options": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"web/service/use-explore.ts": {
|
||||
"@tanstack/query/prefer-query-options": {
|
||||
"count": 2
|
||||
|
||||
@@ -129,8 +129,8 @@ export type EndpointCreatePayload = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SuccessResponse = {
|
||||
success: boolean
|
||||
export type EndpointMutationResponse = {
|
||||
success: true
|
||||
}
|
||||
|
||||
export type EndpointIdPayload = {
|
||||
@@ -391,6 +391,10 @@ export type ParserExcludePlugin = {
|
||||
plugin_id: string
|
||||
}
|
||||
|
||||
export type SuccessResponse = {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export type PluginAutoUpgradeFetchResponse = {
|
||||
auto_upgrade: PluginAutoUpgradeSettingsResponseModel
|
||||
category: TenantPluginAutoUpgradeCategory
|
||||
@@ -3147,7 +3151,7 @@ export type PostWorkspacesCurrentEndpointsErrors = {
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentEndpointsResponses = {
|
||||
200: SuccessResponse
|
||||
200: EndpointMutationResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentEndpointsResponse =
|
||||
@@ -3165,7 +3169,7 @@ export type PostWorkspacesCurrentEndpointsCreateErrors = {
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentEndpointsCreateResponses = {
|
||||
200: SuccessResponse
|
||||
200: EndpointMutationResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentEndpointsCreateResponse =
|
||||
@@ -3183,7 +3187,7 @@ export type PostWorkspacesCurrentEndpointsDeleteErrors = {
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentEndpointsDeleteResponses = {
|
||||
200: SuccessResponse
|
||||
200: EndpointMutationResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentEndpointsDeleteResponse =
|
||||
@@ -3201,7 +3205,7 @@ export type PostWorkspacesCurrentEndpointsDisableErrors = {
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentEndpointsDisableResponses = {
|
||||
200: SuccessResponse
|
||||
200: EndpointMutationResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentEndpointsDisableResponse =
|
||||
@@ -3219,7 +3223,7 @@ export type PostWorkspacesCurrentEndpointsEnableErrors = {
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentEndpointsEnableResponses = {
|
||||
200: SuccessResponse
|
||||
200: EndpointMutationResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentEndpointsEnableResponse =
|
||||
@@ -3272,7 +3276,7 @@ export type PostWorkspacesCurrentEndpointsUpdateErrors = {
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentEndpointsUpdateResponses = {
|
||||
200: SuccessResponse
|
||||
200: EndpointMutationResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentEndpointsUpdateResponse =
|
||||
@@ -3292,7 +3296,7 @@ export type DeleteWorkspacesCurrentEndpointsByIdErrors = {
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentEndpointsByIdResponses = {
|
||||
200: SuccessResponse
|
||||
200: EndpointMutationResponse
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentEndpointsByIdResponse =
|
||||
@@ -3312,7 +3316,7 @@ export type PatchWorkspacesCurrentEndpointsByIdErrors = {
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentEndpointsByIdResponses = {
|
||||
200: SuccessResponse
|
||||
200: EndpointMutationResponse
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentEndpointsByIdResponse =
|
||||
|
||||
@@ -63,10 +63,10 @@ export const zEndpointCreatePayload = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* SuccessResponse
|
||||
* EndpointMutationResponse
|
||||
*/
|
||||
export const zSuccessResponse = z.object({
|
||||
success: z.boolean(),
|
||||
export const zEndpointMutationResponse = z.object({
|
||||
success: z.literal(true),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -278,6 +278,13 @@ export const zPluginAutoUpgradeChangeResponse = z.object({
|
||||
success: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SuccessResponse
|
||||
*/
|
||||
export const zSuccessResponse = z.object({
|
||||
success: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* PluginDebuggingKeyResponse
|
||||
*/
|
||||
@@ -4118,35 +4125,35 @@ export const zPostWorkspacesCurrentEndpointsBody = zEndpointCreatePayload
|
||||
/**
|
||||
* Endpoint created successfully
|
||||
*/
|
||||
export const zPostWorkspacesCurrentEndpointsResponse = zSuccessResponse
|
||||
export const zPostWorkspacesCurrentEndpointsResponse = zEndpointMutationResponse
|
||||
|
||||
export const zPostWorkspacesCurrentEndpointsCreateBody = zEndpointCreatePayload
|
||||
|
||||
/**
|
||||
* Endpoint created successfully
|
||||
*/
|
||||
export const zPostWorkspacesCurrentEndpointsCreateResponse = zSuccessResponse
|
||||
export const zPostWorkspacesCurrentEndpointsCreateResponse = zEndpointMutationResponse
|
||||
|
||||
export const zPostWorkspacesCurrentEndpointsDeleteBody = zEndpointIdPayload
|
||||
|
||||
/**
|
||||
* Endpoint deleted successfully
|
||||
*/
|
||||
export const zPostWorkspacesCurrentEndpointsDeleteResponse = zSuccessResponse
|
||||
export const zPostWorkspacesCurrentEndpointsDeleteResponse = zEndpointMutationResponse
|
||||
|
||||
export const zPostWorkspacesCurrentEndpointsDisableBody = zEndpointIdPayload
|
||||
|
||||
/**
|
||||
* Endpoint disabled successfully
|
||||
*/
|
||||
export const zPostWorkspacesCurrentEndpointsDisableResponse = zSuccessResponse
|
||||
export const zPostWorkspacesCurrentEndpointsDisableResponse = zEndpointMutationResponse
|
||||
|
||||
export const zPostWorkspacesCurrentEndpointsEnableBody = zEndpointIdPayload
|
||||
|
||||
/**
|
||||
* Endpoint enabled successfully
|
||||
*/
|
||||
export const zPostWorkspacesCurrentEndpointsEnableResponse = zSuccessResponse
|
||||
export const zPostWorkspacesCurrentEndpointsEnableResponse = zEndpointMutationResponse
|
||||
|
||||
export const zGetWorkspacesCurrentEndpointsListQuery = z.object({
|
||||
page: z.int().gte(1),
|
||||
@@ -4174,7 +4181,7 @@ export const zPostWorkspacesCurrentEndpointsUpdateBody = zLegacyEndpointUpdatePa
|
||||
/**
|
||||
* Endpoint updated successfully
|
||||
*/
|
||||
export const zPostWorkspacesCurrentEndpointsUpdateResponse = zSuccessResponse
|
||||
export const zPostWorkspacesCurrentEndpointsUpdateResponse = zEndpointMutationResponse
|
||||
|
||||
export const zDeleteWorkspacesCurrentEndpointsByIdPath = z.object({
|
||||
id: z.string(),
|
||||
@@ -4183,7 +4190,7 @@ export const zDeleteWorkspacesCurrentEndpointsByIdPath = z.object({
|
||||
/**
|
||||
* Endpoint deleted successfully
|
||||
*/
|
||||
export const zDeleteWorkspacesCurrentEndpointsByIdResponse = zSuccessResponse
|
||||
export const zDeleteWorkspacesCurrentEndpointsByIdResponse = zEndpointMutationResponse
|
||||
|
||||
export const zPatchWorkspacesCurrentEndpointsByIdBody = zEndpointUpdatePayload
|
||||
|
||||
@@ -4194,7 +4201,7 @@ export const zPatchWorkspacesCurrentEndpointsByIdPath = z.object({
|
||||
/**
|
||||
* Endpoint updated successfully
|
||||
*/
|
||||
export const zPatchWorkspacesCurrentEndpointsByIdResponse = zSuccessResponse
|
||||
export const zPatchWorkspacesCurrentEndpointsByIdResponse = zEndpointMutationResponse
|
||||
|
||||
/**
|
||||
* Success
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
import type { EndpointListItem, PluginDetail } from '../../types'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import EndpointCard from '../endpoint-card'
|
||||
|
||||
const mockHandleChange = vi.fn()
|
||||
const mockEnableEndpoint = vi.fn()
|
||||
const mockDisableEndpoint = vi.fn()
|
||||
const mockDeleteEndpoint = vi.fn()
|
||||
const mockUpdateEndpoint = vi.fn()
|
||||
const mockToastNotify = vi.fn()
|
||||
|
||||
const getEditButton = () => screen.getByRole('button', { name: 'common.operation.edit' })
|
||||
const getDeleteButton = () => screen.getByRole('button', { name: 'common.operation.delete' })
|
||||
const getCopyButton = () => screen.getByRole('button', { name: 'common.operation.copy' })
|
||||
|
||||
vi.mock('@/app/notifications', () => ({
|
||||
toast: Object.assign(
|
||||
(message: string, options?: { type?: string }) =>
|
||||
mockToastNotify({ type: options?.type, message }),
|
||||
{
|
||||
success: (message: string) => mockToastNotify({ type: 'success', message }),
|
||||
error: (message: string) => mockToastNotify({ type: 'error', message }),
|
||||
warning: (message: string) => mockToastNotify({ type: 'warning', message }),
|
||||
info: (message: string) => mockToastNotify({ type: 'info', message }),
|
||||
dismiss: vi.fn(),
|
||||
update: vi.fn(),
|
||||
promise: vi.fn(),
|
||||
},
|
||||
),
|
||||
}))
|
||||
|
||||
// Flags to control whether operations should fail
|
||||
const failureFlags = {
|
||||
enable: false,
|
||||
disable: false,
|
||||
delete: false,
|
||||
update: false,
|
||||
}
|
||||
|
||||
vi.mock('@/service/use-endpoints', () => ({
|
||||
useEnableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void; onError: () => void }) => ({
|
||||
mutate: (id: string) => {
|
||||
mockEnableEndpoint(id)
|
||||
if (failureFlags.enable) onError()
|
||||
else onSuccess()
|
||||
},
|
||||
}),
|
||||
useDisableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void; onError: () => void }) => ({
|
||||
mutate: (id: string) => {
|
||||
mockDisableEndpoint(id)
|
||||
if (failureFlags.disable) onError()
|
||||
else onSuccess()
|
||||
},
|
||||
}),
|
||||
useDeleteEndpoint: ({ onSuccess, onError }: { onSuccess: () => void; onError: () => void }) => ({
|
||||
mutate: (id: string) => {
|
||||
mockDeleteEndpoint(id)
|
||||
if (failureFlags.delete) onError()
|
||||
else onSuccess()
|
||||
},
|
||||
}),
|
||||
useUpdateEndpoint: ({ onSuccess, onError }: { onSuccess: () => void; onError: () => void }) => ({
|
||||
mutate: (data: unknown) => {
|
||||
mockUpdateEndpoint(data)
|
||||
if (failureFlags.update) onError()
|
||||
else onSuccess()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/status-dot', () => ({
|
||||
StatusDot: ({ status }: { status: string }) => (
|
||||
<span data-testid="indicator" data-status={status} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||
toolCredentialToFormSchemas: (schemas: unknown[]) => schemas,
|
||||
addDefaultValue: (value: unknown) => value,
|
||||
}))
|
||||
|
||||
vi.mock('../endpoint-modal', () => ({
|
||||
default: ({ onCancel, onSaved }: { onCancel: () => void; onSaved: (state: unknown) => void }) => (
|
||||
<div data-testid="endpoint-modal">
|
||||
<button data-testid="modal-cancel" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button data-testid="modal-save" onClick={() => onSaved({ name: 'Updated' })}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockEndpointData: EndpointListItem = {
|
||||
id: 'ep-1',
|
||||
name: 'Test Endpoint',
|
||||
url: 'https://api.example.com',
|
||||
enabled: true,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-02',
|
||||
settings: {},
|
||||
tenant_id: 'tenant-1',
|
||||
plugin_id: 'plugin-1',
|
||||
expired_at: '',
|
||||
hook_id: 'hook-1',
|
||||
declaration: {
|
||||
settings: [],
|
||||
endpoints: [
|
||||
{ path: '/api/test', method: 'GET' },
|
||||
{ path: '/api/hidden', method: 'POST', hidden: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const mockPluginDetail: 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: {} 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: 'marketplace' as PluginDetail['source'],
|
||||
meta: undefined,
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
}
|
||||
|
||||
describe('EndpointCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset failure flags
|
||||
failureFlags.enable = false
|
||||
failureFlags.disable = false
|
||||
failureFlags.delete = false
|
||||
failureFlags.update = false
|
||||
// Polyfill document.execCommand for copy-to-clipboard in the test DOM runtime
|
||||
if (typeof document.execCommand !== 'function') {
|
||||
document.execCommand = vi.fn().mockReturnValue(true)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const waitForAlertDialogToClose = async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render endpoint name', () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Endpoint'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render visible endpoints only', () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('GET'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.example.com/api/test'))!.toBeInTheDocument()
|
||||
expect(screen.queryByText('POST')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show active status when enabled', () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.serviceOk'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('indicator'))!.toHaveAttribute('data-status', 'success')
|
||||
})
|
||||
|
||||
it('should show disabled status when not enabled', () => {
|
||||
const disabledData = { ...mockEndpointData, enabled: false }
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={disabledData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.disabled'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('indicator'))!.toHaveAttribute('data-status', 'disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should reach endpoint actions through the tab order', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.tab()
|
||||
expect(getEditButton()).toHaveFocus()
|
||||
|
||||
await user.tab()
|
||||
expect(getDeleteButton()).toHaveFocus()
|
||||
|
||||
await user.keyboard('{Enter}')
|
||||
expect(screen.getByText('plugin.detailPanel.endpointDeleteTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show disable confirm when switching off', () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.endpointDisableTip'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call disableEndpoint when confirm disable', () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
// Click confirm button in the Confirm dialog
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
expect(mockDisableEndpoint).toHaveBeenCalledWith('ep-1')
|
||||
})
|
||||
|
||||
it('should show delete confirm when delete clicked', () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(getDeleteButton())
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.endpointDeleteTip'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call deleteEndpoint when confirm delete', () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(getDeleteButton())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
expect(mockDeleteEndpoint).toHaveBeenCalledWith('ep-1')
|
||||
})
|
||||
|
||||
it('should show edit modal when edit clicked', () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(getEditButton())
|
||||
|
||||
expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call updateEndpoint when save in modal', () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(getEditButton())
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
|
||||
expect(mockUpdateEndpoint).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copy Functionality', () => {
|
||||
it('should reset copy state after timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(getCopyButton())
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Test Endpoint'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty endpoints', () => {
|
||||
const dataWithNoEndpoints = {
|
||||
...mockEndpointData,
|
||||
declaration: { settings: [], endpoints: [] },
|
||||
}
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={dataWithNoEndpoints}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Endpoint'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleChange after enable', () => {
|
||||
const disabledData = { ...mockEndpointData, enabled: false }
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={disabledData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(mockHandleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should hide disable confirm and revert state when cancel clicked', async () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
expect(screen.getByText('plugin.detailPanel.endpointDisableTip'))!.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
await waitForAlertDialogToClose()
|
||||
|
||||
expect(screen.getByRole('switch'))!.toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should hide delete confirm when cancel clicked', async () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(getDeleteButton())
|
||||
expect(screen.getByText('plugin.detailPanel.endpointDeleteTip'))!.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
await waitForAlertDialogToClose()
|
||||
})
|
||||
|
||||
it('should hide edit modal when cancel clicked', () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(getEditButton())
|
||||
expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
|
||||
expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should show error toast when enable fails', () => {
|
||||
failureFlags.enable = true
|
||||
const disabledData = { ...mockEndpointData, enabled: false }
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={disabledData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(mockEnableEndpoint).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error toast when disable fails', () => {
|
||||
failureFlags.disable = true
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
expect(mockDisableEndpoint).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error toast when delete fails', () => {
|
||||
failureFlags.delete = true
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(getDeleteButton())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
expect(mockDeleteEndpoint).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error toast when update fails', () => {
|
||||
render(
|
||||
<EndpointCard
|
||||
pluginDetail={mockPluginDetail}
|
||||
data={mockEndpointData}
|
||||
handleChange={mockHandleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(getEditButton())
|
||||
|
||||
expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument()
|
||||
|
||||
failureFlags.update = true
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
|
||||
expect(mockUpdateEndpoint).toHaveBeenCalled()
|
||||
expect(mockHandleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { normalizeInstalledPluginDetail } from '@/service/use-plugins'
|
||||
|
||||
export const createPluginDetail = () =>
|
||||
normalizeInstalledPluginDetail({
|
||||
id: 'test-id',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-02',
|
||||
plugin_id: 'test-plugin',
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
tenant_id: 'tenant-1',
|
||||
endpoints_setups: 1,
|
||||
endpoints_active: 1,
|
||||
version: '1.0.0',
|
||||
source: 'marketplace',
|
||||
runtime_type: 'local',
|
||||
checksum: 'checksum',
|
||||
meta: {},
|
||||
declaration: {
|
||||
version: '1.0.0',
|
||||
author: 'Dify',
|
||||
name: 'Test Plugin',
|
||||
category: 'extension',
|
||||
created_at: '2024-01-01',
|
||||
icon: 'plugin.svg',
|
||||
label: { en_US: 'Test Plugin' },
|
||||
description: { en_US: 'Endpoint plugin' },
|
||||
resource: {},
|
||||
plugins: {},
|
||||
meta: {},
|
||||
endpoint: {
|
||||
settings: [
|
||||
{ name: 'enabled', type: 'boolean', default: false },
|
||||
{ name: 'count', type: 'text-input', default: '0' },
|
||||
{ name: 'token', type: 'secret-input', default: '' },
|
||||
],
|
||||
endpoints: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,221 +1,233 @@
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import type { EndpointListItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import EndpointList from '../endpoint-list'
|
||||
import { createPluginDetail } from './endpoint-fixture'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/cn', () => ({
|
||||
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
const { request, showError } = vi.hoisted(() => ({ request: vi.fn(), showError: vi.fn() }))
|
||||
vi.mock('@/service/base', () => ({ request }))
|
||||
vi.mock('@/app/notifications', () => ({ toast: { error: showError } }))
|
||||
vi.mock('../../readme-panel/entrance', () => ({ ReadmeEntrance: () => null }))
|
||||
|
||||
const mockEndpoints = [
|
||||
{
|
||||
id: 'ep-1',
|
||||
name: 'Endpoint 1',
|
||||
url: 'https://api.example.com',
|
||||
declaration: { settings: [], endpoints: [] },
|
||||
},
|
||||
]
|
||||
const detail = createPluginDetail()
|
||||
|
||||
let mockEndpointListData: { endpoints: typeof mockEndpoints } | undefined
|
||||
|
||||
const mockInvalidateEndpointList = vi.fn()
|
||||
const mockInvalidateInstalledPluginList = vi.fn()
|
||||
const mockCreateEndpoint = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-endpoints', () => ({
|
||||
useEndpointList: () => ({ data: mockEndpointListData }),
|
||||
useInvalidateEndpointList: () => mockInvalidateEndpointList,
|
||||
useCreateEndpoint: ({ onSuccess }: { onSuccess: () => void }) => ({
|
||||
mutate: (data: unknown) => {
|
||||
mockCreateEndpoint(data)
|
||||
onSuccess()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||
toolCredentialToFormSchemas: (schemas: unknown[]) => schemas,
|
||||
}))
|
||||
|
||||
vi.mock('../endpoint-card', () => ({
|
||||
default: ({ data }: { data: { name: string } }) => (
|
||||
<div data-testid="endpoint-card">{data.name}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../endpoint-modal', () => ({
|
||||
default: ({ onCancel, onSaved }: { onCancel: () => void; onSaved: (state: unknown) => void }) => (
|
||||
<div data-testid="endpoint-modal">
|
||||
<button data-testid="modal-cancel" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button data-testid="modal-save" onClick={() => onSaved({ name: 'New Endpoint' })}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createPluginDetail = (): PluginDetail => ({
|
||||
id: 'test-id',
|
||||
const createEndpoint = (): EndpointListItemResponse => ({
|
||||
id: 'ep-1',
|
||||
name: 'Endpoint 1',
|
||||
url: 'https://api.example.com',
|
||||
enabled: true,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-02',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'test-plugin',
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
declaration: {
|
||||
endpoint: { settings: [], endpoints: [] },
|
||||
tool: undefined,
|
||||
} as unknown as PluginDetail['declaration'],
|
||||
installation_id: 'install-1',
|
||||
settings: { enabled: false, count: '0', token: '', retry_count: 0 },
|
||||
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: 'marketplace' as PluginDetail['source'],
|
||||
meta: undefined,
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
plugin_id: 'test-plugin',
|
||||
expired_at: '',
|
||||
hook_id: 'hook-1',
|
||||
declaration: {
|
||||
settings: detail.declaration.endpoint?.settings ?? [],
|
||||
endpoints: [
|
||||
{ method: 'GET', path: '/public' },
|
||||
{ method: 'POST', path: '/hidden', hidden: true },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
describe('EndpointList', () => {
|
||||
const getAddButton = () =>
|
||||
screen.getByRole('button', { name: 'plugin.detailPanel.endpointModalTitle' })
|
||||
let endpoints: EndpointListItemResponse[]
|
||||
let rejectMutation: boolean
|
||||
let failAfterWrite: boolean
|
||||
let mutationGate: Promise<void> | undefined
|
||||
const mutations: { path: string; method: string; body: unknown }[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEndpointListData = { endpoints: mockEndpoints }
|
||||
const renderEndpoints = () => render(<EndpointList detail={detail} />)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
endpoints = [createEndpoint()]
|
||||
rejectMutation = false
|
||||
failAfterWrite = false
|
||||
mutationGate = undefined
|
||||
mutations.length = 0
|
||||
request.mockImplementation(
|
||||
async (url: string, _init: RequestInit, options: { request: Request }) => {
|
||||
const method = options.request.method
|
||||
const path = new URL(url).pathname.replace(/^.*\/workspaces/, '/workspaces')
|
||||
if (method === 'GET') return Response.json({ endpoints })
|
||||
const body: unknown = options.request.body ? await options.request.json() : undefined
|
||||
mutations.push({ path, method, body })
|
||||
await mutationGate
|
||||
if (rejectMutation && !failAfterWrite)
|
||||
return Response.json({ message: 'Failed' }, { status: 500 })
|
||||
if (path.endsWith('/enable'))
|
||||
endpoints = endpoints.map((item) => ({ ...item, enabled: true }))
|
||||
else if (path.endsWith('/disable'))
|
||||
endpoints = endpoints.map((item) => ({ ...item, enabled: false }))
|
||||
else if (method === 'DELETE') endpoints = []
|
||||
else endpoints = [{ ...createEndpoint(), name: 'Saved endpoint' }]
|
||||
if (rejectMutation) return Response.json({ message: 'Failed after write' }, { status: 500 })
|
||||
return Response.json({ success: true })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Endpoint management', () => {
|
||||
it('loads the plugin endpoints and only displays public paths', async () => {
|
||||
renderEndpoints()
|
||||
expect(await screen.findByText('Endpoint 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.example.com/public')).toBeInTheDocument()
|
||||
expect(screen.queryByText('https://api.example.com/hidden')).not.toBeInTheDocument()
|
||||
const requestedURL = new URL(request.mock.calls[0]![0])
|
||||
expect(requestedURL.searchParams.get('plugin_id')).toBe('test-plugin')
|
||||
expect(requestedURL.searchParams.get('page')).toBe('1')
|
||||
expect(requestedURL.searchParams.get('page_size')).toBe('100')
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render endpoint list', () => {
|
||||
render(<EndpointList detail={createPluginDetail()} />)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.endpoints'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render endpoint cards', () => {
|
||||
render(<EndpointList detail={createPluginDetail()} />)
|
||||
|
||||
expect(screen.getByTestId('endpoint-card'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Endpoint 1'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null when no data', () => {
|
||||
mockEndpointListData = undefined
|
||||
const { container } = render(<EndpointList detail={createPluginDetail()} />)
|
||||
|
||||
expect(container)!.toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should show empty message when no endpoints', () => {
|
||||
mockEndpointListData = { endpoints: [] }
|
||||
render(<EndpointList detail={createPluginDetail()} />)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.endpointsEmpty'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render add button', () => {
|
||||
render(<EndpointList detail={createPluginDetail()} />)
|
||||
|
||||
expect(getAddButton()).toBeInTheDocument()
|
||||
})
|
||||
it('creates through the canonical route and refreshes the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
endpoints = []
|
||||
renderEndpoints()
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'plugin.detailPanel.endpointModalTitle' }),
|
||||
)
|
||||
await user.clear(screen.getByPlaceholderText('Endpoint Name'))
|
||||
await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Saved endpoint')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(await screen.findByText('Saved endpoint')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(mutations).toEqual([
|
||||
{
|
||||
path: '/workspaces/current/endpoints',
|
||||
method: 'POST',
|
||||
body: {
|
||||
plugin_unique_identifier: 'test-uid',
|
||||
name: 'Saved endpoint',
|
||||
settings: { enabled: false, count: '0', token: '' },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should show modal when add button clicked', () => {
|
||||
render(<EndpointList detail={createPluginDetail()} />)
|
||||
|
||||
fireEvent.click(getAddButton())
|
||||
|
||||
expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide modal when cancel clicked', () => {
|
||||
render(<EndpointList detail={createPluginDetail()} />)
|
||||
|
||||
fireEvent.click(getAddButton())
|
||||
expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call createEndpoint when save clicked', () => {
|
||||
render(<EndpointList detail={createPluginDetail()} />)
|
||||
|
||||
fireEvent.click(getAddButton())
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
|
||||
expect(mockCreateEndpoint).toHaveBeenCalled()
|
||||
})
|
||||
it('updates through the canonical item route without dropping falsy settings', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderEndpoints()
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
await user.clear(screen.getByPlaceholderText('Endpoint Name'))
|
||||
await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Saved endpoint')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(await screen.findByText('Saved endpoint')).toBeInTheDocument()
|
||||
expect(mutations).toEqual([
|
||||
{
|
||||
path: '/workspaces/current/endpoints/ep-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: 'Saved endpoint',
|
||||
settings: { enabled: false, count: '0', token: '', retry_count: 0 },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('Border Style', () => {
|
||||
it('should render with border style based on tool existence', () => {
|
||||
const detail = createPluginDetail()
|
||||
detail.declaration.tool = {} as PluginDetail['declaration']['tool']
|
||||
render(<EndpointList detail={detail} />)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.endpoints'))!.toBeInTheDocument()
|
||||
})
|
||||
it('keeps enabled state when disabling is cancelled, then follows refetched state after confirm', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderEndpoints()
|
||||
await user.click(await screen.findByRole('switch'))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(mutations).toHaveLength(0)
|
||||
await user.click(screen.getByRole('switch'))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false'))
|
||||
await user.click(screen.getByRole('switch'))
|
||||
await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'))
|
||||
expect(mutations.map(({ path, body }) => ({ path, body }))).toEqual([
|
||||
{ path: '/workspaces/current/endpoints/disable', body: { endpoint_id: 'ep-1' } },
|
||||
{ path: '/workspaces/current/endpoints/enable', body: { endpoint_id: 'ep-1' } },
|
||||
])
|
||||
})
|
||||
|
||||
describe('Multiple Endpoints', () => {
|
||||
it('should render multiple endpoint cards', () => {
|
||||
mockEndpointListData = {
|
||||
endpoints: [
|
||||
{
|
||||
id: 'ep-1',
|
||||
name: 'Endpoint 1',
|
||||
url: 'https://api1.example.com',
|
||||
declaration: { settings: [], endpoints: [] },
|
||||
},
|
||||
{
|
||||
id: 'ep-2',
|
||||
name: 'Endpoint 2',
|
||||
url: 'https://api2.example.com',
|
||||
declaration: { settings: [], endpoints: [] },
|
||||
},
|
||||
],
|
||||
}
|
||||
render(<EndpointList detail={createPluginDetail()} />)
|
||||
|
||||
expect(screen.getAllByTestId('endpoint-card')).toHaveLength(2)
|
||||
})
|
||||
it('deletes through the canonical route and refreshes the empty state', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderEndpoints()
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
expect(await screen.findByText('plugin.detailPanel.endpointsEmpty')).toBeInTheDocument()
|
||||
expect(mutations).toEqual([
|
||||
{ path: '/workspaces/current/endpoints/ep-1', method: 'DELETE', body: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
describe('Create Endpoint Flow', () => {
|
||||
it('should invalidate endpoint list after successful create', () => {
|
||||
render(<EndpointList detail={createPluginDetail()} />)
|
||||
it('keeps edit values available when the mutation fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
rejectMutation = true
|
||||
renderEndpoints()
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
await user.clear(screen.getByPlaceholderText('Endpoint Name'))
|
||||
await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Saved endpoint')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await waitFor(() => expect(showError).toHaveBeenCalled())
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Endpoint 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(getAddButton())
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
|
||||
return waitFor(() => {
|
||||
expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin')
|
||||
expect(mockInvalidateInstalledPluginList).toHaveBeenCalled()
|
||||
it.each(['edit', 'disable', 'delete'] as const)(
|
||||
'blocks duplicate %s while pending and retains the failed action for retry',
|
||||
async (action) => {
|
||||
const user = userEvent.setup()
|
||||
let settle: () => void = () => {}
|
||||
mutationGate = new Promise<void>((resolve) => {
|
||||
settle = resolve
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass correct params to createEndpoint', () => {
|
||||
render(<EndpointList detail={createPluginDetail()} />)
|
||||
|
||||
fireEvent.click(getAddButton())
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
|
||||
expect(mockCreateEndpoint).toHaveBeenCalledWith({
|
||||
pluginUniqueID: 'test-uid',
|
||||
state: { name: 'New Endpoint' },
|
||||
rejectMutation = true
|
||||
renderEndpoints()
|
||||
if (action === 'disable') await user.click(await screen.findByRole('switch'))
|
||||
else
|
||||
await user.click(await screen.findByRole('button', { name: `common.operation.${action}` }))
|
||||
const actionName = action === 'edit' ? 'common.operation.save' : 'common.operation.confirm'
|
||||
const submit = screen.getByRole('button', { name: actionName })
|
||||
await user.click(submit)
|
||||
await waitFor(() => expect(submit).toHaveAttribute('aria-disabled', 'true'))
|
||||
expect(submit).toHaveFocus()
|
||||
expect(submit).not.toBeDisabled()
|
||||
await user.click(submit)
|
||||
expect(mutations).toHaveLength(1)
|
||||
await act(async () => {
|
||||
settle()
|
||||
})
|
||||
})
|
||||
await waitFor(() => expect(showError).toHaveBeenCalled())
|
||||
expect(screen.getByRole('button', { name: actionName })).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
)
|
||||
if (action === 'edit')
|
||||
expect(screen.getByPlaceholderText('Endpoint Name')).toHaveValue('Endpoint 1')
|
||||
else expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
|
||||
rejectMutation = false
|
||||
await user.click(screen.getByRole('button', { name: actionName }))
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole(action === 'edit' ? 'dialog' : 'alertdialog'),
|
||||
).not.toBeInTheDocument(),
|
||||
)
|
||||
expect(mutations).toHaveLength(2)
|
||||
},
|
||||
)
|
||||
|
||||
it('refreshes a committed write after an error while preserving the editable form', async () => {
|
||||
const user = userEvent.setup()
|
||||
rejectMutation = true
|
||||
failAfterWrite = true
|
||||
renderEndpoints()
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
await user.clear(screen.getByPlaceholderText('Endpoint Name'))
|
||||
await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Submitted name')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
await waitFor(() => expect(showError).toHaveBeenCalled())
|
||||
expect(await screen.findByText('Saved endpoint')).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Endpoint Name')).toHaveValue('Submitted name')
|
||||
expect(mutations).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
+183
-389
@@ -1,413 +1,207 @@
|
||||
import type { FormSchema } from '../../../base/form/types'
|
||||
import type { PluginDetail } from '../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import EndpointModal from '../endpoint-modal'
|
||||
import { createPluginDetail } from './endpoint-fixture'
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
|
||||
vi.mock('@/app/notifications', () => ({
|
||||
toast: Object.assign(
|
||||
(message: string, options?: { type?: string }) =>
|
||||
mockToastNotify({ type: options?.type, message }),
|
||||
{
|
||||
success: (message: string) => mockToastNotify({ type: 'success', message }),
|
||||
error: (message: string) => mockToastNotify({ type: 'error', message }),
|
||||
warning: (message: string) => mockToastNotify({ type: 'warning', message }),
|
||||
info: (message: string) => mockToastNotify({ type: 'info', message }),
|
||||
dismiss: vi.fn(),
|
||||
update: vi.fn(),
|
||||
promise: vi.fn(),
|
||||
},
|
||||
),
|
||||
}))
|
||||
|
||||
const { showError } = vi.hoisted(() => ({ showError: vi.fn() }))
|
||||
vi.mock('@/app/notifications', () => ({ toast: { error: showError } }))
|
||||
vi.mock('../../readme-panel/entrance', () => ({ ReadmeEntrance: () => null }))
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (obj: Record<string, string> | string) =>
|
||||
typeof obj === 'string' ? obj : obj?.en_US || '',
|
||||
useRenderI18nObject: () => (label: { en_US: string }) => label.en_US,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
fieldMoreInfo,
|
||||
}: {
|
||||
value: Record<string, unknown>
|
||||
onChange: (v: Record<string, unknown>) => void
|
||||
fieldMoreInfo?: (item: { url?: string }) => React.ReactNode
|
||||
}) => {
|
||||
return (
|
||||
<div data-testid="form">
|
||||
<input
|
||||
data-testid="form-input"
|
||||
value={(value.name as string) || ''}
|
||||
onChange={(e) => onChange({ ...value, name: e.target.value })}
|
||||
/>
|
||||
{/* Render fieldMoreInfo to test url link */}
|
||||
{fieldMoreInfo && (
|
||||
<div data-testid="field-more-info">
|
||||
{fieldMoreInfo({ url: 'https://example.com' })}
|
||||
{fieldMoreInfo({})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
const mockPluginDetail = createPluginDetail()
|
||||
|
||||
describe('Endpoint settings form', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('preserves typed defaults and validates required false as a present value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSaved = vi.fn()
|
||||
render(
|
||||
<EndpointModal
|
||||
pluginDetail={mockPluginDetail}
|
||||
onCancel={vi.fn()}
|
||||
onSaved={onSaved}
|
||||
settings={[
|
||||
{ name: 'enabled', type: 'boolean', required: true, default: false },
|
||||
{ name: 'limit', type: 'text-input', required: true, default: '0', label: null },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../readme-panel/entrance', () => ({
|
||||
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
|
||||
}))
|
||||
|
||||
const mockFormSchemas = [
|
||||
{ name: 'name', label: { en_US: 'Name' }, type: 'text-input', required: true, default: '' },
|
||||
{
|
||||
name: 'apiKey',
|
||||
label: { en_US: 'API Key' },
|
||||
type: 'secret-input',
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
] as unknown as FormSchema[]
|
||||
|
||||
const mockPluginDetail: 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: {} 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: 'marketplace' as PluginDetail['source'],
|
||||
meta: undefined,
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
}
|
||||
|
||||
describe('EndpointModal', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnSaved = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
await user.type(screen.getByPlaceholderText('Endpoint Name'), 'New endpoint')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSaved).toHaveBeenCalledWith({
|
||||
name: 'New endpoint',
|
||||
settings: { enabled: false, limit: '0' },
|
||||
})
|
||||
expect(showError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render drawer', () => {
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={mockFormSchemas}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
|
||||
expect(dialog)!.toBeInTheDocument()
|
||||
expect(dialog).toHaveClass(
|
||||
'data-[swipe-direction=right]:top-2',
|
||||
'data-[swipe-direction=right]:bottom-2',
|
||||
'data-[swipe-direction=right]:h-[calc(100dvh-16px)]',
|
||||
'data-[swipe-direction=right]:w-100',
|
||||
'data-[swipe-direction=right]:max-w-[calc(100vw-1rem)]',
|
||||
)
|
||||
})
|
||||
|
||||
it('should render title and description', () => {
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={mockFormSchemas}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.endpointModalTitle'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.detailPanel.endpointModalDesc'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render form with fieldMoreInfo url link', () => {
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={mockFormSchemas}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('field-more-info'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('tools.howToGet'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render readme entrance', () => {
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={mockFormSchemas}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('readme-entrance'))!.toBeInTheDocument()
|
||||
it.each([
|
||||
['true', true],
|
||||
['True', true],
|
||||
['1', true],
|
||||
['false', false],
|
||||
[true, true],
|
||||
[false, false],
|
||||
[1, true],
|
||||
[0, false],
|
||||
])('renders and submits boolean default %j as %j', async (value, expected) => {
|
||||
const user = userEvent.setup()
|
||||
const onSaved = vi.fn()
|
||||
render(
|
||||
<EndpointModal
|
||||
pluginDetail={mockPluginDetail}
|
||||
onCancel={vi.fn()}
|
||||
onSaved={onSaved}
|
||||
defaultValues={{ name: 'Typed endpoint' }}
|
||||
settings={[{ name: 'enabled', type: 'boolean', required: true, default: value }]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('radio', { name: expected ? 'True' : 'False' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSaved).toHaveBeenCalledWith({
|
||||
name: 'Typed endpoint',
|
||||
settings: { enabled: expected },
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onCancel when cancel clicked', () => {
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={mockFormSchemas}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when close button clicked', () => {
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={mockFormSchemas}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
fireEvent.click(allButtons[0]!)
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should update form value when input changes', () => {
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={mockFormSchemas}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByTestId('form-input')
|
||||
fireEvent.change(input, { target: { value: 'Test Name' } })
|
||||
|
||||
expect(input)!.toHaveValue('Test Name')
|
||||
it('interprets an empty optional boolean default as false', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSaved = vi.fn()
|
||||
render(
|
||||
<EndpointModal
|
||||
pluginDetail={mockPluginDetail}
|
||||
onCancel={vi.fn()}
|
||||
onSaved={onSaved}
|
||||
defaultValues={{ name: 'Optional endpoint' }}
|
||||
settings={[{ name: 'enabled', type: 'boolean', default: '' }]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('radio', { name: 'False' })).toHaveAttribute('aria-checked', 'true')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSaved).toHaveBeenCalledWith({
|
||||
name: 'Optional endpoint',
|
||||
settings: { enabled: false },
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default Values', () => {
|
||||
it('should use defaultValues when provided', () => {
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={mockFormSchemas}
|
||||
defaultValues={{ name: 'Default Name' }}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
it('preserves a nullable optional boolean and rejects an empty required boolean', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSaved = vi.fn()
|
||||
const { rerender } = render(
|
||||
<EndpointModal
|
||||
pluginDetail={mockPluginDetail}
|
||||
onCancel={vi.fn()}
|
||||
onSaved={onSaved}
|
||||
defaultValues={{ name: 'Nullable endpoint' }}
|
||||
settings={[{ name: 'enabled', type: 'boolean', default: null }]}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSaved).toHaveBeenCalledWith({ name: 'Nullable endpoint', settings: { enabled: null } })
|
||||
onSaved.mockClear()
|
||||
rerender(
|
||||
<EndpointModal
|
||||
key="required"
|
||||
pluginDetail={mockPluginDetail}
|
||||
onCancel={vi.fn()}
|
||||
onSaved={onSaved}
|
||||
defaultValues={{ name: 'Required endpoint', enabled: '' }}
|
||||
settings={[{ name: 'enabled', type: 'boolean', required: true, default: true }]}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSaved).not.toHaveBeenCalled()
|
||||
expect(showError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('form-input'))!.toHaveValue('Default Name')
|
||||
it('preserves explicit clearing and does not mutate edit values', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSaved = vi.fn()
|
||||
const defaults = {
|
||||
name: 'Existing',
|
||||
token: '',
|
||||
enabled: false,
|
||||
retry_count: 0,
|
||||
nested: { limit: 3 },
|
||||
}
|
||||
render(
|
||||
<EndpointModal
|
||||
pluginDetail={mockPluginDetail}
|
||||
onCancel={vi.fn()}
|
||||
onSaved={onSaved}
|
||||
defaultValues={defaults}
|
||||
settings={[
|
||||
{
|
||||
name: 'token',
|
||||
type: 'secret-input',
|
||||
default: 'fallback',
|
||||
label: { en_US: 'Token', zh_Hans: null },
|
||||
placeholder: { en_US: 'Token' },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByPlaceholderText('Token')).toHaveValue('')
|
||||
await user.clear(screen.getByPlaceholderText('Endpoint Name'))
|
||||
await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Updated')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSaved).toHaveBeenCalledWith({
|
||||
name: 'Updated',
|
||||
settings: { token: '', enabled: false, retry_count: 0, nested: { limit: 3 } },
|
||||
})
|
||||
|
||||
it('should extract default values from schemas when no defaultValues', () => {
|
||||
const schemasWithDefaults = [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
default: 'Schema Default',
|
||||
},
|
||||
] as unknown as FormSchema[]
|
||||
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={schemasWithDefaults}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('form-input'))!.toHaveValue('Schema Default')
|
||||
})
|
||||
|
||||
it('should handle schemas without default values', () => {
|
||||
const schemasNoDefault = [
|
||||
{ name: 'name', label: 'Name', type: 'text-input', required: false },
|
||||
] as unknown as FormSchema[]
|
||||
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={schemasNoDefault}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('form'))!.toBeInTheDocument()
|
||||
expect(defaults).toEqual({
|
||||
name: 'Existing',
|
||||
token: '',
|
||||
enabled: false,
|
||||
retry_count: 0,
|
||||
nested: { limit: 3 },
|
||||
})
|
||||
})
|
||||
|
||||
describe('Validation - handleSave', () => {
|
||||
it('should show toast error when required field is empty', () => {
|
||||
const schemasWithRequired = [
|
||||
{
|
||||
name: 'name',
|
||||
label: { en_US: 'Name Field' },
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
] as unknown as FormSchema[]
|
||||
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={schemasWithRequired}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.stringContaining('errorMsg.fieldRequired'),
|
||||
})
|
||||
expect(mockOnSaved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show toast error with string label when required field is empty', () => {
|
||||
const schemasWithStringLabel = [
|
||||
{ name: 'name', label: 'String Label', type: 'text-input', required: true, default: '' },
|
||||
] as unknown as FormSchema[]
|
||||
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={schemasWithStringLabel}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.stringContaining('String Label'),
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSaved when all required fields are filled', () => {
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={mockFormSchemas}
|
||||
defaultValues={{ name: 'Valid Name' }}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockOnSaved).toHaveBeenCalledWith({ name: 'Valid Name' })
|
||||
})
|
||||
|
||||
it('should not validate non-required empty fields', () => {
|
||||
const schemasOptional = [
|
||||
{ name: 'optional', label: 'Optional', type: 'text-input', required: false, default: '' },
|
||||
] as unknown as FormSchema[]
|
||||
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={schemasOptional}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockToastNotify).not.toHaveBeenCalled()
|
||||
expect(mockOnSaved).toHaveBeenCalled()
|
||||
})
|
||||
it('keeps the form open and identifies a missing required field', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSaved = vi.fn()
|
||||
render(
|
||||
<EndpointModal
|
||||
pluginDetail={mockPluginDetail}
|
||||
onCancel={vi.fn()}
|
||||
onSaved={onSaved}
|
||||
settings={[]}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSaved).not.toHaveBeenCalled()
|
||||
expect(showError).toHaveBeenCalled()
|
||||
expect(screen.getByPlaceholderText('Endpoint Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Boolean Field Processing', () => {
|
||||
it.each([
|
||||
{ input: 'true', expected: true },
|
||||
{ input: '1', expected: true },
|
||||
{ input: 'True', expected: true },
|
||||
{ input: 'false', expected: false },
|
||||
{ input: 1, expected: true },
|
||||
{ input: 0, expected: false },
|
||||
{ input: true, expected: true },
|
||||
{ input: false, expected: false },
|
||||
])('should convert $input to $expected for boolean fields', ({ input, expected }) => {
|
||||
const schemasWithBoolean = [
|
||||
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
|
||||
] as unknown as FormSchema[]
|
||||
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={schemasWithBoolean}
|
||||
defaultValues={{ enabled: input }}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: expected })
|
||||
})
|
||||
|
||||
it('should not process non-boolean fields', () => {
|
||||
const schemasWithText = [
|
||||
{ name: 'text', label: 'Text', type: 'text-input', required: false, default: '' },
|
||||
] as unknown as FormSchema[]
|
||||
|
||||
render(
|
||||
<EndpointModal
|
||||
formSchemas={schemasWithText}
|
||||
defaultValues={{ text: 'hello' }}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
pluginDetail={mockPluginDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockOnSaved).toHaveBeenCalledWith({ text: 'hello' })
|
||||
})
|
||||
it('keeps pending save focusable and blocks click and implicit submissions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSaved = vi.fn()
|
||||
render(
|
||||
<EndpointModal
|
||||
pluginDetail={mockPluginDetail}
|
||||
onCancel={vi.fn()}
|
||||
onSaved={onSaved}
|
||||
settings={[]}
|
||||
defaultValues={{ name: 'Pending endpoint' }}
|
||||
isPending
|
||||
/>,
|
||||
)
|
||||
const save = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
expect(save).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(save).not.toBeDisabled()
|
||||
await user.click(save)
|
||||
await user.click(screen.getByPlaceholderText('Endpoint Name'))
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onSaved).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { NAME_FIELD } from '../utils'
|
||||
|
||||
describe('utils', () => {
|
||||
describe('NAME_FIELD', () => {
|
||||
it('should have correct type', () => {
|
||||
expect(NAME_FIELD.type).toBe(FormTypeEnum.textInput)
|
||||
})
|
||||
|
||||
it('should have correct name', () => {
|
||||
expect(NAME_FIELD.name).toBe('name')
|
||||
})
|
||||
|
||||
it('should have label translations', () => {
|
||||
expect(NAME_FIELD.label).toBeDefined()
|
||||
expect(NAME_FIELD.label.en_US).toBe('Endpoint Name')
|
||||
expect(NAME_FIELD.label.zh_Hans).toBe('端点名称')
|
||||
expect(NAME_FIELD.label.ja_JP).toBe('エンドポイント名')
|
||||
expect(NAME_FIELD.label.pt_BR).toBe('Nome do ponto final')
|
||||
})
|
||||
|
||||
it('should have placeholder translations', () => {
|
||||
expect(NAME_FIELD.placeholder).toBeDefined()
|
||||
expect(NAME_FIELD.placeholder.en_US).toBe('Endpoint Name')
|
||||
expect(NAME_FIELD.placeholder.zh_Hans).toBe('端点名称')
|
||||
expect(NAME_FIELD.placeholder.ja_JP).toBe('エンドポイント名')
|
||||
expect(NAME_FIELD.placeholder.pt_BR).toBe('Nome do ponto final')
|
||||
})
|
||||
|
||||
it('should be required', () => {
|
||||
expect(NAME_FIELD.required).toBe(true)
|
||||
})
|
||||
|
||||
it('should have empty default value', () => {
|
||||
expect(NAME_FIELD.default).toBe('')
|
||||
})
|
||||
|
||||
it('should have null help', () => {
|
||||
expect(NAME_FIELD.help).toBeNull()
|
||||
})
|
||||
|
||||
it('should have all required field properties', () => {
|
||||
const requiredKeys = ['type', 'name', 'label', 'placeholder', 'required', 'default', 'help']
|
||||
requiredKeys.forEach((key) => {
|
||||
expect(NAME_FIELD).toHaveProperty(key)
|
||||
})
|
||||
})
|
||||
|
||||
it('should match expected structure', () => {
|
||||
expect(NAME_FIELD).toEqual({
|
||||
type: FormTypeEnum.textInput,
|
||||
name: 'name',
|
||||
label: {
|
||||
en_US: 'Endpoint Name',
|
||||
zh_Hans: '端点名称',
|
||||
ja_JP: 'エンドポイント名',
|
||||
pt_BR: 'Nome do ponto final',
|
||||
},
|
||||
placeholder: {
|
||||
en_US: 'Endpoint Name',
|
||||
zh_Hans: '端点名称',
|
||||
ja_JP: 'エンドポイント名',
|
||||
pt_BR: 'Nome do ponto final',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
help: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { EndpointListItem, PluginDetail } from '../types'
|
||||
import type { EndpointListItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { PluginDetail } from '../types'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@@ -12,112 +12,62 @@ import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
addDefaultValue,
|
||||
toolCredentialToFormSchemas,
|
||||
} from '@/app/components/tools/utils/to-form-schema'
|
||||
import { toast } from '@/app/notifications'
|
||||
import {
|
||||
useDeleteEndpoint,
|
||||
useDisableEndpoint,
|
||||
useEnableEndpoint,
|
||||
useUpdateEndpoint,
|
||||
} from '@/service/use-endpoints'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import EndpointModal from './endpoint-modal'
|
||||
import { NAME_FIELD } from './utils'
|
||||
|
||||
type EndpointModalFormSchemas = ComponentProps<typeof EndpointModal>['formSchemas']
|
||||
|
||||
type Props = Readonly<{
|
||||
pluginDetail: PluginDetail
|
||||
data: EndpointListItem
|
||||
handleChange: () => void
|
||||
data: EndpointListItemResponse
|
||||
}>
|
||||
|
||||
const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => {
|
||||
const EndpointCard = ({ pluginDetail, data }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [active, setActive] = useState(data.enabled)
|
||||
const endpointID = data.id
|
||||
|
||||
// switch
|
||||
const [isShowDisableConfirm, { setTrue: showDisableConfirm, setFalse: hideDisableConfirm }] =
|
||||
useBoolean(false)
|
||||
const { mutate: enableEndpoint } = useEnableEndpoint({
|
||||
onSuccess: async () => {
|
||||
await handleChange()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
setActive(false)
|
||||
},
|
||||
})
|
||||
const { mutate: disableEndpoint } = useDisableEndpoint({
|
||||
onSuccess: async () => {
|
||||
await handleChange()
|
||||
hideDisableConfirm()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
setActive(false)
|
||||
},
|
||||
})
|
||||
const handleSwitch = (state: boolean) => {
|
||||
if (state) {
|
||||
setActive(true)
|
||||
enableEndpoint(endpointID)
|
||||
} else {
|
||||
setActive(false)
|
||||
showDisableConfirm()
|
||||
}
|
||||
}
|
||||
|
||||
// delete
|
||||
const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] =
|
||||
useBoolean(false)
|
||||
const { mutate: deleteEndpoint } = useDeleteEndpoint({
|
||||
onSuccess: async () => {
|
||||
await handleChange()
|
||||
hideDeleteConfirm()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
},
|
||||
})
|
||||
|
||||
// update
|
||||
const [
|
||||
isShowEndpointModal,
|
||||
{ setTrue: showEndpointModalConfirm, setFalse: hideEndpointModalConfirm },
|
||||
] = useBoolean(false)
|
||||
const formSchemas = useMemo(() => {
|
||||
return toolCredentialToFormSchemas([NAME_FIELD, ...data.declaration.settings])
|
||||
}, [data.declaration.settings])
|
||||
const formValue = useMemo(() => {
|
||||
const formValue = {
|
||||
name: data.name,
|
||||
...data.settings,
|
||||
}
|
||||
return addDefaultValue(formValue, formSchemas)
|
||||
}, [data.name, data.settings, formSchemas])
|
||||
const { mutate: updateEndpoint } = useUpdateEndpoint({
|
||||
onSuccess: async () => {
|
||||
await handleChange()
|
||||
hideEndpointModalConfirm()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
},
|
||||
})
|
||||
const handleUpdate = (state: Record<string, unknown>) =>
|
||||
updateEndpoint({
|
||||
endpointID,
|
||||
state,
|
||||
})
|
||||
const showSaveError = () => {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
}
|
||||
const { mutate: enableEndpoint, isPending: isEnabling } = useMutation(
|
||||
consoleQuery.workspaces.current.endpoints.enable.post.mutationOptions({
|
||||
onError: showSaveError,
|
||||
}),
|
||||
)
|
||||
const { mutate: disableEndpoint, isPending: isDisabling } = useMutation(
|
||||
consoleQuery.workspaces.current.endpoints.disable.post.mutationOptions({
|
||||
onSuccess: hideDisableConfirm,
|
||||
onError: showSaveError,
|
||||
}),
|
||||
)
|
||||
const { mutate: deleteEndpoint, isPending: isDeleting } = useMutation(
|
||||
consoleQuery.workspaces.current.endpoints.byId.delete.mutationOptions({
|
||||
onSuccess: hideDeleteConfirm,
|
||||
onError: showSaveError,
|
||||
}),
|
||||
)
|
||||
const { mutate: updateEndpoint, isPending: isUpdating } = useMutation(
|
||||
consoleQuery.workspaces.current.endpoints.byId.patch.mutationOptions({
|
||||
onSuccess: hideEndpointModalConfirm,
|
||||
onError: showSaveError,
|
||||
}),
|
||||
)
|
||||
const handleSwitch = (enabled: boolean) => {
|
||||
if (enabled) enableEndpoint({ body: { endpoint_id: endpointID } })
|
||||
else showDisableConfirm()
|
||||
}
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const handleCopy = (value: string) => {
|
||||
@@ -125,13 +75,6 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => {
|
||||
setIsCopied(true)
|
||||
}
|
||||
|
||||
const handleDisableConfirmOpenChange = (open: boolean) => {
|
||||
if (open) return
|
||||
|
||||
hideDisableConfirm()
|
||||
setActive(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -169,10 +112,10 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => {
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{(data.declaration.endpoints ?? [])
|
||||
{(data.declaration?.endpoints ?? [])
|
||||
.filter((endpoint) => !endpoint.hidden)
|
||||
.map((endpoint, index) => (
|
||||
<div key={index} className="flex h-6 items-center">
|
||||
.map((endpoint) => (
|
||||
<div key={`${endpoint.method}:${endpoint.path}`} className="flex h-6 items-center">
|
||||
<div className="w-12 shrink-0 system-xs-regular text-text-tertiary">
|
||||
{endpoint.method}
|
||||
</div>
|
||||
@@ -207,21 +150,30 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 pl-3">
|
||||
{active && (
|
||||
{data.enabled && (
|
||||
<div className="flex items-center gap-1 system-xs-semibold-uppercase text-util-colors-green-green-600">
|
||||
<StatusDot status="success" />
|
||||
{t(($) => $['detailPanel.serviceOk'], { ns: 'plugin' })}
|
||||
</div>
|
||||
)}
|
||||
{!active && (
|
||||
{!data.enabled && (
|
||||
<div className="flex items-center gap-1 system-xs-semibold-uppercase text-text-tertiary">
|
||||
<StatusDot status="disabled" />
|
||||
{t(($) => $['detailPanel.disabled'], { ns: 'plugin' })}
|
||||
</div>
|
||||
)}
|
||||
<Switch className="ml-3" checked={active} onCheckedChange={handleSwitch} size="sm" />
|
||||
<Switch
|
||||
className="ml-3"
|
||||
checked={data.enabled}
|
||||
onCheckedChange={handleSwitch}
|
||||
disabled={isEnabling || isDisabling}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<AlertDialog open={isShowDisableConfirm} onOpenChange={handleDisableConfirmOpenChange}>
|
||||
<AlertDialog
|
||||
open={isShowDisableConfirm}
|
||||
onOpenChange={(open) => !open && hideDisableConfirm()}
|
||||
>
|
||||
<AlertDialogContent backdropProps={{ forceRender: true }}>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
@@ -235,7 +187,10 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => {
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={() => disableEndpoint(endpointID)}>
|
||||
<AlertDialogConfirmButton
|
||||
loading={isDisabling}
|
||||
onClick={() => disableEndpoint({ body: { endpoint_id: endpointID } })}
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
@@ -255,7 +210,10 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => {
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={() => deleteEndpoint(endpointID)}>
|
||||
<AlertDialogConfirmButton
|
||||
loading={isDeleting}
|
||||
onClick={() => deleteEndpoint({ params: { id: endpointID } })}
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
@@ -263,10 +221,11 @@ const EndpointCard = ({ pluginDetail, data, handleChange }: Props) => {
|
||||
</AlertDialog>
|
||||
{isShowEndpointModal && (
|
||||
<EndpointModal
|
||||
formSchemas={formSchemas as EndpointModalFormSchemas}
|
||||
defaultValues={formValue}
|
||||
settings={data.declaration?.settings ?? []}
|
||||
defaultValues={{ ...data.settings, name: data.name }}
|
||||
onCancel={hideEndpointModalConfirm}
|
||||
onSaved={handleUpdate}
|
||||
onSaved={(body) => updateEndpoint({ params: { id: endpointID }, body })}
|
||||
isPending={isUpdating}
|
||||
pluginDetail={pluginDetail}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
import type { EndpointProviderDeclarationResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import {
|
||||
useCreateEndpoint,
|
||||
useEndpointList,
|
||||
useInvalidateEndpointList,
|
||||
} from '@/service/use-endpoints'
|
||||
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import EndpointCard from './endpoint-card'
|
||||
import EndpointModal from './endpoint-modal'
|
||||
import { NAME_FIELD } from './utils'
|
||||
|
||||
type Props = Readonly<{
|
||||
detail: PluginDetail
|
||||
}>
|
||||
|
||||
type EndpointDeclaration = NonNullable<PluginDetail['declaration']['endpoint']>
|
||||
|
||||
type EndpointListContentProps = Readonly<{
|
||||
declaration: EndpointDeclaration
|
||||
declaration: EndpointProviderDeclarationResponse
|
||||
detail: PluginDetail
|
||||
}>
|
||||
|
||||
@@ -35,33 +27,22 @@ const EndpointListContent = ({ declaration, detail }: EndpointListContentProps)
|
||||
const docLink = useDocLink()
|
||||
const pluginUniqueID = detail.plugin_unique_identifier
|
||||
const showTopBorder = detail.declaration.tool
|
||||
const { data } = useEndpointList(detail.plugin_id)
|
||||
const invalidateEndpointList = useInvalidateEndpointList()
|
||||
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
|
||||
|
||||
const { data } = useQuery(
|
||||
consoleQuery.workspaces.current.endpoints.list.plugin.get.queryOptions({
|
||||
input: { query: { plugin_id: detail.plugin_id, page: 1, page_size: 100 } },
|
||||
}),
|
||||
)
|
||||
const [isShowEndpointModal, { setTrue: showEndpointModal, setFalse: hideEndpointModal }] =
|
||||
useBoolean(false)
|
||||
|
||||
const formSchemas = useMemo(() => {
|
||||
return toolCredentialToFormSchemas([NAME_FIELD, ...declaration.settings])
|
||||
}, [declaration.settings])
|
||||
|
||||
const { mutate: createEndpoint } = useCreateEndpoint({
|
||||
onSuccess: async () => {
|
||||
await invalidateEndpointList(detail.plugin_id)
|
||||
invalidateInstalledPluginList(detail.declaration.category)
|
||||
hideEndpointModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = (state: Record<string, any>) =>
|
||||
createEndpoint({
|
||||
pluginUniqueID,
|
||||
state,
|
||||
})
|
||||
const showSaveError = () => {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
}
|
||||
const { mutate: createEndpoint, isPending } = useMutation(
|
||||
consoleQuery.workspaces.current.endpoints.post.mutationOptions({
|
||||
onSuccess: hideEndpointModal,
|
||||
onError: showSaveError,
|
||||
}),
|
||||
)
|
||||
|
||||
if (!data) return null
|
||||
|
||||
@@ -124,22 +105,17 @@ const EndpointListContent = ({ declaration, detail }: EndpointListContentProps)
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.endpoints.map((item) => (
|
||||
<EndpointCard
|
||||
key={item.id}
|
||||
data={item}
|
||||
handleChange={() => {
|
||||
invalidateEndpointList(detail.plugin_id)
|
||||
invalidateInstalledPluginList(detail.declaration.category)
|
||||
}}
|
||||
pluginDetail={detail}
|
||||
/>
|
||||
<EndpointCard key={item.id} data={item} pluginDetail={detail} />
|
||||
))}
|
||||
</div>
|
||||
{isShowEndpointModal && (
|
||||
<EndpointModal
|
||||
formSchemas={formSchemas as any}
|
||||
settings={declaration.settings ?? []}
|
||||
onCancel={hideEndpointModal}
|
||||
onSaved={handleCreate}
|
||||
onSaved={(body) =>
|
||||
createEndpoint({ body: { ...body, plugin_unique_identifier: pluginUniqueID } })
|
||||
}
|
||||
isPending={isPending}
|
||||
pluginDetail={detail}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { FormSchema } from '../../base/form/types'
|
||||
import type {
|
||||
EndpointProviderConfigI18nResponse,
|
||||
EndpointProviderConfigResponse,
|
||||
EndpointUpdatePayload,
|
||||
ProviderConfigType,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type {
|
||||
CredentialFormSchema,
|
||||
TypeWithI18N,
|
||||
} from '../../header/account-setting/model-provider-page/declarations'
|
||||
import type { PluginDetail } from '../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -13,72 +21,126 @@ import {
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { RiArrowRightUpLine, RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { FormTypeEnum } from '../../header/account-setting/model-provider-page/declarations'
|
||||
import { ReadmeEntrance } from '../readme-panel/entrance'
|
||||
|
||||
const NAME_FIELD = {
|
||||
type: 'text-input',
|
||||
name: 'name',
|
||||
label: {
|
||||
en_US: 'Endpoint Name',
|
||||
zh_Hans: '端点名称',
|
||||
ja_JP: 'エンドポイント名',
|
||||
pt_BR: 'Nome do ponto final',
|
||||
},
|
||||
placeholder: {
|
||||
en_US: 'Endpoint Name',
|
||||
zh_Hans: '端点名称',
|
||||
ja_JP: 'エンドポイント名',
|
||||
pt_BR: 'Nome do ponto final',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
help: null,
|
||||
} satisfies EndpointProviderConfigResponse
|
||||
|
||||
type Props = Readonly<{
|
||||
formSchemas: FormSchema[]
|
||||
defaultValues?: any
|
||||
settings: EndpointProviderConfigResponse[]
|
||||
defaultValues?: Record<string, unknown>
|
||||
onCancel: () => void
|
||||
onSaved: (value: Record<string, any>) => void
|
||||
onSaved: (value: EndpointUpdatePayload) => void
|
||||
isPending?: boolean
|
||||
pluginDetail: PluginDetail
|
||||
}>
|
||||
|
||||
const extractDefaultValues = (schemas: any[]) => {
|
||||
const result: Record<string, any> = {}
|
||||
for (const field of schemas) {
|
||||
if (field.default !== undefined) result[field.name] = field.default
|
||||
}
|
||||
return result
|
||||
const fieldTypes: Record<ProviderConfigType, FormTypeEnum> = {
|
||||
'text-input': FormTypeEnum.textInput,
|
||||
'secret-input': FormTypeEnum.secretInput,
|
||||
select: FormTypeEnum.select,
|
||||
boolean: FormTypeEnum.checkbox,
|
||||
'app-selector': FormTypeEnum.appSelector,
|
||||
'model-selector': FormTypeEnum.modelSelector,
|
||||
'array[tools]': FormTypeEnum.multiToolSelector,
|
||||
}
|
||||
|
||||
const EndpointModal: FC<Props> = ({
|
||||
formSchemas,
|
||||
defaultValues = {},
|
||||
const toFormLabel = (label: EndpointProviderConfigI18nResponse): TypeWithI18N => ({
|
||||
en_US: label.en_US,
|
||||
zh_Hans: label.zh_Hans ?? label.en_US,
|
||||
pt_BR: label.pt_BR ?? label.en_US,
|
||||
ja_JP: label.ja_JP ?? label.en_US,
|
||||
})
|
||||
|
||||
const toFormSchema = (field: EndpointProviderConfigResponse): CredentialFormSchema => ({
|
||||
name: field.name,
|
||||
variable: field.name,
|
||||
type: fieldTypes[field.type],
|
||||
label: toFormLabel(field.label ?? { en_US: field.name }),
|
||||
required: field.required ?? false,
|
||||
show_on: [],
|
||||
tooltip: field.help ? toFormLabel(field.help) : undefined,
|
||||
placeholder: field.placeholder ? toFormLabel(field.placeholder) : undefined,
|
||||
scope: field.scope ?? undefined,
|
||||
url: field.url ?? undefined,
|
||||
options: (field.options ?? []).map((option) => ({
|
||||
value: option.value,
|
||||
label: toFormLabel(option.label),
|
||||
show_on: [],
|
||||
})),
|
||||
})
|
||||
|
||||
const EndpointModal = ({
|
||||
settings,
|
||||
defaultValues,
|
||||
onCancel,
|
||||
onSaved,
|
||||
isPending,
|
||||
pluginDetail,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const getValueFromI18nObject = useRenderI18nObject()
|
||||
const { t } = useTranslation()
|
||||
const initialValues =
|
||||
Object.keys(defaultValues).length > 0 ? defaultValues : extractDefaultValues(formSchemas)
|
||||
const [tempCredential, setTempCredential] = React.useState<any>(initialValues)
|
||||
const fields = [NAME_FIELD, ...settings]
|
||||
const formSchemas = fields.map(toFormSchema)
|
||||
const [tempCredential, setTempCredential] = React.useState<Record<string, unknown>>(() => {
|
||||
const values: Record<string, unknown> = {
|
||||
...Object.fromEntries(
|
||||
fields
|
||||
.filter((field) => field.default !== undefined)
|
||||
.map((field) => [field.name, field.default]),
|
||||
),
|
||||
...defaultValues,
|
||||
}
|
||||
for (const field of fields) {
|
||||
const value = values[field.name]
|
||||
if (field.type !== 'boolean' || (field.required && value === '')) continue
|
||||
if (typeof value === 'string')
|
||||
values[field.name] = value === 'true' || value === '1' || value === 'True'
|
||||
else if (typeof value === 'number') values[field.name] = value === 1
|
||||
}
|
||||
return values
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
for (const field of formSchemas) {
|
||||
if (field.required && !tempCredential[field.name]) {
|
||||
if (isPending) return
|
||||
for (const field of fields) {
|
||||
const value = tempCredential[field.name]
|
||||
if (field.required && (value === undefined || value === null || value === '')) {
|
||||
toast.error(
|
||||
t(($) => $['errorMsg.fieldRequired'], {
|
||||
ns: 'common',
|
||||
field:
|
||||
typeof field.label === 'string'
|
||||
? field.label
|
||||
: getValueFromI18nObject(field.label as Record<string, string>),
|
||||
field: field.label ? getValueFromI18nObject(toFormLabel(field.label)) : field.name,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fix: Process boolean fields to ensure they are sent as proper boolean values
|
||||
const processedCredential = { ...tempCredential }
|
||||
formSchemas.forEach((field: any) => {
|
||||
if (field.type === 'boolean' && processedCredential[field.name] !== undefined) {
|
||||
const value = processedCredential[field.name]
|
||||
if (typeof value === 'string')
|
||||
processedCredential[field.name] = value === 'true' || value === '1' || value === 'True'
|
||||
else if (typeof value === 'number') processedCredential[field.name] = value === 1
|
||||
else if (typeof value === 'boolean') processedCredential[field.name] = value
|
||||
}
|
||||
})
|
||||
|
||||
onSaved(processedCredential)
|
||||
const { name, ...values } = tempCredential
|
||||
if (typeof name !== 'string' || name.length === 0) return
|
||||
onSaved({ name, settings: values })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -108,7 +170,7 @@ const EndpointModal: FC<Props> = ({
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine aria-hidden="true" className="size-4" />
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="mt-0.5 system-xs-regular text-text-tertiary">
|
||||
@@ -116,14 +178,20 @@ const EndpointModal: FC<Props> = ({
|
||||
</div>
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} className="px-0 pt-3" />
|
||||
</div>
|
||||
<div className="grow overflow-y-auto">
|
||||
<form
|
||||
className="grow overflow-y-auto"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
handleSave()
|
||||
}}
|
||||
>
|
||||
<div className="px-4 py-2">
|
||||
<Form
|
||||
value={tempCredential}
|
||||
onChange={(v) => {
|
||||
setTempCredential(v)
|
||||
}}
|
||||
formSchemas={formSchemas as any}
|
||||
formSchemas={formSchemas}
|
||||
isEditMode={true}
|
||||
showOnVariableMap={{}}
|
||||
validating={false}
|
||||
@@ -137,7 +205,7 @@ const EndpointModal: FC<Props> = ({
|
||||
className="inline-flex items-center body-xs-regular text-text-accent-secondary"
|
||||
>
|
||||
{t(($) => $.howToGet, { ns: 'tools' })}
|
||||
<RiArrowRightUpLine className="ml-1 size-3" />
|
||||
<span aria-hidden className="ml-1 i-ri-arrow-right-up-line size-3" />
|
||||
</a>
|
||||
) : null
|
||||
}
|
||||
@@ -148,12 +216,12 @@ const EndpointModal: FC<Props> = ({
|
||||
<Button onClick={onCancel}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
<Button variant="primary" type="submit" loading={isPending}>
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Plugin endpoints
|
||||
|
||||
The endpoint list, card, and modal own the plugin endpoint management surface.
|
||||
They consume generated `consoleQuery.workspaces.current.endpoints` options
|
||||
directly. There is no endpoint service hook, handwritten cache key, or frontend
|
||||
copy of an API response type.
|
||||
|
||||
## API and types
|
||||
|
||||
- [Endpoint controllers]
|
||||
own request/response models and tenant, user, and permission enforcement.
|
||||
- [Plugin endpoint client]
|
||||
owns the daemon boundary. Successful writes return `true`; daemon failures
|
||||
raise errors. An unexpected `false` acknowledgement is an error, not a second
|
||||
successful HTTP response. Deleting an already removed endpoint remains
|
||||
idempotent.
|
||||
- Generated Console contracts own list items, provider configuration, and write
|
||||
payloads. Create uses `POST /endpoints`, edit uses `PATCH /endpoints/{id}`, and
|
||||
delete uses `DELETE /endpoints/{id}`. Enable and disable use their generated
|
||||
action endpoints. Success keeps the existing `200 { success: true }` shape.
|
||||
- The modal owns conversion from provider configuration to the existing form
|
||||
schema and from form values to the generated settings payload. It preserves
|
||||
`false`, `0`, and empty strings, and separates the name without mutating the
|
||||
submitted object. Boolean fields interpret the declaration's string or numeric
|
||||
defaults as boolean form values (`"true"`, `"True"`, `"1"`, and `1` mean true).
|
||||
Missing and null values remain missing; a required empty value still fails validation.
|
||||
An optional empty boolean default means false, as before;
|
||||
other field types retain their original values. Optional declarations and nullable localized labels are
|
||||
handled at this display boundary.
|
||||
|
||||
When an API shape changes, update its Pydantic owner and regenerate TypeScript,
|
||||
Zod, and OpenAPI Markdown. Do not widen generated unions, assert a different DTO,
|
||||
or add a frontend compatibility type to hide a contract mismatch.
|
||||
|
||||
## Queries and mutations
|
||||
|
||||
| Decision | Owner and behavior |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List identity | Generated input includes plugin ID, page `1`, and page size `100`; pagination behavior is unchanged. |
|
||||
| Freshness and retries | Inherit the existing QueryClient defaults, including five-minute freshness. No endpoint-specific override is needed. |
|
||||
| Rendering | The list queries when its endpoint surface is mounted. The card reads `enabled` from query data; pending changes disable the switch. |
|
||||
| Shared invalidation | All five settled writes invalidate the endpoint list family and installed plugin list/category queries containing endpoint counts. |
|
||||
| Invalidation scope | Update/delete/enable/disable only identify an endpoint, so their shared policy cannot safely infer its plugin. All endpoint list inputs are marked stale. Installed plugin IDs and unrelated tools remain untouched. |
|
||||
| Failure | Rejected mutations refresh server state, retain editable form state, and do not close the form or retry the write. |
|
||||
| Local callbacks | The surface owns toast, confirmation, and closing behavior; shared cache work stays in `query-policies.ts` `onSettled`. |
|
||||
|
||||
Pending submit and confirmation actions use the Button loading contract and block
|
||||
repeat submissions, including implicit form submission.
|
||||
|
||||
Endpoint writes are not atomic across the daemon's database and follow-up work.
|
||||
Create can install a record before later encryption fails; update/delete can
|
||||
commit before credential-cache cleanup fails. A rejected request is therefore
|
||||
not proof that server state is unchanged. All five mutation policies invalidate
|
||||
on settlement, including errors, with the generated keys written inline in each
|
||||
configuration. Local `onError` feedback remains separate; refreshing is a read,
|
||||
not an automatic retry or a rollback of the write. Mutations retain TanStack
|
||||
Query's default of no automatic retries.
|
||||
|
||||
The endpoint list family uses `endpoints.list.key()`: `list.get.key()` would not
|
||||
match `list.plugin.get`. Query data remains scoped by the application's existing
|
||||
workspace-switch boundary; this module does not introduce a separate cache.
|
||||
|
||||
## Verification
|
||||
|
||||
Backend tests protect the success-only write contract, daemon failure handling,
|
||||
delete idempotence, and generated schema. Frontend tests exercise generated
|
||||
transport and real QueryClient invalidation alongside local callbacks, including
|
||||
failure after a committed write and unchanged scalar settings. Surface tests protect form submission,
|
||||
server-owned switch state, and failure recovery. Coverage percentage is not an
|
||||
acceptance criterion.
|
||||
|
||||
[Endpoint controllers]: ../../../../../api/controllers/console/workspace/endpoint.py
|
||||
[Plugin endpoint client]: ../../../../../api/core/plugin/impl/endpoint.py
|
||||
@@ -1,21 +0,0 @@
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
export const NAME_FIELD = {
|
||||
type: FormTypeEnum.textInput,
|
||||
name: 'name',
|
||||
label: {
|
||||
en_US: 'Endpoint Name',
|
||||
zh_Hans: '端点名称',
|
||||
ja_JP: 'エンドポイント名',
|
||||
pt_BR: 'Nome do ponto final',
|
||||
},
|
||||
placeholder: {
|
||||
en_US: 'Endpoint Name',
|
||||
zh_Hans: '端点名称',
|
||||
ja_JP: 'エンドポイント名',
|
||||
pt_BR: 'Nome do ponto final',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
help: null,
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { EndpointProviderDeclarationResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { FormTypeEnum } from '../base/form/types'
|
||||
import type { CredentialFormSchemaBase } from '../header/account-setting/model-provider-page/declarations'
|
||||
import type { AutoUpdateConfig } from './reference-setting-modal/auto-update-setting/types'
|
||||
@@ -38,32 +39,6 @@ type PluginToolDeclaration = {
|
||||
credentials_schema: ToolCredential[] // TODO
|
||||
}
|
||||
|
||||
type PluginEndpointDeclaration = {
|
||||
settings: ToolCredential[]
|
||||
endpoints?: EndpointItem[] | null
|
||||
}
|
||||
|
||||
type EndpointItem = {
|
||||
path: string
|
||||
method: string
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export type EndpointListItem = {
|
||||
id: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
settings: Record<string, any>
|
||||
tenant_id: string
|
||||
plugin_id: string
|
||||
expired_at: string
|
||||
declaration: PluginEndpointDeclaration
|
||||
name: string
|
||||
enabled: boolean
|
||||
url: string
|
||||
hook_id: string
|
||||
}
|
||||
|
||||
type PluginDeclarationMeta = {
|
||||
version: string
|
||||
minimum_dify_version?: string
|
||||
@@ -84,7 +59,7 @@ export type PluginDeclaration = {
|
||||
resource: any // useless in frontend
|
||||
plugins: any // useless in frontend
|
||||
verified: boolean
|
||||
endpoint?: PluginEndpointDeclaration | null
|
||||
endpoint?: EndpointProviderDeclarationResponse | null
|
||||
tool?: PluginToolDeclaration
|
||||
datasource?: PluginToolDeclaration
|
||||
model: any
|
||||
@@ -349,14 +324,6 @@ export type GitHubUrlInfo = {
|
||||
repo?: string
|
||||
}
|
||||
|
||||
// endpoint
|
||||
export type EndpointsResponse = {
|
||||
endpoints: EndpointListItem[]
|
||||
has_more: boolean
|
||||
limit: number
|
||||
total: number
|
||||
page: number
|
||||
}
|
||||
export const InstallStep = {
|
||||
uploading: 'uploading',
|
||||
uploadFailed: 'uploadFailed',
|
||||
|
||||
@@ -312,6 +312,33 @@ describe('normalizeInstalledPluginDetail', () => {
|
||||
expect(categoryDetail.plugin_id).toBe('langgenius/category-plugin')
|
||||
})
|
||||
|
||||
it('preserves endpoint setting types at the dynamic plugin manifest boundary', () => {
|
||||
const plugin = createPluginInstallation()
|
||||
plugin.declaration.endpoint = {
|
||||
settings: [
|
||||
{ name: 'enabled', type: 'boolean', default: false, required: true, label: null },
|
||||
{
|
||||
name: 'count',
|
||||
type: 'text-input',
|
||||
default: '0',
|
||||
placeholder: { en_US: 'Count', zh_Hans: null },
|
||||
},
|
||||
],
|
||||
endpoints: null,
|
||||
}
|
||||
const detail = normalizeInstalledPluginDetail(plugin)
|
||||
|
||||
expect(detail.declaration.endpoint?.settings?.[0]).toMatchObject({
|
||||
default: false,
|
||||
label: null,
|
||||
})
|
||||
expect(detail.declaration.endpoint?.settings?.[1]).toMatchObject({
|
||||
default: '0',
|
||||
placeholder: { zh_Hans: null },
|
||||
})
|
||||
expect(detail.declaration.endpoint?.endpoints).toBeNull()
|
||||
})
|
||||
|
||||
it('should preserve generated plugin declaration capabilities', () => {
|
||||
const detail = normalizeInstalledPluginDetail(createPluginInstallation())
|
||||
|
||||
|
||||
@@ -17,6 +17,25 @@ transport for the Python Console API.
|
||||
- Routes/layouts own prefetching and hydration. Use the existing
|
||||
[QueryClient factory].
|
||||
|
||||
## Policy ownership
|
||||
|
||||
Shared operation defaults belong in `query-policies.ts`. Call sites own inputs,
|
||||
execution conditions, projections, and interaction feedback. A local option
|
||||
overrides the corresponding default; callbacks are not composed automatically.
|
||||
When a mutation uses shared `onSettled` invalidation, keep local toast, close, and
|
||||
navigation behavior in `onSuccess` / `onError`. Do not override `onSettled` without
|
||||
preserving that cache contract. The returned invalidation promise keeps the
|
||||
mutation pending until active consumers have refreshed.
|
||||
|
||||
List affected generated keys inline in the owning operation's policy. Whether
|
||||
invalidation runs after an error depends on the write contract: a rejected
|
||||
request does not imply the server rolled back. Record that decision per module.
|
||||
Do not add an invalidation helper to hide the cache dependencies.
|
||||
|
||||
Use generated options and types directly. A feature-owned options factory is
|
||||
appropriate only when it owns a shared request policy or a composed query;
|
||||
forwarding hooks and handwritten API DTOs create a second owner.
|
||||
|
||||
[Browser transport]: ./browser.ts
|
||||
[Instrumentation]: ../../instrumentation.ts
|
||||
[QueryClient factory]: ../../app/get-query-client.ts
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { QueryKey } from '@tanstack/react-query'
|
||||
import { MutationObserver, QueryClient } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { consoleQuery } from './index'
|
||||
|
||||
const { request } = vi.hoisted(() => ({
|
||||
request:
|
||||
vi.fn<(url: string, init: RequestInit, options: { request: Request }) => Promise<Response>>(),
|
||||
}))
|
||||
|
||||
vi.mock('../base', () => ({ request }))
|
||||
|
||||
const endpoints = consoleQuery.workspaces.current.endpoints
|
||||
const plugins = consoleQuery.workspaces.current.plugin
|
||||
|
||||
const mutations = [
|
||||
{
|
||||
name: 'create',
|
||||
method: 'POST',
|
||||
path: '/endpoints',
|
||||
run: (client: QueryClient, onSuccess: () => void) =>
|
||||
new MutationObserver(client, endpoints.post.mutationOptions({ onSuccess })).mutate({
|
||||
body: { name: 'Endpoint', plugin_unique_identifier: 'plugin@1.0', settings: {} },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'update',
|
||||
method: 'PATCH',
|
||||
path: '/endpoints/endpoint-1',
|
||||
run: (client: QueryClient, onSuccess: () => void) =>
|
||||
new MutationObserver(client, endpoints.byId.patch.mutationOptions({ onSuccess })).mutate({
|
||||
params: { id: 'endpoint-1' },
|
||||
body: { name: 'Renamed', settings: { enabled: false, limit: 0, token: '' } },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'delete',
|
||||
method: 'DELETE',
|
||||
path: '/endpoints/endpoint-1',
|
||||
run: (client: QueryClient, onSuccess: () => void) =>
|
||||
new MutationObserver(client, endpoints.byId.delete.mutationOptions({ onSuccess })).mutate({
|
||||
params: { id: 'endpoint-1' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'enable',
|
||||
method: 'POST',
|
||||
path: '/endpoints/enable',
|
||||
run: (client: QueryClient, onSuccess: () => void) =>
|
||||
new MutationObserver(client, endpoints.enable.post.mutationOptions({ onSuccess })).mutate({
|
||||
body: { endpoint_id: 'endpoint-1' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'disable',
|
||||
method: 'POST',
|
||||
path: '/endpoints/disable',
|
||||
run: (client: QueryClient, onSuccess: () => void) =>
|
||||
new MutationObserver(client, endpoints.disable.post.mutationOptions({ onSuccess })).mutate({
|
||||
body: { endpoint_id: 'endpoint-1' },
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
function seedEndpointCaches(client: QueryClient) {
|
||||
const affectedKeys: QueryKey[] = [
|
||||
endpoints.list.get.queryKey({ input: { query: { page: 1, page_size: 100 } } }),
|
||||
endpoints.list.plugin.get.queryKey({
|
||||
input: { query: { page: 1, page_size: 100, plugin_id: 'plugin-1' } },
|
||||
}),
|
||||
endpoints.list.plugin.get.queryKey({
|
||||
input: { query: { page: 2, page_size: 20, plugin_id: 'plugin-2' } },
|
||||
}),
|
||||
plugins.list.get.key({ type: 'query' }),
|
||||
plugins.byCategory.list.get.key({
|
||||
type: 'infinite',
|
||||
input: { params: { category: 'extension' } },
|
||||
}),
|
||||
]
|
||||
const unrelatedKeys: QueryKey[] = [
|
||||
plugins.installedIds.get.key({ type: 'query' }),
|
||||
consoleQuery.account.profile.get.key({ type: 'query' }),
|
||||
]
|
||||
|
||||
for (const queryKey of [...affectedKeys, ...unrelatedKeys])
|
||||
client.setQueryData(queryKey, { cached: true })
|
||||
|
||||
return { affectedKeys, unrelatedKeys }
|
||||
}
|
||||
|
||||
describe('endpoint mutation cache policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each(mutations)(
|
||||
'$name refreshes endpoint lists and plugin counts with local feedback',
|
||||
async ({ run, method, path }) => {
|
||||
const client = new QueryClient()
|
||||
const { affectedKeys, unrelatedKeys } = seedEndpointCaches(client)
|
||||
const onSuccess = vi.fn()
|
||||
request.mockResolvedValue(Response.json({ success: true }))
|
||||
|
||||
await run(client, onSuccess)
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledOnce()
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/workspaces/current${path}`),
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ request: expect.objectContaining({ method }) }),
|
||||
)
|
||||
if (method === 'PATCH') {
|
||||
const sentRequest = request.mock.calls[0]?.[2].request
|
||||
expect(await sentRequest?.json()).toEqual({
|
||||
name: 'Renamed',
|
||||
settings: { enabled: false, limit: 0, token: '' },
|
||||
})
|
||||
}
|
||||
for (const key of affectedKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(true)
|
||||
for (const key of unrelatedKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(false)
|
||||
client.clear()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(mutations)(
|
||||
'$name invalidates uncertain server state and skips success feedback on failure',
|
||||
async ({ run }) => {
|
||||
const client = new QueryClient()
|
||||
const { affectedKeys } = seedEndpointCaches(client)
|
||||
const onSuccess = vi.fn()
|
||||
request.mockResolvedValue(
|
||||
Response.json({ message: 'Endpoint operation failed' }, { status: 500 }),
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(run(client, onSuccess)).rejects.toThrow()
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
for (const key of affectedKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(true)
|
||||
} finally {
|
||||
client.clear()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('reconciles after a lost response without retrying the create', async () => {
|
||||
const client = new QueryClient()
|
||||
const { affectedKeys, unrelatedKeys } = seedEndpointCaches(client)
|
||||
const onSuccess = vi.fn()
|
||||
request.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
const mutation = new MutationObserver(client, endpoints.post.mutationOptions({ onSuccess }))
|
||||
|
||||
try {
|
||||
await expect(
|
||||
mutation.mutate({
|
||||
body: { name: 'Endpoint', plugin_unique_identifier: 'plugin@1.0', settings: {} },
|
||||
}),
|
||||
).rejects.toThrow('Failed to fetch')
|
||||
expect(request).toHaveBeenCalledOnce()
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
for (const key of affectedKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(true)
|
||||
for (const key of unrelatedKeys) expect(client.getQueryState(key)?.isInvalidated).toBe(false)
|
||||
} finally {
|
||||
client.clear()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,99 @@ export function createConsoleQuery(consoleClient: ConsoleClient) {
|
||||
experimental_defaults: {
|
||||
workspaces: {
|
||||
current: {
|
||||
endpoints: {
|
||||
post: {
|
||||
mutationOptions: {
|
||||
onSettled: (_data, _error, _variables, _result, context) => {
|
||||
return Promise.all([
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.endpoints.list.key(),
|
||||
}),
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.list.get.key(),
|
||||
}),
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.byCategory.list.get.key(),
|
||||
}),
|
||||
])
|
||||
},
|
||||
},
|
||||
},
|
||||
byId: {
|
||||
patch: {
|
||||
mutationOptions: {
|
||||
onSettled: (_data, _error, _variables, _result, context) => {
|
||||
return Promise.all([
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.endpoints.list.key(),
|
||||
}),
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.list.get.key(),
|
||||
}),
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.byCategory.list.get.key(),
|
||||
}),
|
||||
])
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: {
|
||||
onSettled: (_data, _error, _variables, _result, context) => {
|
||||
return Promise.all([
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.endpoints.list.key(),
|
||||
}),
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.list.get.key(),
|
||||
}),
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.byCategory.list.get.key(),
|
||||
}),
|
||||
])
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
enable: {
|
||||
post: {
|
||||
mutationOptions: {
|
||||
onSettled: (_data, _error, _variables, _result, context) => {
|
||||
return Promise.all([
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.endpoints.list.key(),
|
||||
}),
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.list.get.key(),
|
||||
}),
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.byCategory.list.get.key(),
|
||||
}),
|
||||
])
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
disable: {
|
||||
post: {
|
||||
mutationOptions: {
|
||||
onSettled: (_data, _error, _variables, _result, context) => {
|
||||
return Promise.all([
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.endpoints.list.key(),
|
||||
}),
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.list.get.key(),
|
||||
}),
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.plugin.byCategory.list.get.key(),
|
||||
}),
|
||||
])
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
bySkillId: {
|
||||
delete: {
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import type { EndpointsResponse } from '@/app/components/plugins/types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { get, post } from './base'
|
||||
|
||||
const NAME_SPACE = 'endpoints'
|
||||
|
||||
export const useEndpointList = (pluginID: string) => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'list', pluginID],
|
||||
queryFn: () =>
|
||||
get<EndpointsResponse>('/workspaces/current/endpoints/list/plugin', {
|
||||
params: {
|
||||
plugin_id: pluginID,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export const useInvalidateEndpointList = () => {
|
||||
const queryClient = useQueryClient()
|
||||
return (pluginID: string) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [NAME_SPACE, 'list', pluginID],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const useCreateEndpoint = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess?: () => void
|
||||
onError?: (error: any) => void
|
||||
}) => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'create'],
|
||||
mutationFn: (payload: { pluginUniqueID: string; state: Record<string, any> }) => {
|
||||
const { pluginUniqueID, state } = payload
|
||||
const newName = state.name
|
||||
delete state.name
|
||||
return post('/workspaces/current/endpoints/create', {
|
||||
body: {
|
||||
plugin_unique_identifier: pluginUniqueID,
|
||||
settings: state,
|
||||
name: newName,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess,
|
||||
onError,
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdateEndpoint = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess?: () => void
|
||||
onError?: (error: any) => void
|
||||
}) => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'update'],
|
||||
mutationFn: (payload: { endpointID: string; state: Record<string, any> }) => {
|
||||
const { endpointID, state } = payload
|
||||
const newName = state.name
|
||||
delete state.name
|
||||
return post('/workspaces/current/endpoints/update', {
|
||||
body: {
|
||||
endpoint_id: endpointID,
|
||||
settings: state,
|
||||
name: newName,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess,
|
||||
onError,
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteEndpoint = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess?: () => void
|
||||
onError?: (error: any) => void
|
||||
}) => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'delete'],
|
||||
mutationFn: (endpointID: string) => {
|
||||
return post('/workspaces/current/endpoints/delete', {
|
||||
body: {
|
||||
endpoint_id: endpointID,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess,
|
||||
onError,
|
||||
})
|
||||
}
|
||||
|
||||
export const useEnableEndpoint = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess?: () => void
|
||||
onError?: (error: any) => void
|
||||
}) => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'enable'],
|
||||
mutationFn: (endpointID: string) => {
|
||||
return post('/workspaces/current/endpoints/enable', {
|
||||
body: {
|
||||
endpoint_id: endpointID,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess,
|
||||
onError,
|
||||
})
|
||||
}
|
||||
|
||||
export const useDisableEndpoint = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess?: () => void
|
||||
onError?: (error: any) => void
|
||||
}) => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'disable'],
|
||||
mutationFn: (endpointID: string) => {
|
||||
return post('/workspaces/current/endpoints/disable', {
|
||||
body: {
|
||||
endpoint_id: endpointID,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess,
|
||||
onError,
|
||||
})
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
VersionListResponse,
|
||||
} from '@/app/components/plugins/types'
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
import { zEndpointProviderDeclarationResponse } from '@dify/contracts/api/console/workspaces/zod.gen'
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { cloneDeep } from 'es-toolkit/object'
|
||||
import { useAtomValue } from 'jotai'
|
||||
@@ -222,19 +223,6 @@ const normalizePluginToolDeclaration = (value: unknown): PluginDeclaration['tool
|
||||
}
|
||||
}
|
||||
|
||||
const normalizePluginEndpointDeclaration = (value: unknown): PluginDeclaration['endpoint'] => {
|
||||
if (!isRecord(value)) return undefined
|
||||
|
||||
return {
|
||||
settings: getRecordArray(value, 'settings').map(normalizeToolCredential),
|
||||
endpoints: getRecordArray(value, 'endpoints').map((endpoint) => ({
|
||||
path: getString(endpoint.path),
|
||||
method: getString(endpoint.method),
|
||||
hidden: endpoint.hidden === undefined ? undefined : getBoolean(endpoint.hidden),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeParameterDefault = (value: unknown) => {
|
||||
if (Array.isArray(value)) return value.filter((item) => typeof item === 'string')
|
||||
if (typeof value === 'string') return value
|
||||
@@ -440,7 +428,9 @@ const normalizePluginDeclaration = (plugin: InstalledPluginResponse): PluginDecl
|
||||
resource: declaration.resource,
|
||||
plugins: declaration.plugins,
|
||||
verified: declaration.verified ?? false,
|
||||
endpoint: normalizePluginEndpointDeclaration(declaration.endpoint),
|
||||
endpoint: isRecord(declaration.endpoint)
|
||||
? zEndpointProviderDeclarationResponse.parse(declaration.endpoint)
|
||||
: undefined,
|
||||
tool: normalizePluginToolDeclaration(declaration.tool),
|
||||
datasource: normalizePluginToolDeclaration(declaration.datasource),
|
||||
model: declaration.model,
|
||||
|
||||
Reference in new issue
Block a user