mirror of
https://github.com/langgenius/dify.git
synced 2026-05-19 08:17:14 +08:00
Compare commits
18 Commits
main
...
codex/dify
| Author | SHA1 | Date | |
|---|---|---|---|
| d8dd5fe0d9 | |||
| 521af725e4 | |||
| 8208ac0306 | |||
| e2fa7e229b | |||
| cd04f1d962 | |||
| 2a74ab1e9c | |||
| 4a27cd94c4 | |||
| 88597c1b93 | |||
| 261cbfff12 | |||
| 0d0f5d68df | |||
| 02245a17c3 | |||
| 933e327519 | |||
| 362624adcd | |||
| c1779112c6 | |||
| f404b3eac1 | |||
| 78394689c2 | |||
| 9abaa60cfc | |||
| afa212ff80 |
@ -70,6 +70,21 @@ def _serialize_api_based_extension(extension: APIBasedExtension) -> dict[str, An
|
||||
return APIBasedExtensionResponse.model_validate(extension, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
|
||||
def _serialize_saved_api_based_extension(extension: APIBasedExtension, api_key: str) -> dict[str, Any]:
|
||||
"""Serialize a saved extension with the plaintext key used for response masking only.
|
||||
|
||||
APIBasedExtensionService.save mutates the ORM object to hold the encrypted token before returning it. The response
|
||||
contract, however, should match list/detail responses, where api_key is masked from the decrypted token.
|
||||
"""
|
||||
return APIBasedExtensionResponse(
|
||||
id=extension.id,
|
||||
name=extension.name,
|
||||
api_endpoint=extension.api_endpoint,
|
||||
api_key=api_key,
|
||||
created_at=to_timestamp(extension.created_at),
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/code-based-extension")
|
||||
class CodeBasedExtensionAPI(Resource):
|
||||
@console_ns.doc("get_code_based_extension")
|
||||
@ -125,7 +140,7 @@ class APIBasedExtensionAPI(Resource):
|
||||
api_key=payload.api_key,
|
||||
)
|
||||
|
||||
return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data))
|
||||
return _serialize_saved_api_based_extension(APIBasedExtensionService.save(extension_data), payload.api_key), 201
|
||||
|
||||
|
||||
@console_ns.route("/api-based-extension/<uuid:id>")
|
||||
@ -160,14 +175,19 @@ class APIBasedExtensionDetailAPI(Resource):
|
||||
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(current_tenant_id, api_based_extension_id)
|
||||
|
||||
payload = APIBasedExtensionPayload.model_validate(console_ns.payload or {})
|
||||
api_key_for_response = extension_data_from_db.api_key
|
||||
|
||||
extension_data_from_db.name = payload.name
|
||||
extension_data_from_db.api_endpoint = payload.api_endpoint
|
||||
|
||||
if payload.api_key != HIDDEN_VALUE:
|
||||
extension_data_from_db.api_key = payload.api_key
|
||||
api_key_for_response = payload.api_key
|
||||
|
||||
return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data_from_db))
|
||||
return _serialize_saved_api_based_extension(
|
||||
APIBasedExtensionService.save(extension_data_from_db),
|
||||
api_key_for_response,
|
||||
)
|
||||
|
||||
@console_ns.doc("delete_api_based_extension")
|
||||
@console_ns.doc(description="Delete API-based extension")
|
||||
|
||||
@ -16,7 +16,6 @@ from pydantic import TypeAdapter
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.agent.entities import AgentToolEntity
|
||||
from core.helper import marketplace
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
@ -311,8 +310,6 @@ class PluginMigration:
|
||||
"""
|
||||
Fetch plugin unique identifier using plugin id.
|
||||
"""
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
return None
|
||||
plugin_manifest = marketplace.batch_fetch_plugin_manifests([plugin_id])
|
||||
if not plugin_manifest:
|
||||
return None
|
||||
@ -545,11 +542,6 @@ class PluginMigration:
|
||||
"""
|
||||
Install plugins for a tenant.
|
||||
"""
|
||||
if plugin_identifiers_map and not dify_config.MARKETPLACE_ENABLED:
|
||||
raise ValueError(
|
||||
"Marketplace disabled in offline mode; cannot bulk-install plugins. "
|
||||
"Pre-upload plugin packages via Console first."
|
||||
)
|
||||
manager = PluginInstaller()
|
||||
|
||||
# download all the plugins and upload
|
||||
|
||||
@ -73,43 +73,35 @@ class PluginService:
|
||||
cache_not_exists.append(plugin_id)
|
||||
|
||||
if cache_not_exists:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.info(
|
||||
"Marketplace disabled; skipping latest-plugins metadata fetch for %d ids",
|
||||
len(cache_not_exists),
|
||||
manifests = {
|
||||
manifest.plugin_id: manifest
|
||||
for manifest in marketplace.batch_fetch_plugin_manifests(cache_not_exists)
|
||||
}
|
||||
|
||||
for plugin_id, manifest in manifests.items():
|
||||
latest_plugin = PluginService.LatestPluginCache(
|
||||
plugin_id=plugin_id,
|
||||
version=manifest.latest_version,
|
||||
unique_identifier=manifest.latest_package_identifier,
|
||||
status=manifest.status,
|
||||
deprecated_reason=manifest.deprecated_reason,
|
||||
alternative_plugin_id=manifest.alternative_plugin_id,
|
||||
)
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
else:
|
||||
manifests = {
|
||||
manifest.plugin_id: manifest
|
||||
for manifest in marketplace.batch_fetch_plugin_manifests(cache_not_exists)
|
||||
}
|
||||
|
||||
for plugin_id, manifest in manifests.items():
|
||||
latest_plugin = PluginService.LatestPluginCache(
|
||||
plugin_id=plugin_id,
|
||||
version=manifest.latest_version,
|
||||
unique_identifier=manifest.latest_package_identifier,
|
||||
status=manifest.status,
|
||||
deprecated_reason=manifest.deprecated_reason,
|
||||
alternative_plugin_id=manifest.alternative_plugin_id,
|
||||
)
|
||||
# Store in Redis
|
||||
redis_client.setex(
|
||||
f"{PluginService.REDIS_KEY_PREFIX}{plugin_id}",
|
||||
PluginService.REDIS_TTL,
|
||||
latest_plugin.model_dump_json(),
|
||||
)
|
||||
|
||||
# Store in Redis
|
||||
redis_client.setex(
|
||||
f"{PluginService.REDIS_KEY_PREFIX}{plugin_id}",
|
||||
PluginService.REDIS_TTL,
|
||||
latest_plugin.model_dump_json(),
|
||||
)
|
||||
result[plugin_id] = latest_plugin
|
||||
|
||||
result[plugin_id] = latest_plugin
|
||||
# pop plugin_id from cache_not_exists
|
||||
cache_not_exists.remove(plugin_id)
|
||||
|
||||
# pop plugin_id from cache_not_exists
|
||||
cache_not_exists.remove(plugin_id)
|
||||
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
|
||||
@ -1350,12 +1350,6 @@ class RagPipelineService:
|
||||
)
|
||||
return workflow_node_execution_db_model
|
||||
|
||||
def _fetch_recommended_plugin_manifests(self, plugin_ids: list[str]) -> list[Any]:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.info("Marketplace disabled; recommended-plugins list empty")
|
||||
return []
|
||||
return marketplace.batch_fetch_plugin_by_ids(plugin_ids)
|
||||
|
||||
def get_recommended_plugins(self, type: str) -> dict[str, Any]:
|
||||
# Query active recommended plugins
|
||||
stmt = select(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True)
|
||||
@ -1378,7 +1372,7 @@ class RagPipelineService:
|
||||
)
|
||||
providers_map = {provider.plugin_id: provider.to_dict() for provider in providers}
|
||||
|
||||
plugin_manifests = self._fetch_recommended_plugin_manifests(plugin_ids)
|
||||
plugin_manifests = marketplace.batch_fetch_plugin_by_ids(plugin_ids)
|
||||
plugin_manifests_map = {manifest["plugin_id"]: manifest for manifest in plugin_manifests}
|
||||
|
||||
installed_plugin_list = []
|
||||
|
||||
@ -9,7 +9,6 @@ import yaml
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from constants import DOCUMENT_EXTENSIONS
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
|
||||
@ -274,13 +273,6 @@ class RagPipelineTransformService:
|
||||
plugin_unique_identifier = dependency.get("value", {}).get("plugin_unique_identifier")
|
||||
plugin_id = plugin_unique_identifier.split(":")[0]
|
||||
if plugin_id not in installed_plugins_ids:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.warning(
|
||||
"Marketplace disabled; skipping auto-install of %s. "
|
||||
"Pre-install via Console if pipeline requires it.",
|
||||
plugin_id,
|
||||
)
|
||||
continue
|
||||
plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(plugin_id) # type: ignore
|
||||
if plugin_unique_identifier:
|
||||
need_install_plugin_unique_identifiers.append(plugin_unique_identifier)
|
||||
|
||||
@ -0,0 +1,126 @@
|
||||
"""Integration tests for console API-based extension endpoints using testcontainers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants import HIDDEN_VALUE
|
||||
from libs.rsa import generate_key_pair
|
||||
from models import Tenant
|
||||
from tests.test_containers_integration_tests.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
create_console_account_and_tenant,
|
||||
)
|
||||
|
||||
|
||||
def _masked_api_key(api_key: str) -> str:
|
||||
if len(api_key) <= 8:
|
||||
return api_key[0] + "******" + api_key[-1]
|
||||
return api_key[:3] + "******" + api_key[-3:]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_extension_client(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> tuple[FlaskClient, dict[str, str], Tenant]:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
tenant.encrypt_public_key = generate_key_pair(tenant.id)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
headers = authenticate_console_client(test_client_with_containers, account)
|
||||
return test_client_with_containers, headers, tenant
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_api_based_extension_ping():
|
||||
with patch("services.api_based_extension_service.APIBasedExtensionRequestor") as requestor:
|
||||
requestor.return_value.request.return_value = {"result": "pong"}
|
||||
yield requestor
|
||||
|
||||
|
||||
def test_create_response_masks_plaintext_api_key(
|
||||
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
|
||||
) -> None:
|
||||
client, headers, _ = api_extension_client
|
||||
api_key = "plain-secret-12345"
|
||||
|
||||
response = client.post(
|
||||
"/console/api/api-based-extension",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API",
|
||||
"api_endpoint": "https://docs.example.com/hook",
|
||||
"api_key": api_key,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json is not None
|
||||
assert response.json["api_key"] == _masked_api_key(api_key)
|
||||
|
||||
|
||||
def test_update_response_masks_new_plaintext_api_key(
|
||||
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
|
||||
) -> None:
|
||||
client, headers, _ = api_extension_client
|
||||
new_api_key = "new-secret-67890"
|
||||
create_response = client.post(
|
||||
"/console/api/api-based-extension",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API",
|
||||
"api_endpoint": "https://docs.example.com/hook",
|
||||
"api_key": "old-secret-12345",
|
||||
},
|
||||
)
|
||||
assert create_response.json is not None
|
||||
|
||||
update_response = client.post(
|
||||
f"/console/api/api-based-extension/{create_response.json['id']}",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API Updated",
|
||||
"api_endpoint": "https://docs.example.com/v2",
|
||||
"api_key": new_api_key,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json is not None
|
||||
assert update_response.json["api_key"] == _masked_api_key(new_api_key)
|
||||
|
||||
|
||||
def test_update_response_masks_existing_plaintext_api_key_when_hidden_value_is_submitted(
|
||||
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
|
||||
) -> None:
|
||||
client, headers, _ = api_extension_client
|
||||
existing_api_key = "old-secret-12345"
|
||||
create_response = client.post(
|
||||
"/console/api/api-based-extension",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API",
|
||||
"api_endpoint": "https://docs.example.com/hook",
|
||||
"api_key": existing_api_key,
|
||||
},
|
||||
)
|
||||
assert create_response.json is not None
|
||||
|
||||
update_response = client.post(
|
||||
f"/console/api/api-based-extension/{create_response.json['id']}",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API Updated",
|
||||
"api_endpoint": "https://docs.example.com/v2",
|
||||
"api_key": HIDDEN_VALUE,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json is not None
|
||||
assert update_response.json["api_key"] == _masked_api_key(existing_api_key)
|
||||
@ -44,6 +44,12 @@ def _make_extension(
|
||||
return extension
|
||||
|
||||
|
||||
def _masked_api_key(api_key: str) -> str:
|
||||
if len(api_key) <= 8:
|
||||
return api_key[0] + "******" + api_key[-1]
|
||||
return api_key[:3] + "******" + api_key[-3:]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_console_guards(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
"""Bypass console decorators so handlers can run in isolation."""
|
||||
@ -114,7 +120,7 @@ def test_api_based_extension_get_returns_tenant_extensions(app: Flask, monkeypat
|
||||
|
||||
|
||||
def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
saved_extension = _make_extension(name="Docs API", api_key="saved-secret")
|
||||
saved_extension = _make_extension(name="Docs API", api_key="encrypted-token-from-save")
|
||||
save_mock = MagicMock(return_value=saved_extension)
|
||||
monkeypatch.setattr("controllers.console.extension.APIBasedExtensionService.save", save_mock)
|
||||
|
||||
@ -125,7 +131,7 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt
|
||||
}
|
||||
|
||||
with app.test_request_context("/console/api/api-based-extension", method="POST", json=payload):
|
||||
response = APIBasedExtensionAPI().post()
|
||||
response, status = APIBasedExtensionAPI().post()
|
||||
|
||||
args, _ = save_mock.call_args
|
||||
created_extension: APIBasedExtension = args[0]
|
||||
@ -133,7 +139,9 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt
|
||||
assert created_extension.name == payload["name"]
|
||||
assert created_extension.api_endpoint == payload["api_endpoint"]
|
||||
assert created_extension.api_key == payload["api_key"]
|
||||
assert status == 201
|
||||
assert response["name"] == saved_extension.name
|
||||
assert response["api_key"] == _masked_api_key(payload["api_key"])
|
||||
save_mock.assert_called_once()
|
||||
|
||||
|
||||
@ -183,6 +191,7 @@ def test_api_based_extension_detail_post_keeps_hidden_api_key(app: Flask, monkey
|
||||
assert existing_extension.api_key == "keep-me"
|
||||
save_mock.assert_called_once_with(existing_extension)
|
||||
assert response["name"] == payload["name"]
|
||||
assert response["api_key"] == _masked_api_key("keep-me")
|
||||
|
||||
|
||||
def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
@ -212,6 +221,7 @@ def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flas
|
||||
assert existing_extension.api_key == "new-secret"
|
||||
save_mock.assert_called_once_with(existing_extension)
|
||||
assert response["name"] == payload["name"]
|
||||
assert response["api_key"] == _masked_api_key(payload["api_key"])
|
||||
|
||||
|
||||
def test_api_based_extension_detail_delete_removes_extension(app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from services.plugin.plugin_migration import PluginMigration
|
||||
|
||||
MIGRATION_MODULE = "services.plugin.plugin_migration"
|
||||
|
||||
|
||||
def test_fetch_plugin_unique_identifier_returns_none_when_disabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", False)
|
||||
batch_fetch = mocker.patch("services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests")
|
||||
|
||||
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
|
||||
|
||||
assert result is None
|
||||
batch_fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_plugin_unique_identifier_calls_marketplace_when_enabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", True)
|
||||
manifest = mocker.MagicMock()
|
||||
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
|
||||
mocker.patch(
|
||||
"services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests",
|
||||
return_value=[manifest],
|
||||
)
|
||||
|
||||
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
|
||||
|
||||
assert result == "langgenius/openai:1.0.0@abc"
|
||||
|
||||
|
||||
class TestHandlePluginInstanceInstall:
|
||||
def test_raises_when_disabled_and_map_nonempty(self) -> None:
|
||||
with patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg:
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
|
||||
with pytest.raises(ValueError, match="Marketplace disabled"):
|
||||
PluginMigration.handle_plugin_instance_install(
|
||||
"tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"}
|
||||
)
|
||||
|
||||
def test_no_raise_when_disabled_and_map_empty(self) -> None:
|
||||
with (
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
mock_installer = MagicMock()
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
mock_installer.install_from_identifiers.return_value = MagicMock(all_installed=True)
|
||||
|
||||
result = PluginMigration.handle_plugin_instance_install("tenant1", {})
|
||||
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_proceeds_when_enabled(self) -> None:
|
||||
with (
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.marketplace") as mock_marketplace,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_marketplace.download_plugin_pkg.return_value = b"pkg_data"
|
||||
mock_installer = MagicMock()
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
mock_installer.install_from_identifiers.return_value = MagicMock(all_installed=True)
|
||||
|
||||
result = PluginMigration.handle_plugin_instance_install(
|
||||
"tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"}
|
||||
)
|
||||
|
||||
mock_marketplace.download_plugin_pkg.assert_called_once()
|
||||
assert "success" in result or "failed" in result
|
||||
@ -1,50 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
MODULE = "services.plugin.plugin_service"
|
||||
|
||||
|
||||
class TestFetchLatestPluginVersion:
|
||||
def test_skips_marketplace_fetch_when_disabled(self) -> None:
|
||||
"""Cache misses stay None; marketplace is never called when disabled."""
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MODULE}.redis_client") as mock_redis,
|
||||
patch(f"{MODULE}.marketplace") as mock_marketplace,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
mock_redis.get.return_value = None # all cache misses
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai", "langgenius/anthropic"])
|
||||
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_not_called()
|
||||
assert result == {"langgenius/openai": None, "langgenius/anthropic": None}
|
||||
|
||||
def test_calls_marketplace_fetch_when_enabled(self) -> None:
|
||||
"""Cache misses trigger marketplace fetch when enabled."""
|
||||
manifest = MagicMock()
|
||||
manifest.plugin_id = "langgenius/openai"
|
||||
manifest.latest_version = "1.0.0"
|
||||
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
|
||||
manifest.status = "active"
|
||||
manifest.deprecated_reason = ""
|
||||
manifest.alternative_plugin_id = ""
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MODULE}.redis_client") as mock_redis,
|
||||
patch(f"{MODULE}.marketplace") as mock_marketplace,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_redis.get.return_value = None
|
||||
mock_marketplace.batch_fetch_plugin_manifests.return_value = [manifest]
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai"])
|
||||
|
||||
# The list arg is mutated by remove() after the call, so check call count + result.
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_called_once()
|
||||
assert result["langgenius/openai"] is not None
|
||||
assert result["langgenius/openai"].version == "1.0.0"
|
||||
@ -1,36 +0,0 @@
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
|
||||
|
||||
def _make_service() -> RagPipelineService:
|
||||
return RagPipelineService.__new__(RagPipelineService)
|
||||
|
||||
|
||||
def test_fetch_recommended_plugin_manifests_returns_empty_when_disabled(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", False)
|
||||
batch_fetch = mocker.patch("services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids")
|
||||
|
||||
service = _make_service()
|
||||
result = service._fetch_recommended_plugin_manifests(["langgenius/openai"])
|
||||
|
||||
assert result == []
|
||||
batch_fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_recommended_plugin_manifests_returns_data_when_enabled(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", True)
|
||||
expected = [{"plugin_id": "langgenius/openai", "name": "OpenAI"}]
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids",
|
||||
return_value=expected,
|
||||
)
|
||||
|
||||
service = _make_service()
|
||||
result = service._fetch_recommended_plugin_manifests(["langgenius/openai"])
|
||||
|
||||
assert result == expected
|
||||
@ -1,10 +1,8 @@
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from models.dataset import Dataset
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import KnowledgeConfiguration
|
||||
@ -516,64 +514,3 @@ def test_deal_document_data_upload_file_with_existing_file(mocker) -> None:
|
||||
assert document.data_source_type == "local_file"
|
||||
assert "real_file_id" in document.data_source_info
|
||||
assert add_mock.call_count >= 2
|
||||
|
||||
|
||||
def _make_service():
|
||||
return RagPipelineTransformService.__new__(RagPipelineTransformService)
|
||||
|
||||
|
||||
def test_deal_dependencies_skips_marketplace_when_disabled(mocker: MockerFixture, caplog) -> None:
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED",
|
||||
False,
|
||||
)
|
||||
installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value
|
||||
installer.list_plugins.return_value = []
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration")
|
||||
install_call = mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
|
||||
)
|
||||
|
||||
pipeline_yaml = {
|
||||
"dependencies": [
|
||||
{
|
||||
"type": "marketplace",
|
||||
"value": {"plugin_unique_identifier": "langgenius/openai:1.0.0@abc"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service = _make_service()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
service._deal_dependencies(pipeline_yaml, "tenant-1")
|
||||
|
||||
install_call.assert_not_called()
|
||||
assert any("Marketplace disabled" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_deal_dependencies_installs_when_enabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED",
|
||||
True,
|
||||
)
|
||||
installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value
|
||||
installer.list_plugins.return_value = []
|
||||
migration = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration").return_value
|
||||
migration._fetch_plugin_unique_identifier.return_value = "langgenius/openai:1.0.0@abc"
|
||||
install_call = mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
|
||||
)
|
||||
|
||||
pipeline_yaml = {
|
||||
"dependencies": [
|
||||
{
|
||||
"type": "marketplace",
|
||||
"value": {"plugin_unique_identifier": "langgenius/openai:1.0.0@abc"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service = _make_service()
|
||||
service._deal_dependencies(pipeline_yaml, "tenant-1")
|
||||
|
||||
install_call.assert_called_once_with("tenant-1", ["langgenius/openai:1.0.0@abc"])
|
||||
|
||||
@ -4642,11 +4642,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/service/client.spec.ts": {
|
||||
"next/no-assign-module-variable": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/common.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 29
|
||||
|
||||
@ -29,6 +29,8 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
|
||||
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import '@langgenius/dify-ui/styles.css' // once, in the app root
|
||||
```
|
||||
@ -37,18 +39,48 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
|
||||
## Primitives
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
|
||||
| Category | Subpath | Notes |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./checkbox`, `./checkbox-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
|
||||
|
||||
Utilities:
|
||||
|
||||
- `./cn` — `clsx` + `tailwind-merge` wrapper. Use this for conditional class composition.
|
||||
- `./styles.css` — the one CSS entry that ships the design tokens, theme variables, and project utilities/components. Import it once from the app root.
|
||||
|
||||
## Form contract
|
||||
|
||||
Dify UI's form primitives are a Base UI composition layer for native form semantics, field accessibility, and design-system styling. They are intentionally not a form state-management framework. See the upstream [Base UI Form], [Base UI Field], and [Base UI Fieldset] docs for the underlying component contracts.
|
||||
|
||||
Use `Form` for the submit boundary. It renders a native `<form>`, preserves Enter-to-submit and submit-button behavior, and adds Base UI's `onFormSubmit`, `errors`, `actionsRef`, and `validationMode` APIs for structured values and consolidated field validation. Prefer it over a bare `<form>` when the form is composed with Dify UI fields.
|
||||
|
||||
Use `FieldRoot` for each named field. A field must have a stable `name`, a visible `FieldLabel`, and either a `FieldControl` or another control that participates in the same Base UI field context. `FieldLabel`, `FieldDescription`, and `FieldError` provide the label and message relationships that screen readers need, while the Dify wrapper adds the default Form Input Set styling from the design system.
|
||||
|
||||
Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group of related controls, such as checkbox groups, radio groups, or multi-thumb sliders. Compose group controls with the Base UI pattern:
|
||||
|
||||
```tsx
|
||||
<FieldRoot name="allowedNetworkProtocols">
|
||||
<FieldsetRoot render={<CheckboxGroup />}>
|
||||
<FieldsetLegend>Allowed network protocols</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="https" />
|
||||
HTTPS
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
```
|
||||
|
||||
`FieldsetRoot` provides the group semantics and legend relationship. It does not own the interactive state of the grouped control. Pass `disabled`, `value`, `defaultValue`, and change handlers to the actual group primitive (`CheckboxGroup`, radio group, slider root, etc.) instead of relying on the fieldset wrapper to manage them.
|
||||
|
||||
For complex business forms, keep state ownership outside these primitives. TanStack Form, zod, server validation, dialog reset behavior, and schema-driven rendering belong to the feature layer in `web/`; they should pass `name`, `invalid`, `dirty`, `touched`, `value`, `onValueChange`, and errors into these primitives rather than replacing the field semantics.
|
||||
|
||||
Migration rule for `web/`: if a UI has a save/submit action, do not leave it as unrelated `Input` and `Button` pieces. Give it a real submit boundary with `Form` or a native `<form>`, attach visible field names through `FieldLabel`, expose helper/error text through `FieldDescription` / `FieldError`, and keep non-submit buttons as `type="button"`.
|
||||
|
||||
## Tailwind CSS v4 integration
|
||||
|
||||
This package uses Tailwind CSS v4's CSS-first configuration model. Consumers should import Tailwind from their own root stylesheet, then import this package's CSS entry:
|
||||
@ -138,6 +170,9 @@ See `[AGENTS.md](./AGENTS.md)` for:
|
||||
- Application state (`jotai`, `zustand`), data fetching (`ky`, `@tanstack/react-query`, `@orpc/*`), i18n (`next-i18next` / `react-i18next`), and routing (`next`) all live in `web/`. This package has zero dependencies on them and must stay that way so it can eventually be consumed by other apps or extracted.
|
||||
- Business components (chat, workflow, dataset views, etc.). Those belong in `web/app/components/...`.
|
||||
|
||||
[Base UI Field]: https://base-ui.com/react/components/field
|
||||
[Base UI Fieldset]: https://base-ui.com/react/components/fieldset
|
||||
[Base UI Form]: https://base-ui.com/react/components/form
|
||||
[Base UI Portal]: https://base-ui.com/react/overview/quick-start#portals
|
||||
[Base UI]: https://base-ui.com/react
|
||||
[Overlay & portal contract]: #overlay--portal-contract
|
||||
|
||||
@ -53,6 +53,18 @@
|
||||
"types": "./src/dropdown-menu/index.tsx",
|
||||
"import": "./src/dropdown-menu/index.tsx"
|
||||
},
|
||||
"./field": {
|
||||
"types": "./src/field/index.tsx",
|
||||
"import": "./src/field/index.tsx"
|
||||
},
|
||||
"./fieldset": {
|
||||
"types": "./src/fieldset/index.tsx",
|
||||
"import": "./src/fieldset/index.tsx"
|
||||
},
|
||||
"./form": {
|
||||
"types": "./src/form/index.tsx",
|
||||
"import": "./src/form/index.tsx"
|
||||
},
|
||||
"./meter": {
|
||||
"types": "./src/meter/index.tsx",
|
||||
"import": "./src/meter/index.tsx"
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Field } from '@base-ui/react/field'
|
||||
import { Fieldset } from '@base-ui/react/fieldset'
|
||||
import { useState } from 'react'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { Checkbox } from '../../checkbox'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '../../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
|
||||
import { CheckboxGroup } from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
@ -43,26 +43,26 @@ describe('CheckboxGroup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should compose with Base UI Field and Fieldset without losing labels', async () => {
|
||||
it('should compose with Dify UI Field and Fieldset without losing labels', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const screen = await render(
|
||||
<Field.Root name="features">
|
||||
<Fieldset.Root render={<CheckboxGroup value={['search']} onValueChange={onValueChange} />}>
|
||||
<Fieldset.Legend>Features</Fieldset.Legend>
|
||||
<Field.Item>
|
||||
<Field.Label>
|
||||
<FieldRoot name="features">
|
||||
<FieldsetRoot render={<CheckboxGroup value={['search']} onValueChange={onValueChange} />}>
|
||||
<FieldsetLegend>Features</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Checkbox value="search" />
|
||||
Search
|
||||
</Field.Label>
|
||||
</Field.Item>
|
||||
<Field.Item>
|
||||
<Field.Label>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Checkbox value="analytics" />
|
||||
Analytics
|
||||
</Field.Label>
|
||||
</Field.Item>
|
||||
</Fieldset.Root>
|
||||
</Field.Root>,
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>,
|
||||
)
|
||||
|
||||
const analytics = screen.getByRole('checkbox', { name: 'Analytics' })
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Field } from '@base-ui/react/field'
|
||||
import { Fieldset } from '@base-ui/react/fieldset'
|
||||
import { useId, useState } from 'react'
|
||||
import { CheckboxGroup } from '.'
|
||||
import { Checkbox } from '../checkbox'
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldItem,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/CheckboxGroup',
|
||||
title: 'Base/Form/CheckboxGroup',
|
||||
component: CheckboxGroup,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
@ -75,11 +80,11 @@ function DynamicFormFieldDemo() {
|
||||
const [selected, setSelected] = useState<string[]>(['markdown'])
|
||||
|
||||
return (
|
||||
<Field.Root name="allowed_file_types" className="flex w-80 flex-col gap-2">
|
||||
<Field.Description className="body-xs-regular text-text-tertiary">
|
||||
<FieldRoot name="allowed_file_types" className="flex w-80 flex-col gap-2">
|
||||
<FieldDescription className="body-xs-regular text-text-tertiary">
|
||||
This mirrors Dify dynamic form fields where checkbox options are controlled by schema and persisted as a string array.
|
||||
</Field.Description>
|
||||
<Fieldset.Root
|
||||
</FieldDescription>
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<CheckboxGroup
|
||||
value={selected}
|
||||
@ -88,19 +93,19 @@ function DynamicFormFieldDemo() {
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Fieldset.Legend className="system-sm-medium text-text-secondary">
|
||||
<FieldsetLegend className="system-sm-medium text-text-secondary">
|
||||
Allowed file types
|
||||
</Fieldset.Legend>
|
||||
</FieldsetLegend>
|
||||
{options.map(option => (
|
||||
<Field.Item key={option.value}>
|
||||
<Field.Label className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<FieldItem key={option.value}>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Checkbox value={option.value} />
|
||||
{option.label}
|
||||
</Field.Label>
|
||||
</Field.Item>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))}
|
||||
</Fieldset.Root>
|
||||
</Field.Root>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
} from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Checkbox',
|
||||
title: 'Base/Form/Checkbox',
|
||||
component: Checkbox,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@ -9,6 +9,8 @@ import {
|
||||
DialogTrigger,
|
||||
} from '.'
|
||||
import { Button } from '../button'
|
||||
import { FieldControl, FieldDescription, FieldError, FieldLabel, FieldRoot } from '../field'
|
||||
import { Form } from '../form'
|
||||
|
||||
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
|
||||
|
||||
@ -139,6 +141,89 @@ export const Controlled: Story = {
|
||||
render: () => <ControlledDemo />,
|
||||
}
|
||||
|
||||
type ApiExtensionFormValues = {
|
||||
name: string
|
||||
endpoint: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
const FormDialogDemo = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen} disablePointerDismissal>
|
||||
<DialogTrigger
|
||||
render={<button type="button" className={triggerButtonClassName} />}
|
||||
>
|
||||
Configure API extension
|
||||
</DialogTrigger>
|
||||
<DialogContent backdropProps={{ forceRender: true }} className="w-160">
|
||||
<DialogCloseButton />
|
||||
<div className="grid gap-2 pr-8">
|
||||
<DialogTitle className="text-lg leading-7 font-semibold text-text-primary">
|
||||
Configure API extension
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-5 text-text-secondary">
|
||||
Save the endpoint and credentials used by this workspace integration.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Form<ApiExtensionFormValues>
|
||||
className="grid gap-4 pt-5"
|
||||
onFormSubmit={() => setOpen(false)}
|
||||
>
|
||||
<FieldRoot name="name">
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldControl required placeholder="Production API" />
|
||||
<FieldError match="valueMissing">Name is required.</FieldError>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="endpoint">
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<FieldControl type="url" required placeholder="https://api.example.com" />
|
||||
<FieldDescription>
|
||||
<a
|
||||
href="https://docs.dify.ai/use-dify/workspace/api-extension/api-extension"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-fit items-center text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
>
|
||||
View API extension docs
|
||||
</a>
|
||||
</FieldDescription>
|
||||
<FieldError match="valueMissing">Endpoint is required.</FieldError>
|
||||
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
|
||||
</FieldRoot>
|
||||
<FieldRoot
|
||||
name="apiKey"
|
||||
validate={(value) => {
|
||||
if (typeof value === 'string' && value.length > 0 && value.length < 5)
|
||||
return 'API key must be at least 5 characters.'
|
||||
|
||||
return null
|
||||
}}
|
||||
>
|
||||
<FieldLabel>API key</FieldLabel>
|
||||
<FieldControl required placeholder="sk-..." />
|
||||
<FieldError match="valueMissing">API key is required.</FieldError>
|
||||
<FieldError match="customError" />
|
||||
</FieldRoot>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormDialog: Story = {
|
||||
render: () => <FormDialogDemo />,
|
||||
}
|
||||
|
||||
export const ScrollingContent: Story = {
|
||||
render: () => (
|
||||
<Dialog>
|
||||
|
||||
126
packages/dify-ui/src/field/__tests__/index.spec.tsx
Normal file
126
packages/dify-ui/src/field/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { Checkbox } from '../../checkbox'
|
||||
import { CheckboxGroup } from '../../checkbox-group'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
|
||||
import { Form } from '../../form'
|
||||
import {
|
||||
FieldControl,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldItem,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
describe('Field primitives', () => {
|
||||
it('should associate label, description, and error with the control', async () => {
|
||||
const onFormSubmit = vi.fn()
|
||||
const screen = await render(
|
||||
<Form aria-label="profile form" onFormSubmit={onFormSubmit}>
|
||||
<FieldRoot name="email">
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<FieldControl type="email" required />
|
||||
<FieldDescription>Used for account notifications.</FieldDescription>
|
||||
<FieldError match="valueMissing">Email is required.</FieldError>
|
||||
</FieldRoot>
|
||||
<button type="submit">Save</button>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Email' })
|
||||
const label = asHTMLElement(screen.getByText('Email').element())
|
||||
const description = asHTMLElement(screen.getByText('Used for account notifications.').element())
|
||||
|
||||
await expect.element(input).toHaveAccessibleDescription('Used for account notifications.')
|
||||
expect(label.tagName).toBe('LABEL')
|
||||
expect(label).toHaveAttribute('for', asHTMLElement(input.element()).id)
|
||||
expect(asHTMLElement(input.element()).getAttribute('aria-describedby')?.split(' ')).toContain(description.id)
|
||||
await expect.element(input).toHaveClass('rounded-lg', 'system-sm-regular')
|
||||
await expect.element(screen.getByText('Email')).toHaveClass('py-1', 'system-sm-medium')
|
||||
await expect.element(screen.getByText('Used for account notifications.')).toHaveClass('py-0.5', 'body-xs-regular')
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const error = asHTMLElement(screen.getByText('Email is required.').element())
|
||||
await expect.element(screen.getByText('Email is required.')).toBeInTheDocument()
|
||||
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
|
||||
await expect.element(input).toHaveClass('data-invalid:border-components-input-border-destructive')
|
||||
expect(asHTMLElement(input.element()).getAttribute('aria-describedby')?.split(' ')).toEqual(
|
||||
expect.arrayContaining([description.id, error.id]),
|
||||
)
|
||||
})
|
||||
expect(onFormSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should submit valid field values through Base UI Form', async () => {
|
||||
const onFormSubmit = vi.fn()
|
||||
const screen = await render(
|
||||
<Form aria-label="settings form" onFormSubmit={onFormSubmit}>
|
||||
<FieldRoot name="apiKey">
|
||||
<FieldLabel>API key</FieldLabel>
|
||||
<FieldControl defaultValue="sk-test" required />
|
||||
</FieldRoot>
|
||||
<button type="submit">Save</button>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
|
||||
|
||||
expect(onFormSubmit).toHaveBeenCalledTimes(1)
|
||||
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({ apiKey: 'sk-test' })
|
||||
})
|
||||
|
||||
it('should support external invalid state without requiring FieldControl', async () => {
|
||||
const screen = await render(
|
||||
<FieldRoot name="features" invalid>
|
||||
<FieldsetRoot render={<CheckboxGroup value={['search']} />}>
|
||||
<FieldsetLegend>Features</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="search" />
|
||||
Search
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldError match>Choose at least one feature.</FieldError>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'Features' })).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('checkbox', { name: 'Search' })).toHaveAttribute('aria-checked', 'true')
|
||||
await expect.element(screen.getByText('Choose at least one feature.')).toHaveClass('text-text-destructive', 'body-xs-regular')
|
||||
})
|
||||
|
||||
it('should apply design-system control sizes when requested', async () => {
|
||||
const screen = await render(
|
||||
<>
|
||||
<FieldRoot name="name">
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldControl size="large" />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="alias">
|
||||
<FieldLabel>Alias</FieldLabel>
|
||||
<FieldControl size="small" />
|
||||
</FieldRoot>
|
||||
</>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Name' })).toHaveClass('rounded-[10px]', 'py-[7px]', 'system-md-regular')
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Alias' })).toHaveClass('rounded-md', 'py-[3px]', 'system-xs-regular')
|
||||
})
|
||||
|
||||
it('should expose the design-system read-only state', async () => {
|
||||
const screen = await render(
|
||||
<FieldRoot name="token">
|
||||
<FieldLabel>Token</FieldLabel>
|
||||
<FieldControl readOnly defaultValue="readonly-token" />
|
||||
</FieldRoot>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveAttribute('readonly')
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveClass('read-only:cursor-default', 'read-only:focus:border-transparent')
|
||||
})
|
||||
})
|
||||
111
packages/dify-ui/src/field/index.stories.tsx
Normal file
111
packages/dify-ui/src/field/index.stories.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Button } from '../button'
|
||||
import {
|
||||
FieldControl,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from './index'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Field',
|
||||
component: FieldRoot,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Field primitives built on Base UI Field. Use FieldRoot with FieldLabel, FieldControl, FieldDescription, and FieldError for one named form field. External form libraries can control invalid, dirty, and touched on FieldRoot.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof FieldRoot>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const TextField: Story = {
|
||||
render: () => (
|
||||
<form className="grid w-96 gap-4">
|
||||
<FieldRoot name="endpoint">
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<FieldControl type="url" required placeholder="https://api.example.com" />
|
||||
<FieldDescription>Used as the base URL for extension requests.</FieldDescription>
|
||||
<FieldError match="valueMissing">Endpoint is required.</FieldError>
|
||||
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
|
||||
</FieldRoot>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="primary">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
),
|
||||
}
|
||||
|
||||
export const MultipleFields: Story = {
|
||||
render: () => (
|
||||
<form className="grid w-96 gap-4">
|
||||
<FieldRoot name="name">
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldControl required placeholder="Production API" />
|
||||
<FieldError match="valueMissing">Name is required.</FieldError>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="endpoint">
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<FieldControl type="url" required placeholder="https://api.example.com" />
|
||||
<FieldDescription>Used as the base URL for extension requests.</FieldDescription>
|
||||
<FieldError match="valueMissing">Endpoint is required.</FieldError>
|
||||
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="apiKey">
|
||||
<FieldLabel>API key</FieldLabel>
|
||||
<FieldControl required placeholder="sk-..." />
|
||||
<FieldDescription>Stored with the extension configuration.</FieldDescription>
|
||||
<FieldError match="valueMissing">API key is required.</FieldError>
|
||||
</FieldRoot>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="primary">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
),
|
||||
}
|
||||
|
||||
export const ExternalInvalidState: Story = {
|
||||
render: () => (
|
||||
<FieldRoot name="apiKey" invalid className="w-96">
|
||||
<FieldLabel>API key</FieldLabel>
|
||||
<FieldControl defaultValue="expired-key" />
|
||||
<FieldError match>API key has expired.</FieldError>
|
||||
</FieldRoot>
|
||||
),
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-96 gap-4">
|
||||
<FieldRoot name="smallEndpoint">
|
||||
<FieldLabel>Small</FieldLabel>
|
||||
<FieldControl size="small" placeholder="Small input" />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="regularEndpoint">
|
||||
<FieldLabel>Regular</FieldLabel>
|
||||
<FieldControl placeholder="Regular input" />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="largeEndpoint">
|
||||
<FieldLabel>Large</FieldLabel>
|
||||
<FieldControl size="large" placeholder="Large input" />
|
||||
</FieldRoot>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
render: () => (
|
||||
<FieldRoot name="readonlyEndpoint" className="w-96">
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<FieldControl readOnly defaultValue="https://api.example.com" />
|
||||
<FieldDescription>This value is managed by the workspace owner.</FieldDescription>
|
||||
</FieldRoot>
|
||||
),
|
||||
}
|
||||
154
packages/dify-ui/src/field/index.tsx
Normal file
154
packages/dify-ui/src/field/index.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import type { Field as BaseFieldNS } from '@base-ui/react/field'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { Field as BaseField } from '@base-ui/react/field'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export type FieldRootProps
|
||||
= Omit<BaseFieldNS.Root.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export type FieldRootActions = BaseFieldNS.Root.Actions
|
||||
|
||||
export function FieldRoot({
|
||||
className,
|
||||
...props
|
||||
}: FieldRootProps) {
|
||||
return (
|
||||
<BaseField.Root
|
||||
className={cn('group/field grid min-w-0 gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldItemProps
|
||||
= Omit<BaseFieldNS.Item.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FieldItem({
|
||||
className,
|
||||
...props
|
||||
}: FieldItemProps) {
|
||||
return (
|
||||
<BaseField.Item
|
||||
className={cn('grid min-w-0 gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldLabelProps
|
||||
= Omit<BaseFieldNS.Label.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: FieldLabelProps) {
|
||||
return (
|
||||
<BaseField.Label
|
||||
className={cn('w-fit py-1 text-text-secondary system-sm-medium data-disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldControlVariants = cva(
|
||||
[
|
||||
'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
|
||||
'placeholder:text-components-input-text-placeholder',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
|
||||
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
|
||||
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
|
||||
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
|
||||
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'rounded-md px-2 py-[3px] system-xs-regular',
|
||||
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
|
||||
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type FieldControlSize = NonNullable<VariantProps<typeof fieldControlVariants>['size']>
|
||||
|
||||
export type FieldControlProps
|
||||
= Omit<BaseFieldNS.Control.Props, 'className' | 'size'>
|
||||
& VariantProps<typeof fieldControlVariants>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export type FieldControlChangeEventDetails = BaseFieldNS.Control.ChangeEventDetails
|
||||
|
||||
export function FieldControl({
|
||||
className,
|
||||
size = 'medium',
|
||||
...props
|
||||
}: FieldControlProps) {
|
||||
return (
|
||||
<BaseField.Control
|
||||
className={cn(fieldControlVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldDescriptionProps
|
||||
= Omit<BaseFieldNS.Description.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FieldDescription({
|
||||
className,
|
||||
...props
|
||||
}: FieldDescriptionProps) {
|
||||
return (
|
||||
<BaseField.Description
|
||||
className={cn('py-0.5 text-text-tertiary body-xs-regular', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldErrorProps
|
||||
= Omit<BaseFieldNS.Error.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FieldError({
|
||||
className,
|
||||
...props
|
||||
}: FieldErrorProps) {
|
||||
return (
|
||||
<BaseField.Error
|
||||
className={cn('py-0.5 text-text-destructive body-xs-regular', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldValidityProps = BaseFieldNS.Validity.Props
|
||||
export type FieldValidityState = BaseFieldNS.Validity.State
|
||||
|
||||
export const FieldValidity = BaseField.Validity
|
||||
21
packages/dify-ui/src/fieldset/__tests__/index.spec.tsx
Normal file
21
packages/dify-ui/src/fieldset/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
FieldsetLegend,
|
||||
FieldsetRoot,
|
||||
} from '../index'
|
||||
|
||||
describe('Fieldset primitives', () => {
|
||||
it('should apply reset design-system classes', async () => {
|
||||
const screen = await render(
|
||||
<FieldsetRoot className="custom-root">
|
||||
<FieldsetLegend className="custom-legend">Permissions</FieldsetLegend>
|
||||
</FieldsetRoot>,
|
||||
)
|
||||
|
||||
const legend = screen.getByText('Permissions').element() as HTMLElement
|
||||
const fieldset = legend.closest('fieldset') as HTMLElement
|
||||
|
||||
await expect.element(fieldset).toHaveClass('m-0', 'min-w-0', 'border-0', 'p-0', 'custom-root')
|
||||
await expect.element(legend).toHaveClass('mb-1', 'py-1', 'system-sm-medium', 'text-text-secondary', 'custom-legend')
|
||||
})
|
||||
})
|
||||
56
packages/dify-ui/src/fieldset/index.stories.tsx
Normal file
56
packages/dify-ui/src/fieldset/index.stories.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Checkbox } from '../checkbox'
|
||||
import { CheckboxGroup } from '../checkbox-group'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '../field'
|
||||
import {
|
||||
FieldsetLegend,
|
||||
FieldsetRoot,
|
||||
} from './index'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Fieldset',
|
||||
component: FieldsetRoot,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Fieldset primitives built on Base UI Fieldset. Use FieldsetRoot and FieldsetLegend when one field is represented by a group of related controls such as checkbox groups, radio groups, or multi-thumb sliders. Fieldset provides group semantics and labeling; pass interactive state such as disabled and value to the actual group primitive.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof FieldsetRoot>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const CheckboxGroupField: Story = {
|
||||
render: () => (
|
||||
<FieldRoot name="scopes" className="w-80">
|
||||
<FieldsetRoot render={<CheckboxGroup defaultValue={['read']} />}>
|
||||
<FieldsetLegend>Scopes</FieldsetLegend>
|
||||
<div className="grid gap-2">
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="read" />
|
||||
Read
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="write" />
|
||||
Write
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="admin" />
|
||||
Admin
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
),
|
||||
}
|
||||
41
packages/dify-ui/src/fieldset/index.tsx
Normal file
41
packages/dify-ui/src/fieldset/index.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import type { Fieldset as BaseFieldsetNS } from '@base-ui/react/fieldset'
|
||||
import { Fieldset as BaseFieldset } from '@base-ui/react/fieldset'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export type FieldsetRootProps
|
||||
= Omit<BaseFieldsetNS.Root.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FieldsetRoot({
|
||||
className,
|
||||
...props
|
||||
}: FieldsetRootProps) {
|
||||
return (
|
||||
<BaseFieldset.Root
|
||||
className={cn('m-0 min-w-0 border-0 p-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FieldsetLegendProps
|
||||
= Omit<BaseFieldsetNS.Legend.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FieldsetLegend({
|
||||
className,
|
||||
...props
|
||||
}: FieldsetLegendProps) {
|
||||
return (
|
||||
<BaseFieldset.Legend
|
||||
className={cn('mb-1 py-1 text-text-secondary system-sm-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
53
packages/dify-ui/src/form/__tests__/index.spec.tsx
Normal file
53
packages/dify-ui/src/form/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { FieldControl, FieldLabel, FieldRoot } from '../../field'
|
||||
import { Form } from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
describe('Form primitive', () => {
|
||||
it('should render a native named form and merge custom class names', async () => {
|
||||
const screen = await render(
|
||||
<Form aria-label="profile form" className="custom-form">
|
||||
<FieldRoot name="name">
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldControl defaultValue="Ada" />
|
||||
</FieldRoot>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('form', { name: 'profile form' })).toHaveClass('custom-form')
|
||||
})
|
||||
|
||||
it('should call onFormSubmit with submitted values', async () => {
|
||||
const onFormSubmit = vi.fn()
|
||||
const screen = await render(
|
||||
<Form aria-label="api form" onFormSubmit={onFormSubmit}>
|
||||
<FieldRoot name="endpoint">
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<FieldControl defaultValue="https://api.example.com" />
|
||||
</FieldRoot>
|
||||
<button type="submit">Save</button>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
|
||||
|
||||
expect(onFormSubmit).toHaveBeenCalledTimes(1)
|
||||
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({
|
||||
endpoint: 'https://api.example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose externally supplied errors through FieldError consumers', async () => {
|
||||
const screen = await render(
|
||||
<Form aria-label="server form" errors={{ token: 'Token has expired.' }}>
|
||||
<FieldRoot name="token">
|
||||
<FieldLabel>Token</FieldLabel>
|
||||
<FieldControl defaultValue="expired" />
|
||||
</FieldRoot>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
})
|
||||
70
packages/dify-ui/src/form/index.stories.tsx
Normal file
70
packages/dify-ui/src/form/index.stories.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Button } from '../button'
|
||||
import { Checkbox } from '../checkbox'
|
||||
import { CheckboxGroup } from '../checkbox-group'
|
||||
import {
|
||||
FieldControl,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldItem,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
|
||||
import { Form } from './index'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Form',
|
||||
component: Form,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies Meta<typeof Form>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<Form className="grid w-96 gap-4" onFormSubmit={() => undefined}>
|
||||
<FieldRoot name="name">
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldControl required placeholder="Enter a name" />
|
||||
<FieldError match="valueMissing">Name is required.</FieldError>
|
||||
</FieldRoot>
|
||||
|
||||
<FieldRoot name="email">
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<FieldControl type="email" required placeholder="name@example.com" />
|
||||
<FieldDescription>Used for account notifications.</FieldDescription>
|
||||
<FieldError match="valueMissing">Email is required.</FieldError>
|
||||
<FieldError match="typeMismatch">Enter a valid email address.</FieldError>
|
||||
</FieldRoot>
|
||||
|
||||
<FieldRoot name="features">
|
||||
<FieldsetRoot render={<CheckboxGroup defaultValue={['search']} />}>
|
||||
<FieldsetLegend>Features</FieldsetLegend>
|
||||
<div className="grid gap-2">
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="search" />
|
||||
Search
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2">
|
||||
<Checkbox value="analytics" />
|
||||
Analytics
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="primary">Save</Button>
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
}
|
||||
11
packages/dify-ui/src/form/index.tsx
Normal file
11
packages/dify-ui/src/form/index.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { Form as BaseFormNS } from '@base-ui/react/form'
|
||||
import { Form as BaseForm } from '@base-ui/react/form'
|
||||
|
||||
export const Form = BaseForm
|
||||
|
||||
export type FormProps = BaseFormNS.Props
|
||||
export type FormActions = BaseFormNS.Actions
|
||||
export type FormValidationMode = BaseFormNS.ValidationMode
|
||||
export type FormSubmitEventDetails = BaseFormNS.SubmitEventDetails
|
||||
@ -108,7 +108,7 @@ const DemoField = ({
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/NumberField',
|
||||
title: 'Base/Form/NumberField',
|
||||
component: NumberField,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
const triggerWidth = 'w-64'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Select',
|
||||
title: 'Base/Form/Select',
|
||||
component: Select,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@ -4,7 +4,7 @@ import { useState } from 'react'
|
||||
import { Slider } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Slider',
|
||||
title: 'Base/Form/Slider',
|
||||
component: Slider,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@ -4,7 +4,7 @@ import { useState, useTransition } from 'react'
|
||||
import { Switch, SwitchSkeleton } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Switch',
|
||||
title: 'Base/Form/Switch',
|
||||
component: Switch,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@ -9,6 +9,9 @@ export default defineConfig({
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@base-ui/react/form'],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
|
||||
@ -84,7 +84,7 @@ vi.mock('@/app/components/base/features/new-feature-panel/moderation/form-genera
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
|
||||
default: ({
|
||||
ApiBasedExtensionSelector: ({
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
|
||||
@ -13,7 +13,7 @@ import AppIcon from '@/app/components/base/app-icon'
|
||||
import EmojiPicker from '@/app/components/base/emoji-picker'
|
||||
import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation'
|
||||
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
|
||||
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
import { useDocLink, useLocale } from '@/context/i18n'
|
||||
import { useCodeBasedExtensions } from '@/service/use-common'
|
||||
import {
|
||||
|
||||
@ -21,6 +21,7 @@ describe('checkbox list component', () => {
|
||||
)
|
||||
expect(screen.getByText('Test Title'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Test Description'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('group', { name: 'Test Title' }))!.toHaveAccessibleDescription('Test Description')
|
||||
options.forEach((option) => {
|
||||
expect(screen.getByText(option.label))!.toBeInTheDocument()
|
||||
})
|
||||
@ -231,6 +232,7 @@ describe('checkbox list component', () => {
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Test Label'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('group', { name: 'Test Label' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without showSelectAll, showCount, showSearch', () => {
|
||||
|
||||
@ -3,7 +3,9 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { FieldDescription, FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import SearchInput from '@/app/components/base/search-input'
|
||||
@ -16,6 +18,7 @@ type CheckboxListOption = {
|
||||
}
|
||||
|
||||
type CheckboxListProps = {
|
||||
name?: string
|
||||
title?: string
|
||||
label?: string
|
||||
description?: string
|
||||
@ -31,6 +34,7 @@ type CheckboxListProps = {
|
||||
}
|
||||
|
||||
export const CheckboxList = ({
|
||||
name,
|
||||
title = '',
|
||||
label,
|
||||
description,
|
||||
@ -45,7 +49,6 @@ export const CheckboxList = ({
|
||||
maxHeight,
|
||||
}: CheckboxListProps) => {
|
||||
const { t } = useTranslation()
|
||||
const groupLabelId = useId()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
@ -66,116 +69,129 @@ export const CheckboxList = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full flex-col gap-1', containerClassName)}>
|
||||
{label && (
|
||||
<div id={groupLabelId} className="system-sm-medium text-text-secondary">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="body-xs-regular text-text-tertiary">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CheckboxGroup
|
||||
aria-labelledby={label ? groupLabelId : undefined}
|
||||
value={value}
|
||||
onValueChange={nextValue => onChange?.(nextValue)}
|
||||
allValues={selectableOptionValues}
|
||||
disabled={disabled}
|
||||
className="rounded-lg border border-components-panel-border bg-components-panel-bg"
|
||||
<FieldRoot name={name} className={cn('flex w-full flex-col gap-1', containerClassName)}>
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<CheckboxGroup
|
||||
aria-label={!label && title ? title : undefined}
|
||||
value={value}
|
||||
onValueChange={nextValue => onChange?.(nextValue)}
|
||||
allValues={selectableOptionValues}
|
||||
disabled={disabled}
|
||||
className="flex flex-col gap-1"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{(showSelectAll || title || showSearch) && (
|
||||
<div className="relative flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
|
||||
{!searchQuery && showSelectAll && (
|
||||
<label className={cn('flex shrink-0 items-center', !disabled && 'cursor-pointer')}>
|
||||
<Checkbox
|
||||
parent
|
||||
disabled={disabled}
|
||||
{label && (
|
||||
<FieldsetLegend className="mb-0">
|
||||
{label}
|
||||
</FieldsetLegend>
|
||||
)}
|
||||
{description && (
|
||||
<FieldDescription className="body-xs-regular text-text-tertiary">
|
||||
{description}
|
||||
</FieldDescription>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-components-panel-border bg-components-panel-bg">
|
||||
{(showSelectAll || title || showSearch) && (
|
||||
<div className="relative flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
|
||||
{!searchQuery && showSelectAll && (
|
||||
<FieldItem disabled={disabled} className="shrink-0 gap-0">
|
||||
<FieldLabel className={cn('flex items-center p-0', !disabled && 'cursor-pointer')}>
|
||||
<Checkbox
|
||||
parent
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="sr-only">{t('operation.selectAll', { ns: 'common' })}</span>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
)}
|
||||
{!searchQuery
|
||||
? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
{title && (
|
||||
<span className="truncate system-xs-semibold-uppercase leading-5 text-text-secondary">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
{showCount && selectedCount > 0 && (
|
||||
<Badge uppercase>
|
||||
{t('operation.selectCount', { ns: 'common', count: selectedCount })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex-1 system-sm-medium-uppercase leading-6 text-text-secondary">
|
||||
{
|
||||
filteredOptions.length > 0
|
||||
? t('operation.searchCount', { ns: 'common', count: filteredOptions.length, content: title })
|
||||
: t('operation.noSearchCount', { ns: 'common', content: title })
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{showSearch && (
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder={t('placeholder.search', { ns: 'common' })}
|
||||
className="w-40"
|
||||
/>
|
||||
<span className="sr-only">{t('operation.selectAll', { ns: 'common' })}</span>
|
||||
</label>
|
||||
)}
|
||||
{!searchQuery
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="p-1"
|
||||
style={maxHeight ? { maxHeight, overflowY: 'auto' } : {}}
|
||||
data-testid="options-container"
|
||||
>
|
||||
{!filteredOptions.length
|
||||
? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
{title && (
|
||||
<span className="truncate system-xs-semibold-uppercase leading-5 text-text-secondary">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
{showCount && selectedCount > 0 && (
|
||||
<Badge uppercase>
|
||||
{t('operation.selectCount', { ns: 'common', count: selectedCount })}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="px-3 py-6 text-center text-sm text-text-tertiary">
|
||||
{searchQuery
|
||||
? (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<img alt="search menu" src={SearchMenu.src} width={32} />
|
||||
<span className="system-sm-regular text-text-secondary">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
|
||||
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
)
|
||||
: t('noData', { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex-1 system-sm-medium-uppercase leading-6 text-text-secondary">
|
||||
{
|
||||
filteredOptions.length > 0
|
||||
? t('operation.searchCount', { ns: 'common', count: filteredOptions.length, content: title })
|
||||
: t('operation.noSearchCount', { ns: 'common', content: title })
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{showSearch && (
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder={t('placeholder.search', { ns: 'common' })}
|
||||
className="w-40"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="p-1"
|
||||
style={maxHeight ? { maxHeight, overflowY: 'auto' } : {}}
|
||||
data-testid="options-container"
|
||||
>
|
||||
{!filteredOptions.length
|
||||
? (
|
||||
<div className="px-3 py-6 text-center text-sm text-text-tertiary">
|
||||
{searchQuery
|
||||
? (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<img alt="search menu" src={SearchMenu.src} width={32} />
|
||||
<span className="system-sm-regular text-text-secondary">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
|
||||
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
)
|
||||
: t('noData', { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
filteredOptions.map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
data-testid="option-item"
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
|
||||
(option.disabled || disabled) && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
value={option.value}
|
||||
filteredOptions.map(option => (
|
||||
<FieldItem
|
||||
key={option.value}
|
||||
disabled={option.disabled || disabled}
|
||||
/>
|
||||
<span
|
||||
className="flex-1 truncate system-sm-medium text-text-secondary"
|
||||
title={option.label}
|
||||
className="gap-0"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
<FieldLabel
|
||||
data-testid="option-item"
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
|
||||
(option.disabled || disabled) && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
value={option.value}
|
||||
disabled={option.disabled || disabled}
|
||||
/>
|
||||
<span
|
||||
className="flex-1 truncate system-sm-medium text-text-secondary"
|
||||
title={option.label}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CheckboxGroup>
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@ vi.mock('@/app/components/header/account-setting/constants', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
|
||||
default: ({ onChange }: { value: string, onChange: (v: string) => void }) => (
|
||||
ApiBasedExtensionSelector: ({ onChange }: { value: string, onChange: (v: string) => void }) => (
|
||||
<div data-testid="api-selector">
|
||||
<button data-testid="select-api" onClick={() => onChange('api-ext-1')}>Select API</button>
|
||||
</div>
|
||||
|
||||
@ -8,7 +8,7 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useDocLink, useLocale } from '@/context/i18n'
|
||||
|
||||
@ -305,6 +305,7 @@ const BaseField = ({
|
||||
{
|
||||
formItemType === FormTypeEnum.checkbox /* && multiple */ && (
|
||||
<CheckboxList
|
||||
name={field.name}
|
||||
title={name}
|
||||
value={value}
|
||||
onChange={v => field.handleChange(v)}
|
||||
|
||||
@ -66,11 +66,33 @@ vi.mock('@/service/use-datasource', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useApiBasedExtensions: vi.fn(() => ({ data: [], isPending: false })),
|
||||
useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })),
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/client')>()
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: new Proxy(actual.consoleQuery, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'apiBasedExtension') {
|
||||
return {
|
||||
get: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['console', 'api-based-extension'],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/billing/billing-page', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="billing-page" />,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Empty from '../empty'
|
||||
import { Empty } from '../empty'
|
||||
|
||||
describe('Empty State', () => {
|
||||
describe('Rendering', () => {
|
||||
|
||||
@ -1,33 +1,66 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import ApiBasedExtensionPage from '../index'
|
||||
import { ApiBasedExtensionPage } from '../index'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useApiBasedExtensions: vi.fn(),
|
||||
const {
|
||||
mockApiBasedExtensionsQuery,
|
||||
mockCreateApiBasedExtension,
|
||||
mockUpdateApiBasedExtension,
|
||||
mockDeleteApiBasedExtension,
|
||||
} = vi.hoisted(() => ({
|
||||
mockApiBasedExtensionsQuery: vi.fn(),
|
||||
mockCreateApiBasedExtension: vi.fn(),
|
||||
mockUpdateApiBasedExtension: vi.fn(),
|
||||
mockDeleteApiBasedExtension: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
addApiBasedExtension: vi.fn(),
|
||||
updateApiBasedExtension: vi.fn(),
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
apiBasedExtension: {
|
||||
get: {
|
||||
queryOptions: () => ({}),
|
||||
},
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mockCreateApiBasedExtension }),
|
||||
},
|
||||
byId: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mockUpdateApiBasedExtension }),
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: () => ({ mutationFn: mockDeleteApiBasedExtension }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: vi.fn(() => mockApiBasedExtensionsQuery()),
|
||||
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
|
||||
isPending: false,
|
||||
mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => {
|
||||
options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data))
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('ApiBasedExtensionPage', () => {
|
||||
const mockRefetch = vi.fn<() => void>()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: [],
|
||||
isPending: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render empty state when no data exists', () => {
|
||||
// Arrange
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: [],
|
||||
isPending: false,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ApiBasedExtensionPage />)
|
||||
@ -44,11 +77,10 @@ describe('ApiBasedExtensionPage', () => {
|
||||
{ id: '2', name: 'Extension 2', api_endpoint: 'url2', api_key: 'key2' },
|
||||
]
|
||||
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: mockData,
|
||||
isPending: false,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ApiBasedExtensionPage />)
|
||||
@ -63,11 +95,10 @@ describe('ApiBasedExtensionPage', () => {
|
||||
|
||||
it('should handle loading state', () => {
|
||||
// Arrange
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
data: null,
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isPending: true,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ApiBasedExtensionPage />)
|
||||
@ -112,11 +143,10 @@ describe('ApiBasedExtensionPage', () => {
|
||||
describe('User Interactions', () => {
|
||||
it('should open modal when clicking add button', () => {
|
||||
// Arrange
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: [],
|
||||
isPending: false,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ApiBasedExtensionPage />)
|
||||
@ -126,19 +156,18 @@ describe('ApiBasedExtensionPage', () => {
|
||||
expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call refetch when add modal saves successfully', async () => {
|
||||
it('should close add modal when create mutation succeeds', async () => {
|
||||
// Arrange
|
||||
vi.mocked(addApiBasedExtension).mockResolvedValue({
|
||||
mockCreateApiBasedExtension.mockResolvedValue({
|
||||
id: 'new-id',
|
||||
name: 'New Ext',
|
||||
api_endpoint: 'https://api.test',
|
||||
api_key: 'secret-key',
|
||||
})
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: [],
|
||||
isPending: false,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ApiBasedExtensionPage />)
|
||||
@ -150,19 +179,25 @@ describe('ApiBasedExtensionPage', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(mockCreateApiBasedExtension).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'New Ext',
|
||||
api_endpoint: 'https://api.test',
|
||||
api_key: 'secret-key',
|
||||
},
|
||||
})
|
||||
expect(screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call refetch when an item is updated', async () => {
|
||||
it('should close edit modal when update mutation succeeds', async () => {
|
||||
// Arrange
|
||||
const extension: ApiBasedExtensionResponse = { id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'long-api-key' }
|
||||
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...extension, name: 'Updated' })
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
mockUpdateApiBasedExtension.mockResolvedValue({ ...extension, name: 'Updated' })
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: [extension],
|
||||
isPending: false,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
})
|
||||
|
||||
render(<ApiBasedExtensionPage />)
|
||||
|
||||
@ -172,7 +207,17 @@ describe('ApiBasedExtensionPage', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(mockUpdateApiBasedExtension).toHaveBeenCalledWith({
|
||||
params: {
|
||||
id: '1',
|
||||
},
|
||||
body: {
|
||||
name: 'Extension 1',
|
||||
api_endpoint: 'url1',
|
||||
api_key: '[__HIDDEN__]',
|
||||
},
|
||||
})
|
||||
expect(screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.editTitle' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,11 +2,31 @@ import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-
|
||||
import type { TFunction } from 'i18next'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import * as reactI18next from 'react-i18next'
|
||||
import { deleteApiBasedExtension } from '@/service/common'
|
||||
import Item from '../item'
|
||||
import { Item } from '../item'
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
deleteApiBasedExtension: vi.fn(),
|
||||
const { mockDeleteApiBasedExtension } = vi.hoisted(() => ({
|
||||
mockDeleteApiBasedExtension: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
apiBasedExtension: {
|
||||
byId: {
|
||||
delete: {
|
||||
mutationOptions: () => ({ mutationFn: mockDeleteApiBasedExtension }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
|
||||
isPending: false,
|
||||
mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => {
|
||||
options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data))
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('Item Component', () => {
|
||||
@ -16,7 +36,6 @@ describe('Item Component', () => {
|
||||
api_endpoint: 'https://api.example.com',
|
||||
api_key: 'test-api-key',
|
||||
}
|
||||
const mockOnUpdate = vi.fn()
|
||||
const mockOnEdit = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
@ -26,7 +45,7 @@ describe('Item Component', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render extension data correctly', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
@ -44,7 +63,7 @@ describe('Item Component', () => {
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<Item data={minimalData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={minimalData} onEdit={mockOnEdit} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
@ -56,7 +75,7 @@ describe('Item Component', () => {
|
||||
describe('Modal Interactions', () => {
|
||||
it('should request editing with the current extension when clicking edit button', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
fireEvent.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
// Assert
|
||||
@ -67,7 +86,7 @@ describe('Item Component', () => {
|
||||
describe('Deletion', () => {
|
||||
it('should show delete confirmation dialog when clicking delete button', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
// Assert
|
||||
@ -75,10 +94,10 @@ describe('Item Component', () => {
|
||||
expect(screen.getByText(/common\.operation\.delete.*Test Extension.*\?/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call delete API and triggers onUpdate when confirming deletion', async () => {
|
||||
it('should call delete mutation when confirming deletion', async () => {
|
||||
// Arrange
|
||||
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
mockDeleteApiBasedExtension.mockResolvedValue({})
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
@ -90,15 +109,18 @@ describe('Item Component', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(deleteApiBasedExtension).toHaveBeenCalledWith('/api-based-extension/1')
|
||||
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(mockDeleteApiBasedExtension).toHaveBeenCalledWith({
|
||||
params: {
|
||||
id: '1',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide delete confirmation dialog after successful deletion', async () => {
|
||||
// Arrange
|
||||
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
mockDeleteApiBasedExtension.mockResolvedValue({})
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
@ -116,7 +138,7 @@ describe('Item Component', () => {
|
||||
|
||||
it('should close delete confirmation when clicking cancel button', async () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
@ -128,13 +150,12 @@ describe('Item Component', () => {
|
||||
|
||||
it('should not call delete API when canceling deletion', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
// Assert
|
||||
expect(deleteApiBasedExtension).not.toHaveBeenCalled()
|
||||
expect(mockOnUpdate).not.toHaveBeenCalled()
|
||||
expect(mockDeleteApiBasedExtension).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -157,7 +178,7 @@ describe('Item Component', () => {
|
||||
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
|
||||
|
||||
// Act
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
render(<Item apiBasedExtension={mockData} onEdit={mockOnEdit} />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
const editBtn = screen.getByText('operation.edit')
|
||||
const deleteBtn = allButtons.find(btn => btn !== editBtn)
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { fireEvent, render as RTLRender, screen, waitFor } from '@testing-library/react'
|
||||
import * as reactI18next from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
|
||||
import ApiBasedExtensionModal from '../modal'
|
||||
import { ApiBasedExtensionModal } from '../modal'
|
||||
|
||||
const { mockToast } = vi.hoisted(() => {
|
||||
const { mockCreateApiBasedExtension, mockUpdateApiBasedExtension, mockToast } = vi.hoisted(() => {
|
||||
const mockCreateApiBasedExtension = vi.fn()
|
||||
const mockUpdateApiBasedExtension = vi.fn()
|
||||
const mockToast = Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@ -17,16 +18,35 @@ const { mockToast } = vi.hoisted(() => {
|
||||
update: vi.fn(),
|
||||
promise: vi.fn(),
|
||||
})
|
||||
return { mockToast }
|
||||
return { mockCreateApiBasedExtension, mockUpdateApiBasedExtension, mockToast }
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
addApiBasedExtension: vi.fn(),
|
||||
updateApiBasedExtension: vi.fn(),
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
apiBasedExtension: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mockCreateApiBasedExtension }),
|
||||
},
|
||||
byId: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mockUpdateApiBasedExtension }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
|
||||
isPending: false,
|
||||
mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => {
|
||||
options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data))
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
@ -35,7 +55,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
|
||||
describe('ApiBasedExtensionModal', () => {
|
||||
const mockOnOpenChange = vi.fn()
|
||||
const mockOnSave = vi.fn()
|
||||
const mockOnSaved = vi.fn()
|
||||
const mockDocLink = vi.fn((path?: string) => `https://docs.dify.ai${path || ''}`)
|
||||
const mockExtension = (overrides: Partial<ApiBasedExtensionResponse> = {}): ApiBasedExtensionResponse => ({
|
||||
id: '1',
|
||||
@ -46,15 +66,34 @@ describe('ApiBasedExtensionModal', () => {
|
||||
})
|
||||
|
||||
const render = (ui: ReactElement) => RTLRender(ui)
|
||||
const renderModal = (props: Partial<ComponentProps<typeof ApiBasedExtensionModal>> = {}) => render(
|
||||
<ApiBasedExtensionModal
|
||||
open
|
||||
extension={{}}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
const renderModal = (props: {
|
||||
open?: boolean
|
||||
} | {
|
||||
mode: 'edit'
|
||||
apiBasedExtension: ApiBasedExtensionResponse
|
||||
open?: boolean
|
||||
} = {}) => {
|
||||
if ('mode' in props) {
|
||||
return render(
|
||||
<ApiBasedExtensionModal
|
||||
open={props.open ?? true}
|
||||
mode="edit"
|
||||
apiBasedExtension={props.apiBasedExtension}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSaved={mockOnSaved}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
return render(
|
||||
<ApiBasedExtensionModal
|
||||
open={props.open ?? true}
|
||||
mode="create"
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSaved={mockOnSaved}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
const expectCloseRequested = () => {
|
||||
const calls = mockOnOpenChange.mock.calls
|
||||
expect(calls[calls.length - 1]?.[0]).toBe(false)
|
||||
@ -73,9 +112,9 @@ describe('ApiBasedExtensionModal', () => {
|
||||
// Assert
|
||||
expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument()
|
||||
expect(screen.getByText('common.apiBasedExtension.modal.title')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.name.title' })).toHaveAttribute('required')
|
||||
expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiEndpoint.title' })).toHaveAccessibleDescription('common.apiBasedExtension.link')
|
||||
expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiKey.title' })).toHaveAttribute('required')
|
||||
})
|
||||
|
||||
it('should render correctly for editing an existing extension', () => {
|
||||
@ -83,7 +122,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
const data = mockExtension()
|
||||
|
||||
// Act
|
||||
renderModal({ extension: data })
|
||||
renderModal({ mode: 'edit', apiBasedExtension: data })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.apiBasedExtension.modal.editTitle')).toBeInTheDocument()
|
||||
@ -102,7 +141,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
})
|
||||
|
||||
describe('Form Submissions', () => {
|
||||
it('should call addApiBasedExtension on save for new extension', async () => {
|
||||
it('should call create mutation on save for new extension', async () => {
|
||||
// Arrange
|
||||
const newExtension = mockExtension({
|
||||
id: 'new-id',
|
||||
@ -110,7 +149,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
api_endpoint: 'https://api.test',
|
||||
api_key: 'secret-key',
|
||||
})
|
||||
vi.mocked(addApiBasedExtension).mockResolvedValue(newExtension)
|
||||
mockCreateApiBasedExtension.mockResolvedValue(newExtension)
|
||||
renderModal()
|
||||
|
||||
// Act
|
||||
@ -121,23 +160,22 @@ describe('ApiBasedExtensionModal', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(addApiBasedExtension).toHaveBeenCalledWith({
|
||||
url: '/api-based-extension',
|
||||
expect(mockCreateApiBasedExtension).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'New Ext',
|
||||
api_endpoint: 'https://api.test',
|
||||
api_key: 'secret-key',
|
||||
},
|
||||
})
|
||||
expect(mockOnSave).toHaveBeenCalledWith(newExtension)
|
||||
expect(mockOnSaved).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateApiBasedExtension on save for existing extension', async () => {
|
||||
it('should call update mutation on save for existing extension', async () => {
|
||||
// Arrange
|
||||
const data = mockExtension({ api_key: 'long-secret-key' })
|
||||
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, name: 'Updated' })
|
||||
renderModal({ extension: data })
|
||||
mockUpdateApiBasedExtension.mockResolvedValue({ ...data, name: 'Updated' })
|
||||
renderModal({ mode: 'edit', apiBasedExtension: data })
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByDisplayValue('Existing'), { target: { value: 'Updated' } })
|
||||
@ -145,8 +183,10 @@ describe('ApiBasedExtensionModal', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(updateApiBasedExtension).toHaveBeenCalledWith({
|
||||
url: '/api-based-extension/1',
|
||||
expect(mockUpdateApiBasedExtension).toHaveBeenCalledWith({
|
||||
params: {
|
||||
id: '1',
|
||||
},
|
||||
body: {
|
||||
name: 'Updated',
|
||||
api_endpoint: 'url',
|
||||
@ -154,15 +194,15 @@ describe('ApiBasedExtensionModal', () => {
|
||||
},
|
||||
})
|
||||
expect(mockToast.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')
|
||||
expect(mockOnSave).toHaveBeenCalled()
|
||||
expect(mockOnSaved).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateApiBasedExtension with new api_key when key is changed', async () => {
|
||||
it('should call update mutation with new api_key when key is changed', async () => {
|
||||
// Arrange
|
||||
const data = mockExtension({ api_key: 'old-key' })
|
||||
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, api_key: 'new-longer-key' })
|
||||
renderModal({ extension: data })
|
||||
mockUpdateApiBasedExtension.mockResolvedValue({ ...data, api_key: 'new-longer-key' })
|
||||
renderModal({ mode: 'edit', apiBasedExtension: data })
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByDisplayValue('old-key'), { target: { value: 'new-longer-key' } })
|
||||
@ -170,8 +210,10 @@ describe('ApiBasedExtensionModal', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(updateApiBasedExtension).toHaveBeenCalledWith({
|
||||
url: '/api-based-extension/1',
|
||||
expect(mockUpdateApiBasedExtension).toHaveBeenCalledWith({
|
||||
params: {
|
||||
id: '1',
|
||||
},
|
||||
body: {
|
||||
name: 'Existing',
|
||||
api_endpoint: 'url',
|
||||
@ -194,29 +236,16 @@ describe('ApiBasedExtensionModal', () => {
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Assert
|
||||
expect(mockToast.error).toHaveBeenCalledWith('common.apiBasedExtension.modal.apiKey.lengthError')
|
||||
expect(addApiBasedExtension).not.toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('common.apiBasedExtension.modal.apiKey.lengthError')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'common.apiBasedExtension.modal.apiKey.title' })).toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
expect(mockToast.error).not.toHaveBeenCalled()
|
||||
expect(mockCreateApiBasedExtension).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should work when onSave is not provided', async () => {
|
||||
// Arrange
|
||||
vi.mocked(addApiBasedExtension).mockResolvedValue(mockExtension({ id: 'new-id' }))
|
||||
renderModal({ onSave: undefined })
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(addApiBasedExtension).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should request closing when clicking cancel button', () => {
|
||||
// Arrange
|
||||
renderModal()
|
||||
@ -294,7 +323,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
|
||||
|
||||
// Act
|
||||
const { container } = renderModal({ onSave: undefined })
|
||||
const { container } = renderModal()
|
||||
|
||||
// Assert
|
||||
const inputs = container.querySelectorAll('input')
|
||||
|
||||
@ -1,23 +1,50 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { addApiBasedExtension } from '@/service/common'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import ApiBasedExtensionSelector from '../selector'
|
||||
import { ApiBasedExtensionSelector } from '../selector'
|
||||
|
||||
const {
|
||||
mockApiBasedExtensionsQuery,
|
||||
mockCreateApiBasedExtension,
|
||||
mockUpdateApiBasedExtension,
|
||||
} = vi.hoisted(() => ({
|
||||
mockApiBasedExtensionsQuery: vi.fn(),
|
||||
mockCreateApiBasedExtension: vi.fn(),
|
||||
mockUpdateApiBasedExtension: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useApiBasedExtensions: vi.fn(),
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
apiBasedExtension: {
|
||||
get: {
|
||||
queryOptions: () => ({}),
|
||||
},
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mockCreateApiBasedExtension }),
|
||||
},
|
||||
byId: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mockUpdateApiBasedExtension }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
addApiBasedExtension: vi.fn(),
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: vi.fn(() => mockApiBasedExtensionsQuery()),
|
||||
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
|
||||
isPending: false,
|
||||
mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => {
|
||||
options.mutationFn(variables).then(data => mutationOptions?.onSuccess?.(data))
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||
@ -25,7 +52,6 @@ vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/bas
|
||||
describe('ApiBasedExtensionSelector', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
const mockData: ApiBasedExtensionResponse[] = [
|
||||
{ id: '1', name: 'Extension 1', api_endpoint: 'https://api1.test', api_key: 'key1' },
|
||||
@ -37,12 +63,11 @@ describe('ApiBasedExtensionSelector', () => {
|
||||
vi.mocked(useModalContext).mockReturnValue({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
} as unknown as ModalContextState)
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
mockApiBasedExtensionsQuery.mockReturnValue({
|
||||
data: mockData,
|
||||
refetch: mockRefetch,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
} as unknown as UseQueryResult<ApiBasedExtensionResponse[], Error>)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -105,9 +130,9 @@ describe('ApiBasedExtensionSelector', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should open add modal when clicking add button and refetches on save', async () => {
|
||||
it('should open add modal when clicking add button and close it after save', async () => {
|
||||
// Arrange
|
||||
vi.mocked(addApiBasedExtension).mockResolvedValue({
|
||||
mockCreateApiBasedExtension.mockResolvedValue({
|
||||
id: 'new-id',
|
||||
name: 'New Ext',
|
||||
api_endpoint: 'https://api.test',
|
||||
@ -127,7 +152,14 @@ describe('ApiBasedExtensionSelector', () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(mockCreateApiBasedExtension).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'New Ext',
|
||||
api_endpoint: 'https://api.test',
|
||||
api_key: 'secret-key',
|
||||
},
|
||||
})
|
||||
expect(screen.queryByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
const Empty = () => {
|
||||
export function Empty() {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
|
||||
@ -27,5 +27,3 @@ const Empty = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Empty
|
||||
|
||||
@ -1,36 +1,37 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import Empty from './empty'
|
||||
import Item from './item'
|
||||
import ApiBasedExtensionModal from './modal'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { Empty } from './empty'
|
||||
import { Item } from './item'
|
||||
import { ApiBasedExtensionModal } from './modal'
|
||||
|
||||
type ApiBasedExtensionDialogState = {
|
||||
extension: Partial<ApiBasedExtensionResponse>
|
||||
onSave: () => void
|
||||
mode: 'create'
|
||||
} | {
|
||||
mode: 'edit'
|
||||
apiBasedExtension: ApiBasedExtensionResponse
|
||||
} | null
|
||||
|
||||
const ApiBasedExtensionPage = () => {
|
||||
export function ApiBasedExtensionPage() {
|
||||
const { t } = useTranslation()
|
||||
const { data, refetch: mutate, isPending: isLoading } = useApiBasedExtensions()
|
||||
const { data: apiBasedExtensions = [], isPending: isLoading } = useQuery(consoleQuery.apiBasedExtension.get.queryOptions())
|
||||
const [dialogState, setDialogState] = useState<ApiBasedExtensionDialogState>(null)
|
||||
|
||||
const handleOpenApiBasedExtensionModal = () => {
|
||||
setDialogState({
|
||||
extension: {},
|
||||
onSave: () => mutate(),
|
||||
mode: 'create',
|
||||
})
|
||||
}
|
||||
const handleEditApiBasedExtension = (extension: ApiBasedExtensionResponse) => {
|
||||
const handleEditApiBasedExtension = (apiBasedExtension: ApiBasedExtensionResponse) => {
|
||||
setDialogState({
|
||||
extension,
|
||||
onSave: () => mutate(),
|
||||
mode: 'edit',
|
||||
apiBasedExtension,
|
||||
})
|
||||
}
|
||||
const handleSaveApiBasedExtension = () => {
|
||||
dialogState?.onSave()
|
||||
const handleApiBasedExtensionSaved = () => {
|
||||
setDialogState(null)
|
||||
}
|
||||
const handleApiBasedExtensionModalOpenChange = (open: boolean) => {
|
||||
@ -41,18 +42,17 @@ const ApiBasedExtensionPage = () => {
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
!isLoading && !data?.length && (
|
||||
!isLoading && !apiBasedExtensions.length && (
|
||||
<Empty />
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && !!data?.length && (
|
||||
data.map(item => (
|
||||
!isLoading && !!apiBasedExtensions.length && (
|
||||
apiBasedExtensions.map(item => (
|
||||
<Item
|
||||
key={item.id}
|
||||
data={item}
|
||||
apiBasedExtension={item}
|
||||
onEdit={handleEditApiBasedExtension}
|
||||
onUpdate={() => mutate()}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@ -66,17 +66,26 @@ const ApiBasedExtensionPage = () => {
|
||||
{t('apiBasedExtension.add', { ns: 'common' })}
|
||||
</Button>
|
||||
{
|
||||
dialogState && (
|
||||
dialogState?.mode === 'create' && (
|
||||
<ApiBasedExtensionModal
|
||||
open
|
||||
extension={dialogState.extension}
|
||||
mode="create"
|
||||
onOpenChange={handleApiBasedExtensionModalOpenChange}
|
||||
onSave={handleSaveApiBasedExtension}
|
||||
onSaved={handleApiBasedExtensionSaved}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
dialogState?.mode === 'edit' && (
|
||||
<ApiBasedExtensionModal
|
||||
open
|
||||
mode="edit"
|
||||
apiBasedExtension={dialogState.apiBasedExtension}
|
||||
onOpenChange={handleApiBasedExtensionModalOpenChange}
|
||||
onSaved={handleApiBasedExtensionSaved}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApiBasedExtensionPage
|
||||
|
||||
@ -8,38 +8,43 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { deleteApiBasedExtension } from '@/service/common'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type ItemProps = {
|
||||
data: ApiBasedExtensionResponse
|
||||
onEdit: (extension: ApiBasedExtensionResponse) => void
|
||||
onUpdate: () => void
|
||||
apiBasedExtension: ApiBasedExtensionResponse
|
||||
onEdit: (apiBasedExtension: ApiBasedExtensionResponse) => void
|
||||
}
|
||||
const Item = ({
|
||||
data,
|
||||
export function Item({
|
||||
apiBasedExtension,
|
||||
onEdit,
|
||||
onUpdate,
|
||||
}: ItemProps) => {
|
||||
}: ItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const deleteApiBasedExtensionMutation = useMutation(consoleQuery.apiBasedExtension.byId.delete.mutationOptions())
|
||||
|
||||
const handleOpenApiBasedExtensionModal = () => {
|
||||
onEdit(data)
|
||||
onEdit(apiBasedExtension)
|
||||
}
|
||||
const handleDeleteApiBasedExtension = async () => {
|
||||
await deleteApiBasedExtension(`/api-based-extension/${data.id}`)
|
||||
|
||||
setShowDeleteConfirm(false)
|
||||
onUpdate()
|
||||
const handleDeleteApiBasedExtension = () => {
|
||||
deleteApiBasedExtensionMutation.mutate({
|
||||
params: {
|
||||
id: apiBasedExtension.id,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setShowDeleteConfirm(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group mb-2 flex items-center rounded-xl border-[0.5px] border-transparent bg-components-input-bg-normal px-4 py-2 focus-within:border-components-input-border-active focus-within:shadow-xs hover:border-components-input-border-active hover:shadow-xs">
|
||||
<div className="min-w-0 grow">
|
||||
<div className="mb-0.5 text-[13px] font-medium text-text-secondary">{data.name}</div>
|
||||
<div className="truncate text-xs text-text-tertiary">{data.api_endpoint}</div>
|
||||
<div className="mb-0.5 text-[13px] font-medium text-text-secondary">{apiBasedExtension.name}</div>
|
||||
<div className="truncate text-xs text-text-tertiary">{apiBasedExtension.api_endpoint}</div>
|
||||
</div>
|
||||
<div className="pointer-events-none flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<Button
|
||||
@ -59,12 +64,15 @@ const Item = ({
|
||||
<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">
|
||||
{`${t('operation.delete', { ns: 'common' })} \u201C${data.name}\u201D?`}
|
||||
{`${t('operation.delete', { ns: 'common' })} \u201C${apiBasedExtension.name}\u201D?`}
|
||||
</AlertDialogTitle>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={handleDeleteApiBasedExtension}>
|
||||
<AlertDialogConfirmButton
|
||||
disabled={deleteApiBasedExtensionMutation.isPending}
|
||||
onClick={handleDeleteApiBasedExtension}
|
||||
>
|
||||
{t('operation.delete', { ns: 'common' }) || ''}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
@ -73,5 +81,3 @@ const Item = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Item
|
||||
|
||||
@ -4,64 +4,67 @@ import type {
|
||||
} from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { FieldControl, FieldDescription, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
|
||||
|
||||
type ApiBasedExtensionField = 'name' | 'api_endpoint' | 'api_key'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type ApiBasedExtensionModalProps = {
|
||||
open: boolean
|
||||
extension: Partial<ApiBasedExtensionResponse>
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave?: (newData: ApiBasedExtensionResponse) => void
|
||||
}
|
||||
const ApiBasedExtensionModal = ({ open, extension, onOpenChange, onSave }: ApiBasedExtensionModalProps) => {
|
||||
onSaved: () => void
|
||||
} & ({
|
||||
mode: 'create'
|
||||
} | {
|
||||
mode: 'edit'
|
||||
apiBasedExtension: ApiBasedExtensionResponse
|
||||
})
|
||||
|
||||
export function ApiBasedExtensionModal(props: ApiBasedExtensionModalProps) {
|
||||
const { open, mode, onOpenChange, onSaved } = props
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const [localData, setLocalData] = useState(extension)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const handleDataChange = (field: ApiBasedExtensionField, value: string) => {
|
||||
setLocalData({ ...localData, [field]: value })
|
||||
}
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
if (localData.api_key && localData.api_key.length < 5) {
|
||||
toast.error(t('apiBasedExtension.modal.apiKey.lengthError', { ns: 'common' }))
|
||||
setLoading(false)
|
||||
const createApiBasedExtensionMutation = useMutation(consoleQuery.apiBasedExtension.post.mutationOptions())
|
||||
const updateApiBasedExtensionMutation = useMutation(consoleQuery.apiBasedExtension.byId.post.mutationOptions())
|
||||
const editingApiBasedExtension = mode === 'edit' ? props.apiBasedExtension : null
|
||||
const isSaving = createApiBasedExtensionMutation.isPending || updateApiBasedExtensionMutation.isPending
|
||||
const nameLabel = t('apiBasedExtension.modal.name.title', { ns: 'common' })
|
||||
const apiEndpointLabel = t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })
|
||||
const apiKeyLabel = t('apiBasedExtension.modal.apiKey.title', { ns: 'common' })
|
||||
|
||||
const handleSubmit = (formValues: ApiBasedExtensionPayload) => {
|
||||
const body: ApiBasedExtensionPayload = {
|
||||
name: formValues.name,
|
||||
api_endpoint: formValues.api_endpoint,
|
||||
api_key: formValues.api_key,
|
||||
}
|
||||
|
||||
if (editingApiBasedExtension) {
|
||||
updateApiBasedExtensionMutation.mutate({
|
||||
params: {
|
||||
id: editingApiBasedExtension.id,
|
||||
},
|
||||
body: {
|
||||
...body,
|
||||
api_key: editingApiBasedExtension.api_key === body.api_key ? '[__HIDDEN__]' : body.api_key,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
onSaved()
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload: ApiBasedExtensionPayload = {
|
||||
name: localData.name || '',
|
||||
api_endpoint: localData.api_endpoint || '',
|
||||
api_key: localData.api_key || '',
|
||||
}
|
||||
let res = {} as ApiBasedExtensionResponse
|
||||
if (!extension.id) {
|
||||
res = await addApiBasedExtension({
|
||||
url: '/api-based-extension',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
else {
|
||||
res = await updateApiBasedExtension({
|
||||
url: `/api-based-extension/${extension.id}`,
|
||||
body: {
|
||||
...payload,
|
||||
api_key: extension.api_key === localData.api_key ? '[__HIDDEN__]' : payload.api_key,
|
||||
},
|
||||
})
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
}
|
||||
if (onSave)
|
||||
onSave(res)
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
createApiBasedExtensionMutation.mutate({
|
||||
body,
|
||||
}, {
|
||||
onSuccess: onSaved,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@ -73,42 +76,71 @@ const ApiBasedExtensionModal = ({ open, extension, onOpenChange, onSave }: ApiBa
|
||||
<DialogCloseButton />
|
||||
|
||||
<DialogTitle className="mb-2 pr-8 text-xl font-semibold text-text-primary">
|
||||
{extension.name
|
||||
{mode === 'edit'
|
||||
? t('apiBasedExtension.modal.editTitle', { ns: 'common' })
|
||||
: t('apiBasedExtension.modal.title', { ns: 'common' })}
|
||||
</DialogTitle>
|
||||
<div className="py-2">
|
||||
<div className="text-sm leading-9 font-medium text-text-primary">
|
||||
{t('apiBasedExtension.modal.name.title', { ns: 'common' })}
|
||||
<Form<ApiBasedExtensionPayload> className="grid gap-4 pt-2" onFormSubmit={handleSubmit}>
|
||||
<FieldRoot name="name">
|
||||
<FieldLabel>{nameLabel}</FieldLabel>
|
||||
<FieldControl
|
||||
required
|
||||
defaultValue={editingApiBasedExtension?.name || ''}
|
||||
placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''}
|
||||
/>
|
||||
<FieldError match="valueMissing">{t('errorMsg.fieldRequired', { ns: 'common', field: nameLabel })}</FieldError>
|
||||
</FieldRoot>
|
||||
|
||||
<FieldRoot name="api_endpoint">
|
||||
<FieldLabel>{apiEndpointLabel}</FieldLabel>
|
||||
<FieldControl
|
||||
required
|
||||
defaultValue={editingApiBasedExtension?.api_endpoint || ''}
|
||||
placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''}
|
||||
/>
|
||||
<FieldDescription>
|
||||
<a
|
||||
href={docLink('/use-dify/workspace/api-extension/api-extension')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-fit items-center text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
>
|
||||
<span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3" aria-hidden="true" />
|
||||
{t('apiBasedExtension.link', { ns: 'common' })}
|
||||
</a>
|
||||
</FieldDescription>
|
||||
<FieldError match="valueMissing">{t('errorMsg.fieldRequired', { ns: 'common', field: apiEndpointLabel })}</FieldError>
|
||||
</FieldRoot>
|
||||
|
||||
<FieldRoot
|
||||
name="api_key"
|
||||
validate={(value) => {
|
||||
if (typeof value === 'string' && value.length > 0 && value.length < 5)
|
||||
return t('apiBasedExtension.modal.apiKey.lengthError', { ns: 'common' })
|
||||
|
||||
return null
|
||||
}}
|
||||
>
|
||||
<FieldLabel>{apiKeyLabel}</FieldLabel>
|
||||
<FieldControl
|
||||
required
|
||||
defaultValue={editingApiBasedExtension?.api_key || ''}
|
||||
placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''}
|
||||
/>
|
||||
<FieldError match="valueMissing">{t('errorMsg.fieldRequired', { ns: 'common', field: apiKeyLabel })}</FieldError>
|
||||
<FieldError match="customError" />
|
||||
</FieldRoot>
|
||||
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={isSaving}>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
<input value={localData.name || ''} onChange={e => handleDataChange('name', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''} />
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary">
|
||||
{t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })}
|
||||
<a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" className="flex items-center text-xs font-normal text-text-accent">
|
||||
<span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3" aria-hidden="true" />
|
||||
{t('apiBasedExtension.link', { ns: 'common' })}
|
||||
</a>
|
||||
</div>
|
||||
<input value={localData.api_endpoint || ''} onChange={e => handleDataChange('api_endpoint', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''} />
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<div className="text-sm leading-9 font-medium text-text-primary">
|
||||
{t('apiBasedExtension.modal.apiKey.title', { ns: 'common' })}
|
||||
</div>
|
||||
<input value={localData.api_key || ''} onChange={e => handleDataChange('api_key', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''} />
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!localData.name || !localData.api_endpoint || !localData.api_key || loading} onClick={handleSave}>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
export default ApiBasedExtensionModal
|
||||
|
||||
@ -1,36 +1,36 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import ApiBasedExtensionModal from './modal'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { ApiBasedExtensionModal } from './modal'
|
||||
|
||||
type ApiBasedExtensionSelectorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const ApiBasedExtensionSelector = ({
|
||||
export function ApiBasedExtensionSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: ApiBasedExtensionSelectorProps) => {
|
||||
}: ApiBasedExtensionSelectorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [addModalOpen, setAddModalOpen] = useState(false)
|
||||
const {
|
||||
setShowAccountSettingModal,
|
||||
} = useModalContext()
|
||||
const { data, refetch: mutate } = useApiBasedExtensions()
|
||||
const { data: apiBasedExtensions = [] } = useQuery(consoleQuery.apiBasedExtension.get.queryOptions())
|
||||
const handleSelect = (id: string) => {
|
||||
onChange(id)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const currentItem = data?.find(item => item.id === value)
|
||||
const currentItem = apiBasedExtensions.find(item => item.id === value)
|
||||
|
||||
const handleSaveApiBasedExtension = () => {
|
||||
mutate()
|
||||
const handleApiBasedExtensionSaved = () => {
|
||||
setAddModalOpen(false)
|
||||
}
|
||||
const handleAddModalOpenChange = (nextOpen: boolean) => {
|
||||
@ -96,12 +96,12 @@ const ApiBasedExtensionSelector = ({
|
||||
</div>
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
{
|
||||
data?.map(item => (
|
||||
apiBasedExtensions.map(item => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
className="w-full cursor-pointer rounded-md border-none bg-transparent px-3 py-1.5 text-left hover:bg-state-base-hover"
|
||||
onClick={() => handleSelect(item.id!)}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
>
|
||||
<div className="text-sm text-text-primary">{item.name}</div>
|
||||
<div className="text-xs text-text-tertiary">{item.api_endpoint}</div>
|
||||
@ -131,14 +131,12 @@ const ApiBasedExtensionSelector = ({
|
||||
addModalOpen && (
|
||||
<ApiBasedExtensionModal
|
||||
open
|
||||
extension={{}}
|
||||
mode="create"
|
||||
onOpenChange={handleAddModalOpenChange}
|
||||
onSave={handleSaveApiBasedExtension}
|
||||
onSaved={handleApiBasedExtensionSaved}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApiBasedExtensionSelector
|
||||
|
||||
@ -16,7 +16,7 @@ import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import ApiBasedExtensionPage from './api-based-extension-page'
|
||||
import { ApiBasedExtensionPage } from './api-based-extension-page'
|
||||
import DataSourcePage from './data-source-page-new'
|
||||
import LanguagePage from './language-page'
|
||||
import MembersPage from './members-page'
|
||||
|
||||
@ -305,6 +305,7 @@ const FormInputItem: FC<Props> = ({
|
||||
)}
|
||||
{isCheckbox && isConstant && (
|
||||
<CheckboxList
|
||||
name={variable}
|
||||
title={schema.label?.[language] || schema.label?.en_US || variable}
|
||||
value={checkboxListValue}
|
||||
onChange={handleCheckboxListChange}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { MutationFunctionContext } from '@tanstack/react-query'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
@ -7,7 +8,6 @@ const loadGetBaseURL = async (isClientValue: boolean) => {
|
||||
vi.resetModules()
|
||||
vi.doMock('@/utils/client', () => ({ isClient: isClientValue, isServer: !isClientValue }))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// eslint-disable-next-line next/no-assign-module-variable
|
||||
const module = await import('./client')
|
||||
warnSpy.mockClear()
|
||||
return { getBaseURL: module.getBaseURL, warnSpy }
|
||||
@ -35,6 +35,14 @@ const createTag = (overrides: Partial<Tag> = {}): Tag => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createApiBasedExtension = (overrides: Partial<ApiBasedExtensionResponse> = {}): ApiBasedExtensionResponse => ({
|
||||
id: 'extension-1',
|
||||
name: 'Weather',
|
||||
api_endpoint: 'https://api.example.com/weather',
|
||||
api_key: 'secret-key',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Scenario: base URL selection and warnings.
|
||||
describe('getBaseURL', () => {
|
||||
beforeEach(() => {
|
||||
@ -258,3 +266,94 @@ describe('consoleQuery tag mutation defaults', () => {
|
||||
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag])
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: oRPC mutation defaults own shared API Extension cache behavior.
|
||||
describe('consoleQuery apiBasedExtension mutation defaults', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should add created API Extension to the list query cache', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const listKey = consoleQuery.apiBasedExtension.get.queryKey()
|
||||
const existingExtension = createApiBasedExtension({ id: 'extension-1', name: 'Existing' })
|
||||
const createdExtension = createApiBasedExtension({ id: 'extension-2', name: 'Created' })
|
||||
|
||||
queryClient.setQueryData(listKey, [existingExtension])
|
||||
|
||||
const mutationOptions = consoleQuery.apiBasedExtension.post.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
createdExtension,
|
||||
{
|
||||
body: {
|
||||
name: createdExtension.name,
|
||||
api_endpoint: createdExtension.api_endpoint,
|
||||
api_key: createdExtension.api_key,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
|
||||
expect(queryClient.getQueryData(listKey)).toEqual([createdExtension, existingExtension])
|
||||
})
|
||||
|
||||
it('should update matching API Extension in the list query cache', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const listKey = consoleQuery.apiBasedExtension.get.queryKey()
|
||||
const targetExtension = createApiBasedExtension({ id: 'extension-1', name: 'Before' })
|
||||
const otherExtension = createApiBasedExtension({ id: 'extension-2', name: 'Other' })
|
||||
const updatedExtension = createApiBasedExtension({ ...targetExtension, name: 'After' })
|
||||
|
||||
queryClient.setQueryData(listKey, [targetExtension, otherExtension])
|
||||
|
||||
const mutationOptions = consoleQuery.apiBasedExtension.byId.post.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
updatedExtension,
|
||||
{
|
||||
params: {
|
||||
id: targetExtension.id,
|
||||
},
|
||||
body: {
|
||||
name: 'Ignored Client Name',
|
||||
api_endpoint: targetExtension.api_endpoint,
|
||||
api_key: '[__HIDDEN__]',
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
|
||||
expect(queryClient.getQueryData(listKey)).toEqual([updatedExtension, otherExtension])
|
||||
})
|
||||
|
||||
it('should remove deleted API Extension from the list query cache', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const listKey = consoleQuery.apiBasedExtension.get.queryKey()
|
||||
const deletedExtension = createApiBasedExtension({ id: 'extension-1', name: 'Delete me' })
|
||||
const remainingExtension = createApiBasedExtension({ id: 'extension-2', name: 'Keep me' })
|
||||
|
||||
queryClient.setQueryData(listKey, [deletedExtension, remainingExtension])
|
||||
|
||||
const mutationOptions = consoleQuery.apiBasedExtension.byId.delete.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
id: deletedExtension.id,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
|
||||
expect(queryClient.getQueryData(listKey)).toEqual([remainingExtension])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { ContractRouterClient } from '@orpc/contract'
|
||||
import type { JsonifiedClient } from '@orpc/openapi-client'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
@ -89,6 +90,45 @@ export const consoleClient: JsonifiedClient<ContractRouterClient<typeof consoleR
|
||||
export const consoleQuery = createTanstackQueryUtils(consoleClient, {
|
||||
path: ['console'],
|
||||
experimental_defaults: {
|
||||
apiBasedExtension: {
|
||||
post: {
|
||||
mutationOptions: {
|
||||
onSuccess: (createdExtension, _variables, _onMutateResult, context) => {
|
||||
context.client.setQueryData(
|
||||
consoleQuery.apiBasedExtension.get.queryKey(),
|
||||
(oldExtensions: ApiBasedExtensionResponse[] | undefined) =>
|
||||
oldExtensions ? [createdExtension, ...oldExtensions] : oldExtensions,
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
byId: {
|
||||
post: {
|
||||
mutationOptions: {
|
||||
onSuccess: (updatedExtension, variables, _onMutateResult, context) => {
|
||||
context.client.setQueryData(
|
||||
consoleQuery.apiBasedExtension.get.queryKey(),
|
||||
(oldExtensions: ApiBasedExtensionResponse[] | undefined) =>
|
||||
oldExtensions?.map(extension => extension.id === variables.params.id
|
||||
? updatedExtension
|
||||
: extension),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_data, variables, _onMutateResult, context) => {
|
||||
context.client.setQueryData(
|
||||
consoleQuery.apiBasedExtension.get.queryKey(),
|
||||
(oldExtensions: ApiBasedExtensionResponse[] | undefined) =>
|
||||
oldExtensions?.filter(extension => extension.id !== variables.params.id),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
create: {
|
||||
mutationOptions: {
|
||||
|
||||
@ -1,8 +1,3 @@
|
||||
import type {
|
||||
ApiBasedExtensionListResponse,
|
||||
ApiBasedExtensionPayload,
|
||||
ApiBasedExtensionResponse,
|
||||
} from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type {
|
||||
DefaultModelResponse,
|
||||
Model,
|
||||
@ -275,26 +270,6 @@ export const fetchDataSourceNotionBinding = (url: string): Promise<{ result: str
|
||||
return get<{ result: string }>(url)
|
||||
}
|
||||
|
||||
export const fetchApiBasedExtensionList = (url: string): Promise<ApiBasedExtensionListResponse> => {
|
||||
return get<ApiBasedExtensionListResponse>(url)
|
||||
}
|
||||
|
||||
export const fetchApiBasedExtensionDetail = (url: string): Promise<ApiBasedExtensionResponse> => {
|
||||
return get<ApiBasedExtensionResponse>(url)
|
||||
}
|
||||
|
||||
export const addApiBasedExtension = ({ url, body }: { url: string, body: ApiBasedExtensionPayload }): Promise<ApiBasedExtensionResponse> => {
|
||||
return post<ApiBasedExtensionResponse>(url, { body })
|
||||
}
|
||||
|
||||
export const updateApiBasedExtension = ({ url, body }: { url: string, body: ApiBasedExtensionPayload }): Promise<ApiBasedExtensionResponse> => {
|
||||
return post<ApiBasedExtensionResponse>(url, { body })
|
||||
}
|
||||
|
||||
export const deleteApiBasedExtension = (url: string): Promise<{ result: string }> => {
|
||||
return del<{ result: string }>(url)
|
||||
}
|
||||
|
||||
export const fetchCodeBasedExtensionList = (url: string): Promise<CodeBasedExtension> => {
|
||||
return get<CodeBasedExtension>(url)
|
||||
}
|
||||
|
||||
@ -23,7 +23,6 @@ import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { IS_DEV } from '@/config'
|
||||
import { get, post } from './base'
|
||||
import { consoleClient } from './client'
|
||||
|
||||
/**
|
||||
* True iff `err` is a 401 Response thrown by `service/base.ts`.
|
||||
@ -52,7 +51,6 @@ export const commonQueryKeys = {
|
||||
accountIntegrates: [NAME_SPACE, 'account-integrates'] as const,
|
||||
pluginProviders: [NAME_SPACE, 'plugin-providers'] as const,
|
||||
notionConnection: [NAME_SPACE, 'notion-connection'] as const,
|
||||
apiBasedExtensions: [NAME_SPACE, 'api-based-extensions'] as const,
|
||||
codeBasedExtensions: (module?: string) => [NAME_SPACE, 'code-based-extensions', module] as const,
|
||||
invitationCheck: (params?: { workspace_id?: string, email?: string, token?: string }) => [
|
||||
NAME_SPACE,
|
||||
@ -319,13 +317,6 @@ export const useCodeBasedExtensions = (module: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useApiBasedExtensions = () => {
|
||||
return useQuery({
|
||||
queryKey: commonQueryKeys.apiBasedExtensions,
|
||||
queryFn: () => consoleClient.apiBasedExtension.get(),
|
||||
})
|
||||
}
|
||||
|
||||
export const useInvitationCheck = (params?: { workspace_id?: string, email?: string, token?: string }, enabled?: boolean) => {
|
||||
return useQuery({
|
||||
queryKey: commonQueryKeys.invitationCheck(params),
|
||||
|
||||
Reference in New Issue
Block a user