mirror of
https://github.com/langgenius/dify.git
synced 2026-02-14 15:25:34 +08:00
Compare commits
7 Commits
fix/explor
...
feat/defau
| Author | SHA1 | Date | |
|---|---|---|---|
| a1295b66e4 | |||
| cc87cc8057 | |||
| 2020669cb0 | |||
| 734d52c643 | |||
| b640ad5ba1 | |||
| 78bb79d640 | |||
| 50f8647ea8 |
@ -289,6 +289,12 @@ class AccountService:
|
||||
|
||||
TenantService.create_owner_tenant_if_not_exist(account=account)
|
||||
|
||||
# Enterprise-only: best-effort add the account to the default workspace (does not switch current workspace).
|
||||
if getattr(dify_config, "ENTERPRISE_ENABLED", False):
|
||||
from services.enterprise.enterprise_service import try_join_default_workspace_async
|
||||
|
||||
try_join_default_workspace_async(str(account.id))
|
||||
|
||||
return account
|
||||
|
||||
@staticmethod
|
||||
@ -1407,6 +1413,12 @@ class RegisterService:
|
||||
tenant_was_created.send(tenant)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Enterprise-only: best-effort add the account to the default workspace (does not switch current workspace).
|
||||
if getattr(dify_config, "ENTERPRISE_ENABLED", False):
|
||||
from services.enterprise.enterprise_service import try_join_default_workspace_async
|
||||
|
||||
try_join_default_workspace_async(str(account.id))
|
||||
except WorkSpaceNotAllowedCreateError:
|
||||
db.session.rollback()
|
||||
logger.exception("Register failed")
|
||||
|
||||
@ -39,6 +39,9 @@ class BaseRequest:
|
||||
endpoint: str,
|
||||
json: Any | None = None,
|
||||
params: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
raise_for_status: bool = False,
|
||||
) -> Any:
|
||||
headers = {"Content-Type": "application/json", cls.secret_key_header: cls.secret_key}
|
||||
url = f"{cls.base_url}{endpoint}"
|
||||
@ -53,7 +56,16 @@ class BaseRequest:
|
||||
logger.debug("Failed to generate traceparent header", exc_info=True)
|
||||
|
||||
with httpx.Client(mounts=mounts) as client:
|
||||
response = client.request(method, url, json=json, params=params, headers=headers)
|
||||
# IMPORTANT:
|
||||
# - In httpx, passing timeout=None disables timeouts (infinite) and overrides the library default.
|
||||
# - To preserve httpx's default timeout behavior for existing call sites, only pass the kwarg when set.
|
||||
request_kwargs: dict[str, Any] = {"json": json, "params": params, "headers": headers}
|
||||
if timeout is not None:
|
||||
request_kwargs["timeout"] = timeout
|
||||
|
||||
response = client.request(method, url, **request_kwargs)
|
||||
if raise_for_status:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
|
||||
@ -1,9 +1,47 @@
|
||||
import atexit
|
||||
import logging
|
||||
import uuid
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from configs import dify_config
|
||||
from services.enterprise.base import EnterpriseRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_WORKSPACE_JOIN_TIMEOUT_SECONDS = 1.0
|
||||
DEFAULT_WORKSPACE_JOIN_ASYNC_MAX_WORKERS = 1
|
||||
|
||||
# Fire-and-forget executor for signup-time enterprise side effects.
|
||||
# Best-effort means we accept that tasks may be dropped on process restart.
|
||||
_default_workspace_join_executor = ThreadPoolExecutor(
|
||||
max_workers=DEFAULT_WORKSPACE_JOIN_ASYNC_MAX_WORKERS,
|
||||
thread_name_prefix="enterprise-default-workspace-join",
|
||||
)
|
||||
|
||||
|
||||
def _shutdown_default_workspace_join_executor() -> None:
|
||||
"""
|
||||
Best-effort cleanup for the module-level executor.
|
||||
|
||||
We wait for the currently running task (at most one worker), but cancel any queued
|
||||
tasks to avoid blocking process shutdown for too long.
|
||||
"""
|
||||
|
||||
try:
|
||||
_default_workspace_join_executor.shutdown(wait=True, cancel_futures=True)
|
||||
except Exception:
|
||||
logger.debug("Failed to shutdown default workspace join executor", exc_info=True)
|
||||
|
||||
|
||||
def _register_shutdown_hook() -> None:
|
||||
atexit.register(_shutdown_default_workspace_join_executor)
|
||||
|
||||
|
||||
_register_shutdown_hook()
|
||||
|
||||
|
||||
class WebAppSettings(BaseModel):
|
||||
access_mode: str = Field(
|
||||
@ -30,6 +68,78 @@ class WorkspacePermission(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class DefaultWorkspaceJoinResult(BaseModel):
|
||||
"""
|
||||
Result of ensuring an account is a member of the enterprise default workspace.
|
||||
|
||||
- joined=True is idempotent (already a member also returns True)
|
||||
- joined=False means enterprise default workspace is not configured or invalid/archived
|
||||
"""
|
||||
|
||||
# Only workspace_id can be empty when "no default workspace configured".
|
||||
workspace_id: str = ""
|
||||
|
||||
# These fields are required to avoid silently treating error payloads as "skipped".
|
||||
joined: bool
|
||||
message: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def try_join_default_workspace(account_id: str) -> None:
|
||||
"""
|
||||
Enterprise-only side-effect: ensure account is a member of the default workspace.
|
||||
|
||||
This is a best-effort integration. Failures must not block user registration.
|
||||
"""
|
||||
|
||||
if not dify_config.ENTERPRISE_ENABLED:
|
||||
return
|
||||
|
||||
try:
|
||||
result = EnterpriseService.join_default_workspace(account_id=account_id)
|
||||
if result.joined:
|
||||
logger.info(
|
||||
"Joined enterprise default workspace for account %s (workspace_id=%s)",
|
||||
account_id,
|
||||
result.workspace_id,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Skipped joining enterprise default workspace for account %s (message=%s)",
|
||||
account_id,
|
||||
result.message,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to join enterprise default workspace for account %s", account_id, exc_info=True)
|
||||
|
||||
|
||||
def try_join_default_workspace_async(account_id: str) -> None:
|
||||
"""
|
||||
Async best-effort wrapper for try_join_default_workspace().
|
||||
|
||||
This is intended for request paths (signup/registration) where we do not want to
|
||||
add latency or tie up web workers waiting on the enterprise service.
|
||||
"""
|
||||
|
||||
if not dify_config.ENTERPRISE_ENABLED:
|
||||
return
|
||||
|
||||
future: Future[None] = _default_workspace_join_executor.submit(try_join_default_workspace, account_id)
|
||||
|
||||
def _on_done(f: Future[None], *, _account_id: str = account_id) -> None:
|
||||
try:
|
||||
f.result()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Async join enterprise default workspace failed for account %s",
|
||||
_account_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
future.add_done_callback(_on_done)
|
||||
|
||||
|
||||
class EnterpriseService:
|
||||
@classmethod
|
||||
def get_info(cls):
|
||||
@ -39,6 +149,34 @@ class EnterpriseService:
|
||||
def get_workspace_info(cls, tenant_id: str):
|
||||
return EnterpriseRequest.send_request("GET", f"/workspace/{tenant_id}/info")
|
||||
|
||||
@classmethod
|
||||
def join_default_workspace(cls, *, account_id: str) -> DefaultWorkspaceJoinResult:
|
||||
"""
|
||||
Call enterprise inner API to add an account to the default workspace.
|
||||
|
||||
NOTE: EnterpriseRequest.base_url is expected to already include the `/inner/api` prefix,
|
||||
so the endpoint here is `/default-workspace/members`.
|
||||
"""
|
||||
|
||||
# Ensure we are sending a UUID-shaped string (enterprise side validates too).
|
||||
try:
|
||||
uuid.UUID(account_id)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"account_id must be a valid UUID: {account_id}") from e
|
||||
|
||||
data = EnterpriseRequest.send_request(
|
||||
"POST",
|
||||
"/default-workspace/members",
|
||||
json={"account_id": account_id},
|
||||
timeout=DEFAULT_WORKSPACE_JOIN_TIMEOUT_SECONDS,
|
||||
raise_for_status=True,
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Invalid response format from enterprise default workspace API")
|
||||
if "joined" not in data or "message" not in data:
|
||||
raise ValueError("Invalid response payload from enterprise default workspace API")
|
||||
return DefaultWorkspaceJoinResult.model_validate(data)
|
||||
|
||||
@classmethod
|
||||
def get_app_sso_settings_last_update_time(cls) -> datetime:
|
||||
data = EnterpriseRequest.send_request("GET", "/sso/app/last-update-time")
|
||||
|
||||
@ -0,0 +1,182 @@
|
||||
"""Unit tests for enterprise service integrations.
|
||||
|
||||
This module covers the enterprise-only default workspace auto-join behavior:
|
||||
- Enterprise mode disabled: no external calls
|
||||
- Successful join / skipped join: no errors
|
||||
- Failures (network/invalid response/invalid UUID): soft-fail wrapper must not raise
|
||||
"""
|
||||
|
||||
from concurrent.futures import Future
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.enterprise.enterprise_service import (
|
||||
DefaultWorkspaceJoinResult,
|
||||
EnterpriseService,
|
||||
try_join_default_workspace,
|
||||
try_join_default_workspace_async,
|
||||
)
|
||||
|
||||
|
||||
class TestJoinDefaultWorkspace:
|
||||
def test_join_default_workspace_success(self):
|
||||
account_id = "11111111-1111-1111-1111-111111111111"
|
||||
response = {"workspace_id": "22222222-2222-2222-2222-222222222222", "joined": True, "message": "ok"}
|
||||
|
||||
with patch("services.enterprise.enterprise_service.EnterpriseRequest.send_request") as mock_send_request:
|
||||
mock_send_request.return_value = response
|
||||
|
||||
result = EnterpriseService.join_default_workspace(account_id=account_id)
|
||||
|
||||
assert isinstance(result, DefaultWorkspaceJoinResult)
|
||||
assert result.workspace_id == response["workspace_id"]
|
||||
assert result.joined is True
|
||||
assert result.message == "ok"
|
||||
|
||||
mock_send_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/default-workspace/members",
|
||||
json={"account_id": account_id},
|
||||
timeout=1.0,
|
||||
raise_for_status=True,
|
||||
)
|
||||
|
||||
def test_join_default_workspace_invalid_response_format_raises(self):
|
||||
account_id = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
with patch("services.enterprise.enterprise_service.EnterpriseRequest.send_request") as mock_send_request:
|
||||
mock_send_request.return_value = "not-a-dict"
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid response format"):
|
||||
EnterpriseService.join_default_workspace(account_id=account_id)
|
||||
|
||||
def test_join_default_workspace_invalid_account_id_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
EnterpriseService.join_default_workspace(account_id="not-a-uuid")
|
||||
|
||||
def test_join_default_workspace_missing_required_fields_raises(self):
|
||||
account_id = "11111111-1111-1111-1111-111111111111"
|
||||
response = {"workspace_id": "", "message": "ok"} # missing "joined"
|
||||
|
||||
with patch("services.enterprise.enterprise_service.EnterpriseRequest.send_request") as mock_send_request:
|
||||
mock_send_request.return_value = response
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid response payload"):
|
||||
EnterpriseService.join_default_workspace(account_id=account_id)
|
||||
|
||||
|
||||
class TestTryJoinDefaultWorkspace:
|
||||
def test_try_join_default_workspace_enterprise_disabled_noop(self):
|
||||
with (
|
||||
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
|
||||
patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = False
|
||||
|
||||
try_join_default_workspace("11111111-1111-1111-1111-111111111111")
|
||||
|
||||
mock_join.assert_not_called()
|
||||
|
||||
def test_try_join_default_workspace_successful_join_does_not_raise(self):
|
||||
account_id = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
with (
|
||||
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
|
||||
patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
mock_join.return_value = DefaultWorkspaceJoinResult(
|
||||
workspace_id="22222222-2222-2222-2222-222222222222",
|
||||
joined=True,
|
||||
message="ok",
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
try_join_default_workspace(account_id)
|
||||
|
||||
mock_join.assert_called_once_with(account_id=account_id)
|
||||
|
||||
def test_try_join_default_workspace_skipped_join_does_not_raise(self):
|
||||
account_id = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
with (
|
||||
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
|
||||
patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
mock_join.return_value = DefaultWorkspaceJoinResult(
|
||||
workspace_id="",
|
||||
joined=False,
|
||||
message="no default workspace configured",
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
try_join_default_workspace(account_id)
|
||||
|
||||
mock_join.assert_called_once_with(account_id=account_id)
|
||||
|
||||
def test_try_join_default_workspace_api_failure_soft_fails(self):
|
||||
account_id = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
with (
|
||||
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
|
||||
patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
mock_join.side_effect = Exception("network failure")
|
||||
|
||||
# Should not raise
|
||||
try_join_default_workspace(account_id)
|
||||
|
||||
mock_join.assert_called_once_with(account_id=account_id)
|
||||
|
||||
def test_try_join_default_workspace_invalid_account_id_soft_fails(self):
|
||||
with patch("services.enterprise.enterprise_service.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
|
||||
# Should not raise even though UUID parsing fails inside join_default_workspace
|
||||
try_join_default_workspace("not-a-uuid")
|
||||
|
||||
|
||||
class TestTryJoinDefaultWorkspaceAsync:
|
||||
def test_try_join_default_workspace_async_enterprise_disabled_noop(self):
|
||||
with (
|
||||
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
|
||||
patch("services.enterprise.enterprise_service._default_workspace_join_executor") as mock_executor,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = False
|
||||
|
||||
try_join_default_workspace_async("11111111-1111-1111-1111-111111111111")
|
||||
|
||||
mock_executor.submit.assert_not_called()
|
||||
|
||||
def test_try_join_default_workspace_async_submits_task(self):
|
||||
with (
|
||||
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
|
||||
patch("services.enterprise.enterprise_service._default_workspace_join_executor") as mock_executor,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
|
||||
future: Future[None] = Future()
|
||||
future.set_result(None)
|
||||
mock_executor.submit.return_value = future
|
||||
|
||||
try_join_default_workspace_async("11111111-1111-1111-1111-111111111111")
|
||||
|
||||
assert mock_executor.submit.call_count == 1
|
||||
|
||||
def test_try_join_default_workspace_async_logs_warning_on_future_exception(self, caplog):
|
||||
with (
|
||||
patch("services.enterprise.enterprise_service.dify_config") as mock_config,
|
||||
patch("services.enterprise.enterprise_service._default_workspace_join_executor") as mock_executor,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = True
|
||||
|
||||
future: Future[None] = Future()
|
||||
future.set_exception(Exception("boom"))
|
||||
mock_executor.submit.return_value = future
|
||||
|
||||
try_join_default_workspace_async("11111111-1111-1111-1111-111111111111")
|
||||
|
||||
assert "Async join enterprise default workspace failed" in caplog.text
|
||||
@ -1064,6 +1064,71 @@ class TestRegisterService:
|
||||
|
||||
# ==================== Registration Tests ====================
|
||||
|
||||
def test_create_account_and_tenant_calls_default_workspace_join_when_enterprise_enabled(
|
||||
self, mock_db_dependencies, mock_external_service_dependencies, monkeypatch
|
||||
):
|
||||
"""Enterprise-only side effect should be invoked when ENTERPRISE_ENABLED is True."""
|
||||
monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", True, raising=False)
|
||||
|
||||
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
|
||||
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
|
||||
|
||||
mock_account = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="11111111-1111-1111-1111-111111111111"
|
||||
)
|
||||
|
||||
with (
|
||||
patch("services.account_service.AccountService.create_account") as mock_create_account,
|
||||
patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_workspace,
|
||||
patch(
|
||||
"services.enterprise.enterprise_service.try_join_default_workspace_async"
|
||||
) as mock_join_default_workspace,
|
||||
):
|
||||
mock_create_account.return_value = mock_account
|
||||
|
||||
result = AccountService.create_account_and_tenant(
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
interface_language="en-US",
|
||||
password=None,
|
||||
)
|
||||
|
||||
assert result == mock_account
|
||||
mock_create_workspace.assert_called_once_with(account=mock_account)
|
||||
mock_join_default_workspace.assert_called_once_with(str(mock_account.id))
|
||||
|
||||
def test_create_account_and_tenant_does_not_call_default_workspace_join_when_enterprise_disabled(
|
||||
self, mock_db_dependencies, mock_external_service_dependencies, monkeypatch
|
||||
):
|
||||
"""Enterprise-only side effect should not be invoked when ENTERPRISE_ENABLED is False."""
|
||||
monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", False, raising=False)
|
||||
|
||||
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
|
||||
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
|
||||
|
||||
mock_account = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="11111111-1111-1111-1111-111111111111"
|
||||
)
|
||||
|
||||
with (
|
||||
patch("services.account_service.AccountService.create_account") as mock_create_account,
|
||||
patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_workspace,
|
||||
patch(
|
||||
"services.enterprise.enterprise_service.try_join_default_workspace_async"
|
||||
) as mock_join_default_workspace,
|
||||
):
|
||||
mock_create_account.return_value = mock_account
|
||||
|
||||
AccountService.create_account_and_tenant(
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
interface_language="en-US",
|
||||
password=None,
|
||||
)
|
||||
|
||||
mock_create_workspace.assert_called_once_with(account=mock_account)
|
||||
mock_join_default_workspace.assert_not_called()
|
||||
|
||||
def test_register_success(self, mock_db_dependencies, mock_external_service_dependencies):
|
||||
"""Test successful account registration."""
|
||||
# Setup mocks
|
||||
@ -1115,6 +1180,69 @@ class TestRegisterService:
|
||||
mock_event.send.assert_called_once_with(mock_tenant)
|
||||
self._assert_database_operations_called(mock_db_dependencies["db"])
|
||||
|
||||
def test_register_calls_default_workspace_join_when_enterprise_enabled(
|
||||
self, mock_db_dependencies, mock_external_service_dependencies, monkeypatch
|
||||
):
|
||||
"""Enterprise-only side effect should be invoked after successful register commit."""
|
||||
monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", True, raising=False)
|
||||
|
||||
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
|
||||
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
|
||||
|
||||
mock_account = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="11111111-1111-1111-1111-111111111111"
|
||||
)
|
||||
|
||||
with (
|
||||
patch("services.account_service.AccountService.create_account") as mock_create_account,
|
||||
patch(
|
||||
"services.enterprise.enterprise_service.try_join_default_workspace_async"
|
||||
) as mock_join_default_workspace,
|
||||
):
|
||||
mock_create_account.return_value = mock_account
|
||||
|
||||
result = RegisterService.register(
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
password="password123",
|
||||
language="en-US",
|
||||
create_workspace_required=False,
|
||||
)
|
||||
|
||||
assert result == mock_account
|
||||
mock_join_default_workspace.assert_called_once_with(str(mock_account.id))
|
||||
|
||||
def test_register_does_not_call_default_workspace_join_when_enterprise_disabled(
|
||||
self, mock_db_dependencies, mock_external_service_dependencies, monkeypatch
|
||||
):
|
||||
"""Enterprise-only side effect should not be invoked when ENTERPRISE_ENABLED is False."""
|
||||
monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", False, raising=False)
|
||||
|
||||
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
|
||||
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
|
||||
|
||||
mock_account = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="11111111-1111-1111-1111-111111111111"
|
||||
)
|
||||
|
||||
with (
|
||||
patch("services.account_service.AccountService.create_account") as mock_create_account,
|
||||
patch(
|
||||
"services.enterprise.enterprise_service.try_join_default_workspace_async"
|
||||
) as mock_join_default_workspace,
|
||||
):
|
||||
mock_create_account.return_value = mock_account
|
||||
|
||||
RegisterService.register(
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
password="password123",
|
||||
language="en-US",
|
||||
create_workspace_required=False,
|
||||
)
|
||||
|
||||
mock_join_default_workspace.assert_not_called()
|
||||
|
||||
def test_register_with_oauth(self, mock_db_dependencies, mock_external_service_dependencies):
|
||||
"""Test account registration with OAuth integration."""
|
||||
# Setup mocks
|
||||
|
||||
@ -9,9 +9,8 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo
|
||||
import type { App } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import AppList from '@/app/components/explore/app-list'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
|
||||
@ -58,14 +57,6 @@ vi.mock('@/service/explore', () => ({
|
||||
fetchAppList: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-import-dsl', () => ({
|
||||
useImportDSL: () => ({
|
||||
handleImportDSL: mockHandleImportDSL,
|
||||
@ -135,25 +126,26 @@ const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
is_agent: overrides.is_agent ?? false,
|
||||
})
|
||||
|
||||
const mockMemberRole = (hasEditPermission: boolean) => {
|
||||
;(useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
})
|
||||
;(useMembers as Mock).mockReturnValue({
|
||||
data: {
|
||||
accounts: [{ id: 'user-1', role: hasEditPermission ? 'admin' : 'normal' }],
|
||||
},
|
||||
})
|
||||
}
|
||||
const createContextValue = (hasEditPermission = true) => ({
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission,
|
||||
installedApps: [] as never[],
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: false,
|
||||
setShowTryAppPanel: vi.fn(),
|
||||
})
|
||||
|
||||
const renderAppList = (hasEditPermission = true, onSuccess?: () => void) => {
|
||||
mockMemberRole(hasEditPermission)
|
||||
return render(<AppList onSuccess={onSuccess} />)
|
||||
}
|
||||
const wrapWithContext = (hasEditPermission = true, onSuccess?: () => void) => (
|
||||
<ExploreContext.Provider value={createContextValue(hasEditPermission)}>
|
||||
<AppList onSuccess={onSuccess} />
|
||||
</ExploreContext.Provider>
|
||||
)
|
||||
|
||||
const appListElement = (hasEditPermission = true, onSuccess?: () => void) => {
|
||||
mockMemberRole(hasEditPermission)
|
||||
return <AppList onSuccess={onSuccess} />
|
||||
const renderWithContext = (hasEditPermission = true, onSuccess?: () => void) => {
|
||||
return render(wrapWithContext(hasEditPermission, onSuccess))
|
||||
}
|
||||
|
||||
describe('Explore App List Flow', () => {
|
||||
@ -173,7 +165,7 @@ describe('Explore App List Flow', () => {
|
||||
|
||||
describe('Browse and Filter Flow', () => {
|
||||
it('should display all apps when no category filter is applied', () => {
|
||||
renderAppList()
|
||||
renderWithContext()
|
||||
|
||||
expect(screen.getByText('Writer Bot')).toBeInTheDocument()
|
||||
expect(screen.getByText('Translator')).toBeInTheDocument()
|
||||
@ -182,7 +174,7 @@ describe('Explore App List Flow', () => {
|
||||
|
||||
it('should filter apps by selected category', () => {
|
||||
mockTabValue = 'Writing'
|
||||
renderAppList()
|
||||
renderWithContext()
|
||||
|
||||
expect(screen.getByText('Writer Bot')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Translator')).not.toBeInTheDocument()
|
||||
@ -190,7 +182,7 @@ describe('Explore App List Flow', () => {
|
||||
})
|
||||
|
||||
it('should filter apps by search keyword', async () => {
|
||||
renderAppList()
|
||||
renderWithContext()
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'trans' } })
|
||||
@ -215,7 +207,7 @@ describe('Explore App List Flow', () => {
|
||||
options.onSuccess?.()
|
||||
})
|
||||
|
||||
renderAppList(true, onSuccess)
|
||||
renderWithContext(true, onSuccess)
|
||||
|
||||
// Step 2: Click add to workspace button - opens create modal
|
||||
fireEvent.click(screen.getAllByText('explore.appCard.addToWorkspace')[0])
|
||||
@ -248,7 +240,7 @@ describe('Explore App List Flow', () => {
|
||||
// Step 1: Loading state
|
||||
mockIsLoading = true
|
||||
mockExploreData = undefined
|
||||
const { unmount } = render(appListElement())
|
||||
const { rerender } = render(wrapWithContext())
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
@ -258,8 +250,7 @@ describe('Explore App List Flow', () => {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
unmount()
|
||||
renderAppList()
|
||||
rerender(wrapWithContext())
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
@ -268,13 +259,13 @@ describe('Explore App List Flow', () => {
|
||||
|
||||
describe('Permission-Based Behavior', () => {
|
||||
it('should hide add-to-workspace button when user has no edit permission', () => {
|
||||
renderAppList(false)
|
||||
renderWithContext(false)
|
||||
|
||||
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show add-to-workspace button when user has edit permission', () => {
|
||||
renderAppList(true)
|
||||
renderWithContext(true)
|
||||
|
||||
expect(screen.getAllByText('explore.appCard.addToWorkspace').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@ -8,13 +8,20 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { InstalledApp as InstalledAppModel } from '@/models/explore'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import InstalledApp from '@/app/components/explore/installed-app'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: vi.fn(),
|
||||
createContext: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: vi.fn(),
|
||||
}))
|
||||
@ -27,7 +34,6 @@ vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledAppAccessModeByAppId: vi.fn(),
|
||||
useGetInstalledAppParams: vi.fn(),
|
||||
useGetInstalledAppMeta: vi.fn(),
|
||||
useGetInstalledApps: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation', () => ({
|
||||
@ -80,21 +86,18 @@ describe('Installed App Flow', () => {
|
||||
}
|
||||
|
||||
type MockOverrides = {
|
||||
installedApps?: { apps?: InstalledAppModel[], isPending?: boolean, isFetching?: boolean }
|
||||
accessMode?: { isPending?: boolean, data?: unknown, error?: unknown }
|
||||
params?: { isPending?: boolean, data?: unknown, error?: unknown }
|
||||
meta?: { isPending?: boolean, data?: unknown, error?: unknown }
|
||||
context?: { installedApps?: InstalledAppModel[], isFetchingInstalledApps?: boolean }
|
||||
accessMode?: { isFetching?: boolean, data?: unknown, error?: unknown }
|
||||
params?: { isFetching?: boolean, data?: unknown, error?: unknown }
|
||||
meta?: { isFetching?: boolean, data?: unknown, error?: unknown }
|
||||
userAccess?: { data?: unknown, error?: unknown }
|
||||
}
|
||||
|
||||
const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => {
|
||||
const installedApps = overrides.installedApps?.apps ?? (app ? [app] : [])
|
||||
|
||||
;(useGetInstalledApps as Mock).mockReturnValue({
|
||||
data: { installed_apps: installedApps },
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
...overrides.installedApps,
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: app ? [app] : [],
|
||||
isFetchingInstalledApps: false,
|
||||
...overrides.context,
|
||||
})
|
||||
|
||||
;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record<string, Mock>) => unknown) => {
|
||||
@ -108,21 +111,21 @@ describe('Installed App Flow', () => {
|
||||
})
|
||||
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: { accessMode: AccessMode.PUBLIC },
|
||||
error: null,
|
||||
...overrides.accessMode,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: mockAppParams,
|
||||
error: null,
|
||||
...overrides.params,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: { tool_icons: {} },
|
||||
error: null,
|
||||
...overrides.meta,
|
||||
@ -179,7 +182,7 @@ describe('Installed App Flow', () => {
|
||||
describe('Data Loading Flow', () => {
|
||||
it('should show loading spinner when params are being fetched', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app, { params: { isPending: true, data: null } })
|
||||
setupDefaultMocks(app, { params: { isFetching: true, data: null } })
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
@ -187,17 +190,6 @@ describe('Installed App Flow', () => {
|
||||
expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should defer 404 while installed apps are refetching without a match', () => {
|
||||
setupDefaultMocks(undefined, {
|
||||
installedApps: { apps: [], isPending: false, isFetching: true },
|
||||
})
|
||||
|
||||
const { container } = render(<InstalledApp id="nonexistent" />)
|
||||
|
||||
expect(container.querySelector('svg.spin-animation')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content when all data is available', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { IExplore } from '@/context/explore-context'
|
||||
/**
|
||||
* Integration test: Sidebar Lifecycle Flow
|
||||
*
|
||||
@ -9,12 +10,14 @@ import type { InstalledApp } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import SideBar from '@/app/components/explore/sidebar'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockMediaType: string = MediaType.pc
|
||||
const mockSegments = ['apps']
|
||||
const mockPush = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUninstall = vi.fn()
|
||||
const mockUpdatePinStatus = vi.fn()
|
||||
let mockInstalledApps: InstalledApp[] = []
|
||||
@ -37,8 +40,9 @@ vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledApps: () => ({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: { installed_apps: mockInstalledApps },
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useUninstallApp: () => ({
|
||||
mutateAsync: mockUninstall,
|
||||
@ -65,8 +69,24 @@ const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp
|
||||
},
|
||||
})
|
||||
|
||||
const renderSidebar = () => {
|
||||
return render(<SideBar />)
|
||||
const createContextValue = (installedApps: InstalledApp[] = []): IExplore => ({
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission: true,
|
||||
installedApps,
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: false,
|
||||
setShowTryAppPanel: vi.fn(),
|
||||
})
|
||||
|
||||
const renderSidebar = (installedApps: InstalledApp[] = []) => {
|
||||
return render(
|
||||
<ExploreContext.Provider value={createContextValue(installedApps)}>
|
||||
<SideBar controlUpdateInstalledApps={0} />
|
||||
</ExploreContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sidebar Lifecycle Flow', () => {
|
||||
@ -84,7 +104,7 @@ describe('Sidebar Lifecycle Flow', () => {
|
||||
// Step 1: Start with an unpinned app and pin it
|
||||
const unpinnedApp = createInstalledApp({ is_pinned: false })
|
||||
mockInstalledApps = [unpinnedApp]
|
||||
const { unmount } = renderSidebar()
|
||||
const { unmount } = renderSidebar(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
@ -103,7 +123,7 @@ describe('Sidebar Lifecycle Flow', () => {
|
||||
|
||||
const pinnedApp = createInstalledApp({ is_pinned: true })
|
||||
mockInstalledApps = [pinnedApp]
|
||||
renderSidebar()
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.unpin'))
|
||||
@ -121,7 +141,7 @@ describe('Sidebar Lifecycle Flow', () => {
|
||||
mockInstalledApps = [app]
|
||||
mockUninstall.mockResolvedValue(undefined)
|
||||
|
||||
renderSidebar()
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
// Step 1: Open operation menu and click delete
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
@ -147,7 +167,7 @@ describe('Sidebar Lifecycle Flow', () => {
|
||||
const app = createInstalledApp()
|
||||
mockInstalledApps = [app]
|
||||
|
||||
renderSidebar()
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
// Open delete flow
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
@ -168,7 +188,7 @@ describe('Sidebar Lifecycle Flow', () => {
|
||||
createInstalledApp({ id: 'unpinned-1', is_pinned: false, app: { ...createInstalledApp().app, name: 'Regular App' } }),
|
||||
]
|
||||
|
||||
const { container } = renderSidebar()
|
||||
const { container } = renderSidebar(mockInstalledApps)
|
||||
|
||||
// Both apps are rendered
|
||||
const pinnedApp = screen.getByText('Pinned App')
|
||||
@ -190,14 +210,14 @@ describe('Sidebar Lifecycle Flow', () => {
|
||||
describe('Empty State', () => {
|
||||
it('should show NoApps component when no apps are installed on desktop', () => {
|
||||
mockMediaType = MediaType.pc
|
||||
renderSidebar()
|
||||
renderSidebar([])
|
||||
|
||||
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide NoApps on mobile', () => {
|
||||
mockMediaType = MediaType.mobile
|
||||
renderSidebar()
|
||||
renderSidebar([])
|
||||
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -2,6 +2,18 @@ import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiBuildingLine,
|
||||
RiGlobalLine,
|
||||
RiLockLine,
|
||||
RiPlanetLine,
|
||||
RiPlayCircleLine,
|
||||
RiPlayList2Line,
|
||||
RiTerminalBoxLine,
|
||||
RiVerifiedBadgeLine,
|
||||
} from '@remixicon/react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
@ -45,22 +57,22 @@ import SuggestedAction from './suggested-action'
|
||||
|
||||
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
|
||||
|
||||
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
|
||||
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: React.ElementType }> = {
|
||||
[AccessMode.ORGANIZATION]: {
|
||||
label: 'organization',
|
||||
icon: 'i-ri-building-line',
|
||||
icon: RiBuildingLine,
|
||||
},
|
||||
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
|
||||
label: 'specific',
|
||||
icon: 'i-ri-lock-line',
|
||||
icon: RiLockLine,
|
||||
},
|
||||
[AccessMode.PUBLIC]: {
|
||||
label: 'anyone',
|
||||
icon: 'i-ri-global-line',
|
||||
icon: RiGlobalLine,
|
||||
},
|
||||
[AccessMode.EXTERNAL_MEMBERS]: {
|
||||
label: 'external',
|
||||
icon: 'i-ri-verified-badge-line',
|
||||
icon: RiVerifiedBadgeLine,
|
||||
},
|
||||
}
|
||||
|
||||
@ -70,13 +82,13 @@ const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => {
|
||||
if (!mode || !ACCESS_MODE_MAP[mode])
|
||||
return null
|
||||
|
||||
const { icon, label } = ACCESS_MODE_MAP[mode]
|
||||
const { icon: Icon, label } = ACCESS_MODE_MAP[mode]
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
|
||||
<Icon className="h-4 w-4 shrink-0 text-text-secondary" />
|
||||
<div className="grow truncate">
|
||||
<span className="text-text-secondary system-sm-medium">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
|
||||
<span className="system-sm-medium text-text-secondary">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@ -213,7 +225,7 @@ const AppPublisher = ({
|
||||
await openAsyncWindow(async () => {
|
||||
if (!appDetail?.id)
|
||||
throw new Error('App not found')
|
||||
const { installed_apps } = await fetchInstalledAppList(appDetail.id)
|
||||
const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {}
|
||||
if (installed_apps?.length > 0)
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
throw new Error('No app found in Explore')
|
||||
@ -272,19 +284,19 @@ const AppPublisher = ({
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('common.publish', { ns: 'workflow' })}
|
||||
<span className="i-ri-arrow-down-s-line h-4 w-4 text-components-button-primary-text" />
|
||||
<RiArrowDownSLine className="h-4 w-4 text-components-button-primary-text" />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[11]">
|
||||
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
<div className="p-4 pt-3">
|
||||
<div className="flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
|
||||
<div className="system-xs-medium-uppercase flex h-6 items-center text-text-tertiary">
|
||||
{publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })}
|
||||
</div>
|
||||
{publishedAt
|
||||
? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center text-text-secondary system-sm-medium">
|
||||
<div className="system-sm-medium flex items-center text-text-secondary">
|
||||
{t('common.publishedAt', { ns: 'workflow' })}
|
||||
{' '}
|
||||
{formatTimeFromNow(publishedAt)}
|
||||
@ -302,7 +314,7 @@ const AppPublisher = ({
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center text-text-secondary system-sm-medium">
|
||||
<div className="system-sm-medium flex items-center text-text-secondary">
|
||||
{t('common.autoSaved', { ns: 'workflow' })}
|
||||
{' '}
|
||||
·
|
||||
@ -365,10 +377,10 @@ const AppPublisher = ({
|
||||
{systemFeatures.webapp_auth.enabled && (
|
||||
<div className="p-4 pt-3">
|
||||
<div className="flex h-6 items-center">
|
||||
<p className="text-text-tertiary system-xs-medium">{t('publishApp.title', { ns: 'app' })}</p>
|
||||
<p className="system-xs-medium text-text-tertiary">{t('publishApp.title', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent"
|
||||
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent"
|
||||
onClick={() => {
|
||||
setShowAppAccessControl(true)
|
||||
}}
|
||||
@ -376,12 +388,12 @@ const AppPublisher = ({
|
||||
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
|
||||
<AccessModeDisplay mode={appDetail?.access_mode} />
|
||||
</div>
|
||||
{!isAppAccessSet && <p className="shrink-0 text-text-tertiary system-xs-regular">{t('publishApp.notSet', { ns: 'app' })}</p>}
|
||||
{!isAppAccessSet && <p className="system-xs-regular shrink-0 text-text-tertiary">{t('publishApp.notSet', { ns: 'app' })}</p>}
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
|
||||
<RiArrowRightSLine className="h-4 w-4 text-text-quaternary" />
|
||||
</div>
|
||||
</div>
|
||||
{!isAppAccessSet && <p className="mt-1 text-text-warning system-xs-regular">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
|
||||
{!isAppAccessSet && <p className="system-xs-regular mt-1 text-text-warning">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
@ -393,7 +405,7 @@ const AppPublisher = ({
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={appURL}
|
||||
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
|
||||
icon={<RiPlayCircleLine className="h-4 w-4" />}
|
||||
>
|
||||
{t('common.runApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
@ -405,7 +417,7 @@ const AppPublisher = ({
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
|
||||
icon={<RiPlayList2Line className="h-4 w-4" />}
|
||||
>
|
||||
{t('common.batchRunApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
@ -431,7 +443,7 @@ const AppPublisher = ({
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
disabled={disabledFunctionButton}
|
||||
icon={<span className="i-ri-planet-line h-4 w-4" />}
|
||||
icon={<RiPlanetLine className="h-4 w-4" />}
|
||||
>
|
||||
{t('common.openInExplore', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
@ -441,7 +453,7 @@ const AppPublisher = ({
|
||||
className="flex-1"
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
link="./develop"
|
||||
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
|
||||
icon={<RiTerminalBoxLine className="h-4 w-4" />}
|
||||
>
|
||||
{t('common.accessAPIReference', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
|
||||
@ -248,7 +248,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await openAsyncWindow(async () => {
|
||||
const { installed_apps } = await fetchInstalledAppList(app.id)
|
||||
const { installed_apps }: any = await fetchInstalledAppList(app.id) || {}
|
||||
if (installed_apps?.length > 0)
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
throw new Error('No app found in Explore')
|
||||
@ -258,22 +258,21 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : `${e}`
|
||||
Toast.notify({ type: 'error', message })
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: `${e.message || e}` })
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="relative flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
|
||||
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickSettings}>
|
||||
<span className="text-text-secondary system-sm-regular">{t('editApp', { ns: 'app' })}</span>
|
||||
<span className="system-sm-regular text-text-secondary">{t('editApp', { ns: 'app' })}</span>
|
||||
</button>
|
||||
<Divider className="my-1" />
|
||||
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickDuplicate}>
|
||||
<span className="text-text-secondary system-sm-regular">{t('duplicate', { ns: 'app' })}</span>
|
||||
<span className="system-sm-regular text-text-secondary">{t('duplicate', { ns: 'app' })}</span>
|
||||
</button>
|
||||
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickExport}>
|
||||
<span className="text-text-secondary system-sm-regular">{t('export', { ns: 'app' })}</span>
|
||||
<span className="system-sm-regular text-text-secondary">{t('export', { ns: 'app' })}</span>
|
||||
</button>
|
||||
{(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && (
|
||||
<>
|
||||
@ -294,7 +293,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
<>
|
||||
<Divider className="my-1" />
|
||||
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickInstalledApp}>
|
||||
<span className="text-text-secondary system-sm-regular">{t('openInExplore', { ns: 'app' })}</span>
|
||||
<span className="system-sm-regular text-text-secondary">{t('openInExplore', { ns: 'app' })}</span>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
@ -302,7 +301,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
<>
|
||||
<Divider className="my-1" />
|
||||
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickInstalledApp}>
|
||||
<span className="text-text-secondary system-sm-regular">{t('openInExplore', { ns: 'app' })}</span>
|
||||
<span className="system-sm-regular text-text-secondary">{t('openInExplore', { ns: 'app' })}</span>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
@ -324,7 +323,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
className="group mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-destructive-hover"
|
||||
onClick={onClickDelete}
|
||||
>
|
||||
<span className="text-text-secondary system-sm-regular group-hover:text-text-destructive">
|
||||
<span className="system-sm-regular text-text-secondary group-hover:text-text-destructive">
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { CreateAppModalProps } from '../explore/create-app-modal'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import type { CurrentTryAppParams } from '@/context/explore-context'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEducationInit } from '@/app/education-apply/hooks'
|
||||
@ -20,13 +20,13 @@ const Apps = () => {
|
||||
useDocumentTitle(t('menus.apps', { ns: 'common' }))
|
||||
useEducationInit()
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<CurrentTryAppParams | undefined>(undefined)
|
||||
const currApp = currentTryAppParams?.app
|
||||
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
|
||||
const hideTryAppPanel = useCallback(() => {
|
||||
setIsShowTryAppPanel(false)
|
||||
}, [])
|
||||
const setShowTryAppPanel = (showTryAppPanel: boolean, params?: TryAppSelection) => {
|
||||
const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => {
|
||||
if (showTryAppPanel)
|
||||
setCurrentTryAppParams(params)
|
||||
else
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import type { CurrentTryAppParams } from '@/context/explore-context'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import Explore from '../index'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
@ -27,8 +32,9 @@ vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledApps: () => ({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: mockInstalledAppsData,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useUninstallApp: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
@ -42,31 +48,83 @@ vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const ContextReader = ({ triggerTryPanel }: { triggerTryPanel?: boolean }) => {
|
||||
const { hasEditPermission, setShowTryAppPanel, isShowTryAppPanel, currentApp } = useContext(ExploreContext)
|
||||
return (
|
||||
<div>
|
||||
{hasEditPermission ? 'edit-yes' : 'edit-no'}
|
||||
{isShowTryAppPanel && <span data-testid="try-panel-open">open</span>}
|
||||
{currentApp && <span data-testid="current-app">{currentApp.appId}</span>}
|
||||
{triggerTryPanel && (
|
||||
<>
|
||||
<button data-testid="show-try" onClick={() => setShowTryAppPanel(true, { appId: 'test-app' } as CurrentTryAppParams)}>show</button>
|
||||
<button data-testid="hide-try" onClick={() => setShowTryAppPanel(false)}>hide</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Explore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(useAppContext as Mock).mockReturnValue({
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render children', () => {
|
||||
it('should render children and provide edit permission from members role', async () => {
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({
|
||||
data: {
|
||||
accounts: [{ id: 'user-1', role: 'admin' }],
|
||||
},
|
||||
})
|
||||
|
||||
render((
|
||||
<Explore>
|
||||
<ContextReader />
|
||||
</Explore>
|
||||
))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('edit-yes')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effects', () => {
|
||||
it('should set document title on render', () => {
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
|
||||
|
||||
render((
|
||||
<Explore>
|
||||
<div>child</div>
|
||||
</Explore>
|
||||
))
|
||||
|
||||
expect(screen.getByText('child')).toBeInTheDocument()
|
||||
expect(useDocumentTitle).toHaveBeenCalledWith('common.menus.explore')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effects', () => {
|
||||
it('should redirect dataset operators to /datasets', async () => {
|
||||
;(useAppContext as Mock).mockReturnValue({
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: true,
|
||||
})
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
|
||||
|
||||
render((
|
||||
<Explore>
|
||||
@ -79,14 +137,68 @@ describe('Explore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should not redirect non dataset operators', () => {
|
||||
it('should skip permission check when membersData has no accounts', () => {
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({ data: undefined })
|
||||
|
||||
render((
|
||||
<Explore>
|
||||
<div>child</div>
|
||||
<ContextReader />
|
||||
</Explore>
|
||||
))
|
||||
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('edit-no')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Context: setShowTryAppPanel', () => {
|
||||
it('should set currentApp params when showing try panel', async () => {
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
|
||||
|
||||
render((
|
||||
<Explore>
|
||||
<ContextReader triggerTryPanel />
|
||||
</Explore>
|
||||
))
|
||||
|
||||
fireEvent.click(screen.getByTestId('show-try'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('try-panel-open')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('current-app')).toHaveTextContent('test-app')
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear currentApp params when hiding try panel', async () => {
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
|
||||
|
||||
render((
|
||||
<Explore>
|
||||
<ContextReader triggerTryPanel />
|
||||
</Explore>
|
||||
))
|
||||
|
||||
fireEvent.click(screen.getByTestId('show-try'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('try-panel-open')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('hide-try'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('try-panel-open')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('current-app')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,6 +2,7 @@ import type { AppCardProps } from '../index'
|
||||
import type { App } from '@/models/explore'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from '../index'
|
||||
|
||||
@ -40,14 +41,12 @@ const createApp = (overrides?: Partial<App>): App => ({
|
||||
|
||||
describe('AppCard', () => {
|
||||
const onCreate = vi.fn()
|
||||
const onTry = vi.fn()
|
||||
|
||||
const renderComponent = (props?: Partial<AppCardProps>) => {
|
||||
const mergedProps: AppCardProps = {
|
||||
app: createApp(),
|
||||
canCreate: false,
|
||||
onCreate,
|
||||
onTry,
|
||||
isExplore: false,
|
||||
...props,
|
||||
}
|
||||
@ -139,14 +138,31 @@ describe('AppCard', () => {
|
||||
expect(screen.getByText('Sample App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onTry when try button is clicked', () => {
|
||||
it('should call setShowTryAppPanel when try button is clicked', () => {
|
||||
const mockSetShowTryAppPanel = vi.fn()
|
||||
const app = createApp()
|
||||
|
||||
renderComponent({ app, canCreate: true, isExplore: true })
|
||||
render(
|
||||
<ExploreContext.Provider
|
||||
value={{
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission: false,
|
||||
installedApps: [],
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: false,
|
||||
setShowTryAppPanel: mockSetShowTryAppPanel,
|
||||
}}
|
||||
>
|
||||
<AppCard app={app} canCreate={true} onCreate={vi.fn()} isExplore={true} />
|
||||
</ExploreContext.Provider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('explore.appCard.try'))
|
||||
|
||||
expect(onTry).toHaveBeenCalledWith({ appId: 'app-id', app })
|
||||
expect(mockSetShowTryAppPanel).toHaveBeenCalledWith(true, { appId: 'app-id', app })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
'use client'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import { PlusIcon } from '@heroicons/react/20/solid'
|
||||
import { RiInformation2Line } from '@remixicon/react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@ -15,24 +17,25 @@ export type AppCardProps = {
|
||||
app: App
|
||||
canCreate: boolean
|
||||
onCreate: () => void
|
||||
onTry: (params: TryAppSelection) => void
|
||||
isExplore?: boolean
|
||||
isExplore: boolean
|
||||
}
|
||||
|
||||
const AppCard = ({
|
||||
app,
|
||||
canCreate,
|
||||
onCreate,
|
||||
onTry,
|
||||
isExplore = true,
|
||||
isExplore,
|
||||
}: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { app: appBasicInfo } = app
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
|
||||
const handleTryApp = () => {
|
||||
onTry({ appId: app.app_id, app })
|
||||
}
|
||||
const setShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.setShowTryAppPanel)
|
||||
const showTryAPPPanel = useCallback((appId: string) => {
|
||||
return () => {
|
||||
setShowTryAppPanel?.(true, { appId, app })
|
||||
}
|
||||
}, [setShowTryAppPanel, app])
|
||||
|
||||
return (
|
||||
<div className={cn('group relative col-span-1 flex cursor-pointer flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-2 shadow-sm transition-all duration-200 ease-in-out hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-lg')}>
|
||||
@ -64,7 +67,7 @@ const AppCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="description-wrapper h-[90px] px-[14px] text-text-tertiary system-xs-regular">
|
||||
<div className="description-wrapper system-xs-regular h-[90px] px-[14px] text-text-tertiary">
|
||||
<div className="line-clamp-4 group-hover:line-clamp-2">
|
||||
{app.description}
|
||||
</div>
|
||||
@ -80,7 +83,7 @@ const AppCard = ({
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<Button className="h-7" onClick={handleTryApp}>
|
||||
<Button className="h-7" onClick={showTryAPPPanel(app.app_id)}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { CurrentTryAppParams } from '@/context/explore-context'
|
||||
import type { App } from '@/models/explore'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppList from '../index'
|
||||
|
||||
@ -29,14 +29,6 @@ vi.mock('@/service/explore', () => ({
|
||||
fetchAppList: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-import-dsl', () => ({
|
||||
useImportDSL: () => ({
|
||||
handleImportDSL: mockHandleImportDSL,
|
||||
@ -119,22 +111,24 @@ const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
is_agent: overrides.is_agent ?? false,
|
||||
})
|
||||
|
||||
const mockMemberRole = (hasEditPermission: boolean) => {
|
||||
;(useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
})
|
||||
;(useMembers as Mock).mockReturnValue({
|
||||
data: {
|
||||
accounts: [{ id: 'user-1', role: hasEditPermission ? 'admin' : 'normal' }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const renderAppList = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record<string, string>) => {
|
||||
mockMemberRole(hasEditPermission)
|
||||
const renderWithContext = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record<string, string>) => {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<AppList onSuccess={onSuccess} />
|
||||
<ExploreContext.Provider
|
||||
value={{
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission,
|
||||
installedApps: [],
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: false,
|
||||
setShowTryAppPanel: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<AppList onSuccess={onSuccess} />
|
||||
</ExploreContext.Provider>
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
}
|
||||
@ -157,7 +151,7 @@ describe('AppList', () => {
|
||||
mockExploreData = undefined
|
||||
mockIsLoading = true
|
||||
|
||||
renderAppList()
|
||||
renderWithContext()
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
@ -168,7 +162,7 @@ describe('AppList', () => {
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
|
||||
}
|
||||
|
||||
renderAppList()
|
||||
renderWithContext()
|
||||
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||
@ -182,7 +176,7 @@ describe('AppList', () => {
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
|
||||
}
|
||||
|
||||
renderAppList(false, undefined, { category: 'Writing' })
|
||||
renderWithContext(false, undefined, { category: 'Writing' })
|
||||
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
|
||||
@ -195,7 +189,7 @@ describe('AppList', () => {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
|
||||
}
|
||||
renderAppList()
|
||||
renderWithContext()
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'gam' } })
|
||||
@ -223,7 +217,7 @@ describe('AppList', () => {
|
||||
options.onSuccess?.()
|
||||
})
|
||||
|
||||
renderAppList(true, onSuccess)
|
||||
renderWithContext(true, onSuccess)
|
||||
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
@ -247,7 +241,7 @@ describe('AppList', () => {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
|
||||
}
|
||||
renderAppList()
|
||||
renderWithContext()
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'gam' } })
|
||||
@ -269,7 +263,7 @@ describe('AppList', () => {
|
||||
mockIsError = true
|
||||
mockExploreData = undefined
|
||||
|
||||
const { container } = renderAppList()
|
||||
const { container } = renderWithContext()
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
@ -277,7 +271,7 @@ describe('AppList', () => {
|
||||
it('should render nothing when data is undefined', () => {
|
||||
mockExploreData = undefined
|
||||
|
||||
const { container } = renderAppList()
|
||||
const { container } = renderWithContext()
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
@ -287,7 +281,7 @@ describe('AppList', () => {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
|
||||
}
|
||||
renderAppList()
|
||||
renderWithContext()
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'gam' } })
|
||||
@ -310,7 +304,7 @@ describe('AppList', () => {
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' })
|
||||
|
||||
renderAppList(true)
|
||||
renderWithContext(true)
|
||||
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
|
||||
expect(await screen.findByTestId('create-app-modal')).toBeInTheDocument()
|
||||
|
||||
@ -331,7 +325,7 @@ describe('AppList', () => {
|
||||
options.onSuccess?.()
|
||||
})
|
||||
|
||||
renderAppList(true)
|
||||
renderWithContext(true)
|
||||
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
@ -351,7 +345,7 @@ describe('AppList', () => {
|
||||
options.onPending?.()
|
||||
})
|
||||
|
||||
renderAppList(true)
|
||||
renderWithContext(true)
|
||||
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
@ -368,16 +362,70 @@ describe('AppList', () => {
|
||||
|
||||
describe('TryApp Panel', () => {
|
||||
it('should open create modal from try app panel', async () => {
|
||||
vi.useRealTimers()
|
||||
const mockSetShowTryAppPanel = vi.fn()
|
||||
const app = createApp()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [app],
|
||||
}
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ExploreContext.Provider
|
||||
value={{
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission: true,
|
||||
installedApps: [],
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: true,
|
||||
setShowTryAppPanel: mockSetShowTryAppPanel,
|
||||
currentApp: { appId: 'app-1', app },
|
||||
}}
|
||||
>
|
||||
<AppList />
|
||||
</ExploreContext.Provider>
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
|
||||
const createBtn = screen.getByTestId('try-app-create')
|
||||
fireEvent.click(createBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should open create modal with null currApp when appParams has no app', async () => {
|
||||
vi.useRealTimers()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
|
||||
renderAppList(true)
|
||||
|
||||
fireEvent.click(screen.getByText('explore.appCard.try'))
|
||||
expect(screen.getByTestId('try-app-panel')).toBeInTheDocument()
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ExploreContext.Provider
|
||||
value={{
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission: true,
|
||||
installedApps: [],
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: true,
|
||||
setShowTryAppPanel: vi.fn(),
|
||||
currentApp: { appId: 'app-1' } as CurrentTryAppParams,
|
||||
}}
|
||||
>
|
||||
<AppList />
|
||||
</ExploreContext.Provider>
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('try-app-create'))
|
||||
|
||||
@ -386,19 +434,33 @@ describe('AppList', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should close try app panel when close is clicked', () => {
|
||||
it('should render try app panel with empty appId when currentApp is undefined', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
|
||||
renderAppList(true)
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ExploreContext.Provider
|
||||
value={{
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission: true,
|
||||
installedApps: [],
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: true,
|
||||
setShowTryAppPanel: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<AppList />
|
||||
</ExploreContext.Provider>
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('explore.appCard.try'))
|
||||
expect(screen.getByTestId('try-app-panel')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('try-app-close'))
|
||||
expect(screen.queryByTestId('try-app-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -415,7 +477,7 @@ describe('AppList', () => {
|
||||
allList: [createApp()],
|
||||
}
|
||||
|
||||
renderAppList()
|
||||
renderWithContext()
|
||||
|
||||
expect(screen.getByTestId('explore-banner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext, useContextSelector } from 'use-context-selector'
|
||||
import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
@ -16,14 +16,13 @@ import AppCard from '@/app/components/explore/app-card'
|
||||
import Banner from '@/app/components/explore/banner/banner'
|
||||
import Category from '@/app/components/explore/category'
|
||||
import CreateAppModal from '@/app/components/explore/create-app-modal'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useImportDSL } from '@/hooks/use-import-dsl'
|
||||
import {
|
||||
DSLImportMode,
|
||||
} from '@/models/app'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { useExploreAppList } from '@/service/use-explore'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import TryApp from '../try-app'
|
||||
@ -37,12 +36,9 @@ const Apps = ({
|
||||
onSuccess,
|
||||
}: AppsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile } = useAppContext()
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: membersData } = useMembers()
|
||||
const { hasEditPermission } = useContext(ExploreContext)
|
||||
const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' })
|
||||
const userAccount = membersData?.accounts?.find(account => account.id === userProfile.id)
|
||||
const hasEditPermission = !!userAccount && userAccount.role !== 'normal'
|
||||
|
||||
const [keywords, setKeywords] = useState('')
|
||||
const [searchKeywords, setSearchKeywords] = useState('')
|
||||
@ -89,8 +85,8 @@ const Apps = ({
|
||||
)
|
||||
}, [searchKeywords, filteredList])
|
||||
|
||||
const [currApp, setCurrApp] = useState<App | null>(null)
|
||||
const [isShowCreateModal, setIsShowCreateModal] = useState(false)
|
||||
const [currApp, setCurrApp] = React.useState<App | null>(null)
|
||||
const [isShowCreateModal, setIsShowCreateModal] = React.useState(false)
|
||||
|
||||
const {
|
||||
handleImportDSL,
|
||||
@ -100,18 +96,16 @@ const Apps = ({
|
||||
} = useImportDSL()
|
||||
const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false)
|
||||
|
||||
const [currentTryApp, setCurrentTryApp] = useState<TryAppSelection | undefined>(undefined)
|
||||
const isShowTryAppPanel = !!currentTryApp
|
||||
const isShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.isShowTryAppPanel)
|
||||
const setShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.setShowTryAppPanel)
|
||||
const hideTryAppPanel = useCallback(() => {
|
||||
setCurrentTryApp(undefined)
|
||||
}, [])
|
||||
const handleTryApp = useCallback((params: TryAppSelection) => {
|
||||
setCurrentTryApp(params)
|
||||
}, [])
|
||||
setShowTryAppPanel(false)
|
||||
}, [setShowTryAppPanel])
|
||||
const appParams = useContextSelector(ExploreContext, ctx => ctx.currentApp)
|
||||
const handleShowFromTryApp = useCallback(() => {
|
||||
setCurrApp(currentTryApp?.app || null)
|
||||
setCurrApp(appParams?.app || null)
|
||||
setIsShowCreateModal(true)
|
||||
}, [currentTryApp?.app])
|
||||
}, [appParams?.app])
|
||||
|
||||
const onCreate: CreateAppModalProps['onConfirm'] = async ({
|
||||
name,
|
||||
@ -181,7 +175,7 @@ const Apps = ({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="grow truncate text-text-primary system-xl-semibold">{!hasFilterCondition ? t('apps.title', { ns: 'explore' }) : t('apps.resultNum', { num: searchFilteredList.length, ns: 'explore' })}</div>
|
||||
<div className="system-xl-semibold grow truncate text-text-primary">{!hasFilterCondition ? t('apps.title', { ns: 'explore' }) : t('apps.resultNum', { num: searchFilteredList.length, ns: 'explore' })}</div>
|
||||
{hasFilterCondition && (
|
||||
<>
|
||||
<div className="mx-3 h-4 w-px bg-divider-regular"></div>
|
||||
@ -222,13 +216,13 @@ const Apps = ({
|
||||
{searchFilteredList.map(app => (
|
||||
<AppCard
|
||||
key={app.app_id}
|
||||
isExplore
|
||||
app={app}
|
||||
canCreate={hasEditPermission}
|
||||
onCreate={() => {
|
||||
setCurrApp(app)
|
||||
setIsShowCreateModal(true)
|
||||
}}
|
||||
onTry={handleTryApp}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
@ -261,9 +255,9 @@ const Apps = ({
|
||||
|
||||
{isShowTryAppPanel && (
|
||||
<TryApp
|
||||
appId={currentTryApp?.appId || ''}
|
||||
app={currentTryApp?.app}
|
||||
category={currentTryApp?.app?.category}
|
||||
appId={appParams?.appId || ''}
|
||||
app={appParams?.app}
|
||||
category={appParams?.app?.category}
|
||||
onClose={hideTryAppPanel}
|
||||
onCreate={handleShowFromTryApp}
|
||||
/>
|
||||
|
||||
@ -1,29 +1,80 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { CurrentTryAppParams } from '@/context/explore-context'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Sidebar from '@/app/components/explore/sidebar'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
|
||||
const Explore = ({
|
||||
children,
|
||||
}: {
|
||||
export type IExploreProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Explore: FC<IExploreProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
const [controlUpdateInstalledApps, setControlUpdateInstalledApps] = useState(0)
|
||||
const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
const [hasEditPermission, setHasEditPermission] = useState(false)
|
||||
const [installedApps, setInstalledApps] = useState<InstalledApp[]>([])
|
||||
const [isFetchingInstalledApps, setIsFetchingInstalledApps] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { data: membersData } = useMembers()
|
||||
|
||||
useDocumentTitle(t('menus.explore', { ns: 'common' }))
|
||||
|
||||
useEffect(() => {
|
||||
if (!membersData?.accounts)
|
||||
return
|
||||
const currUser = membersData.accounts.find(account => account.id === userProfile.id)
|
||||
setHasEditPermission(currUser?.role !== 'normal')
|
||||
}, [membersData, userProfile.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
router.replace('/datasets')
|
||||
}, [isCurrentWorkspaceDatasetOperator, router])
|
||||
return router.replace('/datasets')
|
||||
}, [isCurrentWorkspaceDatasetOperator])
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<CurrentTryAppParams | undefined>(undefined)
|
||||
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
|
||||
const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => {
|
||||
if (showTryAppPanel)
|
||||
setCurrentTryAppParams(params)
|
||||
else
|
||||
setCurrentTryAppParams(undefined)
|
||||
setIsShowTryAppPanel(showTryAppPanel)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden border-t border-divider-regular bg-background-body">
|
||||
<Sidebar />
|
||||
<div className="h-full min-h-0 w-0 grow">
|
||||
{children}
|
||||
</div>
|
||||
<ExploreContext.Provider
|
||||
value={
|
||||
{
|
||||
controlUpdateInstalledApps,
|
||||
setControlUpdateInstalledApps,
|
||||
hasEditPermission,
|
||||
installedApps,
|
||||
setInstalledApps,
|
||||
isFetchingInstalledApps,
|
||||
setIsFetchingInstalledApps,
|
||||
currentApp: currentTryAppParams,
|
||||
isShowTryAppPanel,
|
||||
setShowTryAppPanel,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Sidebar controlUpdateInstalledApps={controlUpdateInstalledApps} />
|
||||
<div className="h-full min-h-0 w-0 grow">
|
||||
{children}
|
||||
</div>
|
||||
</ExploreContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { InstalledApp as InstalledAppType } from '@/models/explore'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import InstalledApp from '../index'
|
||||
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: vi.fn(),
|
||||
createContext: vi.fn(() => ({})),
|
||||
}))
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: vi.fn(),
|
||||
}))
|
||||
@ -19,9 +24,28 @@ vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledAppAccessModeByAppId: vi.fn(),
|
||||
useGetInstalledAppParams: vi.fn(),
|
||||
useGetInstalledAppMeta: vi.fn(),
|
||||
useGetInstalledApps: vi.fn(),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Mock child components for unit testing
|
||||
*
|
||||
* RATIONALE FOR MOCKING:
|
||||
* - TextGenerationApp: 648 lines, complex batch processing, task management, file uploads
|
||||
* - ChatWithHistory: 576-line custom hook, complex conversation/history management, 30+ context values
|
||||
*
|
||||
* These components are too complex to test as real components. Using real components would:
|
||||
* 1. Require mocking dozens of their dependencies (services, contexts, hooks)
|
||||
* 2. Make tests fragile and coupled to child component implementation details
|
||||
* 3. Violate the principle of testing one component in isolation
|
||||
*
|
||||
* For a container component like InstalledApp, its responsibility is to:
|
||||
* - Correctly route to the appropriate child component based on app mode
|
||||
* - Pass the correct props to child components
|
||||
* - Handle loading/error states before rendering children
|
||||
*
|
||||
* The internal logic of ChatWithHistory and TextGenerationApp should be tested
|
||||
* in their own dedicated test files.
|
||||
*/
|
||||
vi.mock('@/app/components/share/text-generation', () => ({
|
||||
default: ({ isInstalledApp, installedAppInfo, isWorkflow }: {
|
||||
isInstalledApp?: boolean
|
||||
@ -91,29 +115,13 @@ describe('InstalledApp', () => {
|
||||
result: true,
|
||||
}
|
||||
|
||||
const setupMocks = (
|
||||
installedApps: InstalledAppType[] = [mockInstalledApp],
|
||||
options: {
|
||||
isPending?: boolean
|
||||
isFetching?: boolean
|
||||
} = {},
|
||||
) => {
|
||||
const {
|
||||
isPending = false,
|
||||
isFetching = false,
|
||||
} = options
|
||||
|
||||
;(useGetInstalledApps as Mock).mockReturnValue({
|
||||
data: { installed_apps: installedApps },
|
||||
isPending,
|
||||
isFetching,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
setupMocks()
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [mockInstalledApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
;(useWebAppStore as unknown as Mock).mockImplementation((
|
||||
selector: (state: {
|
||||
@ -135,19 +143,19 @@ describe('InstalledApp', () => {
|
||||
})
|
||||
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: mockWebAppAccessMode,
|
||||
error: null,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: mockAppParams,
|
||||
error: null,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: mockAppMeta,
|
||||
error: null,
|
||||
})
|
||||
@ -166,7 +174,7 @@ describe('InstalledApp', () => {
|
||||
|
||||
it('should render loading state when fetching app params', () => {
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: true,
|
||||
isFetching: true,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
@ -178,7 +186,7 @@ describe('InstalledApp', () => {
|
||||
|
||||
it('should render loading state when fetching app meta', () => {
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isPending: true,
|
||||
isFetching: true,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
@ -190,7 +198,7 @@ describe('InstalledApp', () => {
|
||||
|
||||
it('should render loading state when fetching web app access mode', () => {
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isPending: true,
|
||||
isFetching: true,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
@ -201,7 +209,10 @@ describe('InstalledApp', () => {
|
||||
})
|
||||
|
||||
it('should render loading state when fetching installed apps', () => {
|
||||
setupMocks([mockInstalledApp], { isPending: true })
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [mockInstalledApp],
|
||||
isFetchingInstalledApps: true,
|
||||
})
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-123" />)
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
@ -209,7 +220,10 @@ describe('InstalledApp', () => {
|
||||
})
|
||||
|
||||
it('should render app not found (404) when installedApp does not exist', () => {
|
||||
setupMocks([])
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
@ -220,7 +234,7 @@ describe('InstalledApp', () => {
|
||||
it('should render error when app params fails to load', () => {
|
||||
const error = new Error('Failed to load app params')
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error,
|
||||
})
|
||||
@ -232,7 +246,7 @@ describe('InstalledApp', () => {
|
||||
it('should render error when app meta fails to load', () => {
|
||||
const error = new Error('Failed to load app meta')
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error,
|
||||
})
|
||||
@ -244,7 +258,7 @@ describe('InstalledApp', () => {
|
||||
it('should render error when web app access mode fails to load', () => {
|
||||
const error = new Error('Failed to load access mode')
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error,
|
||||
})
|
||||
@ -291,7 +305,10 @@ describe('InstalledApp', () => {
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
},
|
||||
}
|
||||
setupMocks([advancedChatApp])
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [advancedChatApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
|
||||
@ -306,7 +323,10 @@ describe('InstalledApp', () => {
|
||||
mode: AppModeEnum.AGENT_CHAT,
|
||||
},
|
||||
}
|
||||
setupMocks([agentChatApp])
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [agentChatApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
|
||||
@ -321,7 +341,10 @@ describe('InstalledApp', () => {
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
},
|
||||
}
|
||||
setupMocks([completionApp])
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [completionApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Text Generation App/i)).toBeInTheDocument()
|
||||
@ -336,7 +359,10 @@ describe('InstalledApp', () => {
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
}
|
||||
setupMocks([workflowApp])
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [workflowApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Text Generation App/i)).toBeInTheDocument()
|
||||
@ -348,7 +374,10 @@ describe('InstalledApp', () => {
|
||||
it('should use id prop to find installed app', () => {
|
||||
const app1 = { ...mockInstalledApp, id: 'app-1' }
|
||||
const app2 = { ...mockInstalledApp, id: 'app-2' }
|
||||
setupMocks([app1, app2])
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [app1, app2],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="app-2" />)
|
||||
expect(screen.getByText(/app-2/)).toBeInTheDocument()
|
||||
@ -387,7 +416,10 @@ describe('InstalledApp', () => {
|
||||
})
|
||||
|
||||
it('should update app info to null when installedApp is not found', async () => {
|
||||
setupMocks([])
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
|
||||
@ -456,7 +488,7 @@ describe('InstalledApp', () => {
|
||||
|
||||
it('should not update app params when data is null', async () => {
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
@ -472,7 +504,7 @@ describe('InstalledApp', () => {
|
||||
|
||||
it('should not update app meta when data is null', async () => {
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
@ -488,7 +520,7 @@ describe('InstalledApp', () => {
|
||||
|
||||
it('should not update access mode when data is null', async () => {
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
@ -505,7 +537,10 @@ describe('InstalledApp', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty installedApps array', () => {
|
||||
setupMocks([])
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
@ -520,7 +555,10 @@ describe('InstalledApp', () => {
|
||||
name: 'Other App',
|
||||
},
|
||||
}
|
||||
setupMocks([otherApp, mockInstalledApp])
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [otherApp, mockInstalledApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
|
||||
@ -530,7 +568,10 @@ describe('InstalledApp', () => {
|
||||
it('should handle rapid id prop changes', async () => {
|
||||
const app1 = { ...mockInstalledApp, id: 'app-1' }
|
||||
const app2 = { ...mockInstalledApp, id: 'app-2' }
|
||||
setupMocks([app1, app2])
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [app1, app2],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
const { rerender } = render(<InstalledApp id="app-1" />)
|
||||
expect(screen.getByText(/app-1/)).toBeInTheDocument()
|
||||
@ -552,7 +593,10 @@ describe('InstalledApp', () => {
|
||||
})
|
||||
|
||||
it('should call service hooks with null when installedApp is not found', () => {
|
||||
setupMocks([])
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
|
||||
@ -569,7 +613,7 @@ describe('InstalledApp', () => {
|
||||
describe('Render Priority', () => {
|
||||
it('should show error before loading state', () => {
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: true,
|
||||
isFetching: true,
|
||||
data: null,
|
||||
error: new Error('Some error'),
|
||||
})
|
||||
@ -580,7 +624,7 @@ describe('InstalledApp', () => {
|
||||
|
||||
it('should show error before permission check', () => {
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error: new Error('Params error'),
|
||||
})
|
||||
@ -595,7 +639,10 @@ describe('InstalledApp', () => {
|
||||
})
|
||||
|
||||
it('should show permission error before 404', () => {
|
||||
setupMocks([])
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: { result: false },
|
||||
error: null,
|
||||
@ -606,8 +653,16 @@ describe('InstalledApp', () => {
|
||||
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading before 404 while installed apps are refetching', () => {
|
||||
setupMocks([], { isFetching: true })
|
||||
it('should show loading before 404', () => {
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isFetching: true,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { container } = render(<InstalledApp id="nonexistent-app" />)
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
|
||||
@ -1,32 +1,37 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import type { AppData } from '@/models/share'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import TextGenerationApp from '@/app/components/share/text-generation'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppUnavailable from '../../base/app-unavailable'
|
||||
|
||||
const InstalledApp = ({
|
||||
id,
|
||||
}: {
|
||||
export type IInstalledAppProps = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const InstalledApp: FC<IInstalledAppProps> = ({
|
||||
id,
|
||||
}) => {
|
||||
const { data, isPending: isPendingInstalledApps, isFetching: isFetchingInstalledApps } = useGetInstalledApps()
|
||||
const installedApp = data?.installed_apps?.find(item => item.id === id)
|
||||
const { installedApps, isFetchingInstalledApps } = useContext(ExploreContext)
|
||||
const updateAppInfo = useWebAppStore(s => s.updateAppInfo)
|
||||
const installedApp = installedApps.find(item => item.id === id)
|
||||
const updateWebAppAccessMode = useWebAppStore(s => s.updateWebAppAccessMode)
|
||||
const updateAppParams = useWebAppStore(s => s.updateAppParams)
|
||||
const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta)
|
||||
const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp)
|
||||
const { isPending: isPendingWebAppAccessMode, data: webAppAccessMode, error: webAppAccessModeError } = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null)
|
||||
const { isPending: isPendingAppParams, data: appParams, error: appParamsError } = useGetInstalledAppParams(installedApp?.id ?? null)
|
||||
const { isPending: isPendingAppMeta, data: appMeta, error: appMetaError } = useGetInstalledAppMeta(installedApp?.id ?? null)
|
||||
const { isFetching: isFetchingWebAppAccessMode, data: webAppAccessMode, error: webAppAccessModeError } = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null)
|
||||
const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetInstalledAppParams(installedApp?.id ?? null)
|
||||
const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetInstalledAppMeta(installedApp?.id ?? null)
|
||||
const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: installedApp?.app.id, isInstalledApp: true })
|
||||
|
||||
useEffect(() => {
|
||||
@ -97,11 +102,7 @@ const InstalledApp = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (
|
||||
isPendingInstalledApps
|
||||
|| (!installedApp && isFetchingInstalledApps)
|
||||
|| (installedApp && (isPendingAppParams || isPendingAppMeta || isPendingWebAppAccessMode))
|
||||
) {
|
||||
if (isFetchingAppParams || isFetchingAppMeta || isFetchingWebAppAccessMode || isFetchingInstalledApps) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loading />
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import type { IExplore } from '@/context/explore-context'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import SideBar from '../index'
|
||||
|
||||
const mockSegments = ['apps']
|
||||
const mockPush = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUninstall = vi.fn()
|
||||
const mockUpdatePinStatus = vi.fn()
|
||||
let mockIsPending = false
|
||||
let mockIsFetching = false
|
||||
let mockInstalledApps: InstalledApp[] = []
|
||||
let mockMediaType: string = MediaType.pc
|
||||
|
||||
@ -31,8 +34,9 @@ vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledApps: () => ({
|
||||
isPending: mockIsPending,
|
||||
isFetching: mockIsFetching,
|
||||
data: { installed_apps: mockInstalledApps },
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useUninstallApp: () => ({
|
||||
mutateAsync: mockUninstall,
|
||||
@ -59,14 +63,28 @@ const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp
|
||||
},
|
||||
})
|
||||
|
||||
const renderSideBar = () => {
|
||||
return render(<SideBar />)
|
||||
const renderWithContext = (installedApps: InstalledApp[] = []) => {
|
||||
return render(
|
||||
<ExploreContext.Provider
|
||||
value={{
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission: true,
|
||||
installedApps,
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
} as unknown as IExplore}
|
||||
>
|
||||
<SideBar controlUpdateInstalledApps={0} />
|
||||
</ExploreContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SideBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsPending = false
|
||||
mockIsFetching = false
|
||||
mockInstalledApps = []
|
||||
mockMediaType = MediaType.pc
|
||||
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||
@ -74,38 +92,31 @@ describe('SideBar', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render discovery link', () => {
|
||||
renderSideBar()
|
||||
renderWithContext()
|
||||
|
||||
expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workspace items when installed apps exist', () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderSideBar()
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument()
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render NoApps component when no installed apps on desktop', () => {
|
||||
renderSideBar()
|
||||
renderWithContext([])
|
||||
|
||||
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render NoApps while loading', () => {
|
||||
mockIsPending = true
|
||||
renderSideBar()
|
||||
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple installed apps', () => {
|
||||
mockInstalledApps = [
|
||||
createInstalledApp({ id: 'app-1', app: { ...createInstalledApp().app, name: 'Alpha' } }),
|
||||
createInstalledApp({ id: 'app-2', app: { ...createInstalledApp().app, name: 'Beta' } }),
|
||||
]
|
||||
renderSideBar()
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||
@ -116,18 +127,27 @@ describe('SideBar', () => {
|
||||
createInstalledApp({ id: 'app-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned' } }),
|
||||
createInstalledApp({ id: 'app-2', is_pinned: false, app: { ...createInstalledApp().app, name: 'Unpinned' } }),
|
||||
]
|
||||
const { container } = renderSideBar()
|
||||
const { container } = renderWithContext(mockInstalledApps)
|
||||
|
||||
const dividers = container.querySelectorAll('[class*="divider"], hr')
|
||||
expect(dividers.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effects', () => {
|
||||
it('should refetch installed apps on mount', () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should uninstall app and show toast when delete is confirmed', async () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
mockUninstall.mockResolvedValue(undefined)
|
||||
renderSideBar()
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
@ -145,7 +165,7 @@ describe('SideBar', () => {
|
||||
it('should update pin status and show toast when pin is clicked', async () => {
|
||||
mockInstalledApps = [createInstalledApp({ is_pinned: false })]
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
renderSideBar()
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
@ -162,7 +182,7 @@ describe('SideBar', () => {
|
||||
it('should unpin an already pinned app', async () => {
|
||||
mockInstalledApps = [createInstalledApp({ is_pinned: true })]
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
renderSideBar()
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.unpin'))
|
||||
@ -174,7 +194,7 @@ describe('SideBar', () => {
|
||||
|
||||
it('should open and close confirm dialog for delete', async () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderSideBar()
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
@ -192,7 +212,7 @@ describe('SideBar', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('should hide NoApps and app names on mobile', () => {
|
||||
mockMediaType = MediaType.mobile
|
||||
renderSideBar()
|
||||
renderWithContext([])
|
||||
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.sidebar.webApps')).not.toBeInTheDocument()
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiAppsFill, RiExpandRightLine, RiLayoutLeft2Line } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import Link from 'next/link'
|
||||
import { useSelectedLayoutSegments } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@ -14,13 +18,19 @@ import Toast from '../../base/toast'
|
||||
import Item from './app-nav-item'
|
||||
import NoApps from './no-apps'
|
||||
|
||||
const SideBar = () => {
|
||||
export type IExploreSideBarProps = {
|
||||
controlUpdateInstalledApps: number
|
||||
}
|
||||
|
||||
const SideBar: FC<IExploreSideBarProps> = ({
|
||||
controlUpdateInstalledApps,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const segments = useSelectedLayoutSegments()
|
||||
const lastSegment = segments.slice(-1)[0]
|
||||
const isDiscoverySelected = lastSegment === 'apps'
|
||||
const { data, isPending } = useGetInstalledApps()
|
||||
const installedApps = data?.installed_apps ?? []
|
||||
const { installedApps, setInstalledApps, setIsFetchingInstalledApps } = useContext(ExploreContext)
|
||||
const { isFetching: isFetchingInstalledApps, data: ret, refetch: fetchInstalledAppList } = useGetInstalledApps()
|
||||
const { mutateAsync: uninstallApp } = useUninstallApp()
|
||||
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
|
||||
|
||||
@ -50,6 +60,22 @@ const SideBar = () => {
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const installed_apps = (ret as any)?.installed_apps
|
||||
if (installed_apps && installed_apps.length > 0)
|
||||
setInstalledApps(installed_apps)
|
||||
else
|
||||
setInstalledApps([])
|
||||
}, [ret, setInstalledApps])
|
||||
|
||||
useEffect(() => {
|
||||
setIsFetchingInstalledApps(isFetchingInstalledApps)
|
||||
}, [isFetchingInstalledApps, setIsFetchingInstalledApps])
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstalledAppList()
|
||||
}, [controlUpdateInstalledApps, fetchInstalledAppList])
|
||||
|
||||
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
|
||||
return (
|
||||
<div className={cn('relative w-fit shrink-0 cursor-pointer px-3 pt-6 sm:w-[240px]', isFold && 'sm:w-[56px]')}>
|
||||
@ -59,13 +85,13 @@ const SideBar = () => {
|
||||
className={cn(isDiscoverySelected ? 'bg-state-base-active' : 'hover:bg-state-base-hover', 'flex h-8 items-center gap-2 rounded-lg px-1 mobile:w-fit mobile:justify-center pc:w-full pc:justify-start')}
|
||||
>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid">
|
||||
<span className="i-ri-apps-fill size-3.5 text-components-avatar-shape-fill-stop-100" />
|
||||
<RiAppsFill className="size-3.5 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
{!isMobile && !isFold && <div className={cn('truncate', isDiscoverySelected ? 'text-components-menu-item-text-active system-sm-semibold' : 'text-components-menu-item-text system-sm-regular')}>{t('sidebar.title', { ns: 'explore' })}</div>}
|
||||
{!isMobile && !isFold && <div className={cn('truncate', isDiscoverySelected ? 'system-sm-semibold text-components-menu-item-text-active' : 'system-sm-regular text-components-menu-item-text')}>{t('sidebar.title', { ns: 'explore' })}</div>}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!isPending && installedApps.length === 0 && !isMobile && !isFold
|
||||
{installedApps.length === 0 && !isMobile && !isFold
|
||||
&& (
|
||||
<div className="mt-5">
|
||||
<NoApps />
|
||||
@ -74,7 +100,7 @@ const SideBar = () => {
|
||||
|
||||
{installedApps.length > 0 && (
|
||||
<div className="mt-5">
|
||||
{!isMobile && !isFold && <p className="mb-1.5 break-all pl-2 uppercase text-text-tertiary system-xs-medium-uppercase mobile:px-0">{t('sidebar.webApps', { ns: 'explore' })}</p>}
|
||||
{!isMobile && !isFold && <p className="system-xs-medium-uppercase mb-1.5 break-all pl-2 uppercase text-text-tertiary mobile:px-0">{t('sidebar.webApps', { ns: 'explore' })}</p>}
|
||||
<div
|
||||
className="space-y-0.5 overflow-y-auto overflow-x-hidden"
|
||||
style={{
|
||||
@ -110,9 +136,9 @@ const SideBar = () => {
|
||||
{!isMobile && (
|
||||
<div className="absolute bottom-3 left-3 flex size-8 cursor-pointer items-center justify-center text-text-tertiary" onClick={toggleIsFold}>
|
||||
{isFold
|
||||
? <span className="i-ri-expand-right-line" />
|
||||
? <RiExpandRightLine className="size-4.5" />
|
||||
: (
|
||||
<span className="i-ri-layout-left-2-line" />
|
||||
<RiLayoutLeft2Line className="size-4.5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppInfo from '../index'
|
||||
|
||||
@ -11,21 +9,6 @@ vi.mock('../use-get-requirements', () => ({
|
||||
default: (...args: unknown[]) => mockUseGetRequirements(...args),
|
||||
}))
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({
|
||||
src,
|
||||
alt,
|
||||
unoptimized: _unoptimized,
|
||||
...rest
|
||||
}: {
|
||||
src: string
|
||||
alt: string
|
||||
unoptimized?: boolean
|
||||
} & ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
React.createElement('img', { src, alt, ...rest })
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({
|
||||
id: 'test-app-id',
|
||||
name: 'Test App Name',
|
||||
@ -329,7 +312,7 @@ describe('AppInfo', () => {
|
||||
expect(screen.queryByText('explore.tryApp.requirements')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders requirement icons with correct image src', () => {
|
||||
it('renders requirement icons with correct background image', () => {
|
||||
mockUseGetRequirements.mockReturnValue({
|
||||
requirements: [
|
||||
{ name: 'Test Tool', iconUrl: 'https://example.com/test-icon.png' },
|
||||
@ -347,36 +330,9 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const iconElement = container.querySelector('img[src="https://example.com/test-icon.png"]')
|
||||
const iconElement = container.querySelector('[style*="background-image"]')
|
||||
expect(iconElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to default icon when requirement image fails to load', () => {
|
||||
mockUseGetRequirements.mockReturnValue({
|
||||
requirements: [
|
||||
{ name: 'Broken Tool', iconUrl: 'https://example.com/broken-icon.png' },
|
||||
],
|
||||
})
|
||||
|
||||
const appDetail = createMockAppDetail('chat')
|
||||
const mockOnCreate = vi.fn()
|
||||
|
||||
render(
|
||||
<AppInfo
|
||||
appId="test-app-id"
|
||||
appDetail={appDetail}
|
||||
onCreate={mockOnCreate}
|
||||
/>,
|
||||
)
|
||||
|
||||
const requirementRow = screen.getByText('Broken Tool').parentElement as HTMLElement
|
||||
const iconImage = requirementRow.querySelector('img') as HTMLImageElement
|
||||
expect(iconImage).toBeInTheDocument()
|
||||
|
||||
fireEvent.error(iconImage)
|
||||
|
||||
expect(requirementRow.querySelector('img')).not.toBeInTheDocument()
|
||||
expect(requirementRow.querySelector('.i-custom-public-other-default-tool-icon')).toBeInTheDocument()
|
||||
expect(iconElement).toHaveStyle({ backgroundImage: 'url(https://example.com/test-icon.png)' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -400,61 +400,6 @@ describe('useGetRequirements', () => {
|
||||
|
||||
expect(result.current.requirements[0].iconUrl).toBe('https://marketplace.api/plugins/org/plugin/icon')
|
||||
})
|
||||
|
||||
it('maps google model provider to gemini plugin icon URL', () => {
|
||||
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
|
||||
|
||||
const appDetail = createMockAppDetail('chat', {
|
||||
model_config: {
|
||||
model: {
|
||||
provider: 'langgenius/google/google',
|
||||
name: 'gemini-2.0',
|
||||
mode: 'chat',
|
||||
},
|
||||
dataset_configs: { datasets: { datasets: [] } },
|
||||
agent_mode: { tools: [] },
|
||||
user_input_form: [],
|
||||
},
|
||||
} as unknown as Partial<TryAppInfo>)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGetRequirements({ appDetail, appId: 'test-app-id' }),
|
||||
)
|
||||
|
||||
expect(result.current.requirements[0].iconUrl).toBe('https://marketplace.api/plugins/langgenius/gemini/icon')
|
||||
})
|
||||
|
||||
it('maps special builtin tool providers to *_tool plugin icon URL', () => {
|
||||
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
|
||||
|
||||
const appDetail = createMockAppDetail('agent-chat', {
|
||||
model_config: {
|
||||
model: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
name: 'gpt-4',
|
||||
mode: 'chat',
|
||||
},
|
||||
dataset_configs: { datasets: { datasets: [] } },
|
||||
agent_mode: {
|
||||
tools: [
|
||||
{
|
||||
enabled: true,
|
||||
provider_id: 'langgenius/jina/jina',
|
||||
tool_label: 'Jina Search',
|
||||
},
|
||||
],
|
||||
},
|
||||
user_input_form: [],
|
||||
},
|
||||
} as unknown as Partial<TryAppInfo>)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGetRequirements({ appDetail, appId: 'test-app-id' }),
|
||||
)
|
||||
|
||||
const toolRequirement = result.current.requirements.find(item => item.name === 'Jina Search')
|
||||
expect(toolRequirement?.iconUrl).toBe('https://marketplace.api/plugins/langgenius/jina_tool/icon')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hook calls', () => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import Image from 'next/image'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AppTypeIcon } from '@/app/components/app/type-selector'
|
||||
@ -19,37 +19,6 @@ type Props = {
|
||||
}
|
||||
|
||||
const headerClassName = 'system-sm-semibold-uppercase text-text-secondary mb-3'
|
||||
const requirementIconSize = 20
|
||||
|
||||
type RequirementIconProps = {
|
||||
iconUrl: string
|
||||
}
|
||||
|
||||
const RequirementIcon: FC<RequirementIconProps> = ({ iconUrl }) => {
|
||||
const [failedSource, setFailedSource] = React.useState<string | null>(null)
|
||||
const hasLoadError = !iconUrl || failedSource === iconUrl
|
||||
|
||||
if (hasLoadError) {
|
||||
return (
|
||||
<div className="flex size-5 items-center justify-center overflow-hidden rounded-[6px] border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
|
||||
<div className="i-custom-public-other-default-tool-icon size-3 text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
className="size-5 rounded-md object-cover shadow-xs"
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={requirementIconSize}
|
||||
height={requirementIconSize}
|
||||
unoptimized
|
||||
onError={() => setFailedSource(iconUrl)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AppInfo: FC<Props> = ({
|
||||
appId,
|
||||
@ -93,17 +62,17 @@ const AppInfo: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
{appDetail.description && (
|
||||
<div className="mt-[14px] shrink-0 text-text-secondary system-sm-regular">{appDetail.description}</div>
|
||||
<div className="system-sm-regular mt-[14px] shrink-0 text-text-secondary">{appDetail.description}</div>
|
||||
)}
|
||||
<Button variant="primary" className="mt-3 flex w-full max-w-full" onClick={onCreate}>
|
||||
<span className="i-ri-add-line mr-1 size-4 shrink-0" />
|
||||
<RiAddLine className="mr-1 size-4 shrink-0" />
|
||||
<span className="truncate">{t('tryApp.createFromSampleApp', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
|
||||
{category && (
|
||||
<div className="mt-6 shrink-0">
|
||||
<div className={headerClassName}>{t('tryApp.category', { ns: 'explore' })}</div>
|
||||
<div className="text-text-secondary system-md-regular">{category}</div>
|
||||
<div className="system-md-regular text-text-secondary">{category}</div>
|
||||
</div>
|
||||
)}
|
||||
{requirements.length > 0 && (
|
||||
@ -112,8 +81,8 @@ const AppInfo: FC<Props> = ({
|
||||
<div className="space-y-0.5">
|
||||
{requirements.map(item => (
|
||||
<div className="flex items-center space-x-2 py-1" key={item.name}>
|
||||
<RequirementIcon iconUrl={item.iconUrl} />
|
||||
<div className="w-0 grow truncate text-text-secondary system-md-regular">{item.name}</div>
|
||||
<div className="size-5 rounded-md bg-cover shadow-xs" style={{ backgroundImage: `url(${item.iconUrl})` }} />
|
||||
<div className="system-md-regular w-0 grow truncate text-text-secondary">{item.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -16,56 +16,8 @@ type RequirementItem = {
|
||||
name: string
|
||||
iconUrl: string
|
||||
}
|
||||
|
||||
type ProviderType = 'model' | 'tool'
|
||||
|
||||
type ProviderInfo = {
|
||||
organization: string
|
||||
providerName: string
|
||||
}
|
||||
|
||||
const PROVIDER_PLUGIN_ALIASES: Record<ProviderType, Record<string, string>> = {
|
||||
model: {
|
||||
google: 'gemini',
|
||||
},
|
||||
tool: {
|
||||
stepfun: 'stepfun_tool',
|
||||
jina: 'jina_tool',
|
||||
siliconflow: 'siliconflow_tool',
|
||||
gitee_ai: 'gitee_ai_tool',
|
||||
},
|
||||
}
|
||||
|
||||
const parseProviderId = (providerId: string): ProviderInfo | null => {
|
||||
const segments = providerId.split('/').filter(Boolean)
|
||||
if (!segments.length)
|
||||
return null
|
||||
|
||||
if (segments.length === 1) {
|
||||
return {
|
||||
organization: 'langgenius',
|
||||
providerName: segments[0],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
organization: segments[0],
|
||||
providerName: segments[1],
|
||||
}
|
||||
}
|
||||
|
||||
const getPluginName = (providerName: string, type: ProviderType) => {
|
||||
return PROVIDER_PLUGIN_ALIASES[type][providerName] || providerName
|
||||
}
|
||||
|
||||
const getIconUrl = (providerId: string, type: ProviderType) => {
|
||||
const parsed = parseProviderId(providerId)
|
||||
if (!parsed)
|
||||
return ''
|
||||
|
||||
const organization = encodeURIComponent(parsed.organization)
|
||||
const pluginName = encodeURIComponent(getPluginName(parsed.providerName, type))
|
||||
return `${MARKETPLACE_API_PREFIX}/plugins/${organization}/${pluginName}/icon`
|
||||
const getIconUrl = (provider: string, tool: string) => {
|
||||
return `${MARKETPLACE_API_PREFIX}/plugins/${provider}/${tool}/icon`
|
||||
}
|
||||
|
||||
const useGetRequirements = ({ appDetail, appId }: Params) => {
|
||||
@ -76,19 +28,20 @@ const useGetRequirements = ({ appDetail, appId }: Params) => {
|
||||
|
||||
const requirements: RequirementItem[] = []
|
||||
if (isBasic) {
|
||||
const modelProvider = appDetail.model_config.model.provider
|
||||
const modelProviderAndName = appDetail.model_config.model.provider.split('/')
|
||||
const name = appDetail.model_config.model.provider.split('/').pop() || ''
|
||||
requirements.push({
|
||||
name,
|
||||
iconUrl: getIconUrl(modelProvider, 'model'),
|
||||
iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]),
|
||||
})
|
||||
}
|
||||
if (isAgent) {
|
||||
requirements.push(...appDetail.model_config.agent_mode.tools.filter(data => (data as AgentTool).enabled).map((data) => {
|
||||
const tool = data as AgentTool
|
||||
const modelProviderAndName = tool.provider_id.split('/')
|
||||
return {
|
||||
name: tool.tool_label,
|
||||
iconUrl: getIconUrl(tool.provider_id, 'tool'),
|
||||
iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]),
|
||||
}
|
||||
}))
|
||||
}
|
||||
@ -97,18 +50,20 @@ const useGetRequirements = ({ appDetail, appId }: Params) => {
|
||||
const llmNodes = nodes.filter(node => node.data.type === BlockEnum.LLM)
|
||||
requirements.push(...llmNodes.map((node) => {
|
||||
const data = node.data as LLMNodeType
|
||||
const modelProviderAndName = data.model.provider.split('/')
|
||||
return {
|
||||
name: data.model.name,
|
||||
iconUrl: getIconUrl(data.model.provider, 'model'),
|
||||
iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]),
|
||||
}
|
||||
}))
|
||||
|
||||
const toolNodes = nodes.filter(node => node.data.type === BlockEnum.Tool)
|
||||
requirements.push(...toolNodes.map((node) => {
|
||||
const data = node.data as ToolNodeType
|
||||
const toolProviderAndName = data.provider_id.split('/')
|
||||
return {
|
||||
name: data.tool_label,
|
||||
iconUrl: getIconUrl(data.provider_id, 'tool'),
|
||||
iconUrl: getIconUrl(toolProviderAndName[0], toolProviderAndName[1]),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@ -2,12 +2,11 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { App as AppType } from '@/models/explore'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Modal from '@/app/components/base/modal/index'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useGetTryAppInfo } from '@/service/use-try-app'
|
||||
import Button from '../../base/button'
|
||||
@ -33,10 +32,15 @@ const TryApp: FC<Props> = ({
|
||||
}) => {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app)
|
||||
const canUseTryTab = IS_CLOUD_EDITION && (app ? isTrialApp : true)
|
||||
const [type, setType] = useState<TypeEnum>(() => (canUseTryTab ? TypeEnum.TRY : TypeEnum.DETAIL))
|
||||
const activeType = canUseTryTab ? type : TypeEnum.DETAIL
|
||||
const { data: appDetail, isLoading, isError, error } = useGetTryAppInfo(appId)
|
||||
const [type, setType] = useState<TypeEnum>(() => (app && !isTrialApp ? TypeEnum.DETAIL : TypeEnum.TRY))
|
||||
const { data: appDetail, isLoading } = useGetTryAppInfo(appId)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (app && !isTrialApp && type !== TypeEnum.DETAIL)
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setType(TypeEnum.DETAIL)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [app, isTrialApp])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -48,19 +52,11 @@ const TryApp: FC<Props> = ({
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<AppUnavailable className="h-auto w-auto" isUnknownReason={!error} unknownReason={error instanceof Error ? error.message : undefined} />
|
||||
</div>
|
||||
) : !appDetail ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<AppUnavailable className="h-auto w-auto" isUnknownReason />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 justify-between pl-4">
|
||||
<Tab
|
||||
value={activeType}
|
||||
value={type}
|
||||
onChange={setType}
|
||||
disableTry={app ? !isTrialApp : false}
|
||||
/>
|
||||
@ -70,15 +66,15 @@ const TryApp: FC<Props> = ({
|
||||
className="flex size-7 items-center justify-center rounded-[10px] p-0 text-components-button-tertiary-text"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span className="i-ri-close-line size-5" />
|
||||
<RiCloseLine className="size-5" onClick={onClose} />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Main content */}
|
||||
<div className="mt-2 flex h-0 grow justify-between space-x-2">
|
||||
{activeType === TypeEnum.TRY ? <App appId={appId} appDetail={appDetail} /> : <Preview appId={appId} appDetail={appDetail} />}
|
||||
{type === TypeEnum.TRY ? <App appId={appId} appDetail={appDetail!} /> : <Preview appId={appId} appDetail={appDetail!} />}
|
||||
<AppInfo
|
||||
className="w-[360px] shrink-0"
|
||||
appDetail={appDetail}
|
||||
appDetail={appDetail!}
|
||||
appId={appId}
|
||||
category={category}
|
||||
onCreate={onCreate}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { SetTryAppPanel, TryAppSelection } from '@/types/try-app'
|
||||
import type { CurrentTryAppParams } from './explore-context'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { createContext } from 'use-context-selector'
|
||||
|
||||
type Props = {
|
||||
currentApp?: TryAppSelection
|
||||
currentApp?: CurrentTryAppParams
|
||||
isShowTryAppPanel: boolean
|
||||
setShowTryAppPanel: SetTryAppPanel
|
||||
setShowTryAppPanel: (showTryAppPanel: boolean, params?: CurrentTryAppParams) => void
|
||||
controlHideCreateFromTemplatePanel: number
|
||||
}
|
||||
|
||||
|
||||
36
web/context/explore-context.ts
Normal file
36
web/context/explore-context.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { App, InstalledApp } from '@/models/explore'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { createContext } from 'use-context-selector'
|
||||
|
||||
export type CurrentTryAppParams = {
|
||||
appId: string
|
||||
app: App
|
||||
}
|
||||
|
||||
export type IExplore = {
|
||||
controlUpdateInstalledApps: number
|
||||
setControlUpdateInstalledApps: (controlUpdateInstalledApps: number) => void
|
||||
hasEditPermission: boolean
|
||||
installedApps: InstalledApp[]
|
||||
setInstalledApps: (installedApps: InstalledApp[]) => void
|
||||
isFetchingInstalledApps: boolean
|
||||
setIsFetchingInstalledApps: (isFetchingInstalledApps: boolean) => void
|
||||
currentApp?: CurrentTryAppParams
|
||||
isShowTryAppPanel: boolean
|
||||
setShowTryAppPanel: (showTryAppPanel: boolean, params?: CurrentTryAppParams) => void
|
||||
}
|
||||
|
||||
const ExploreContext = createContext<IExplore>({
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: noop,
|
||||
hasEditPermission: false,
|
||||
installedApps: [],
|
||||
setInstalledApps: noop,
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: noop,
|
||||
isShowTryAppPanel: false,
|
||||
setShowTryAppPanel: noop,
|
||||
currentApp: undefined,
|
||||
})
|
||||
|
||||
export default ExploreContext
|
||||
@ -1,121 +0,0 @@
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import type { Banner } from '@/models/app'
|
||||
import type { App, AppCategory, InstalledApp } from '@/models/explore'
|
||||
import type { AppMeta } from '@/models/share'
|
||||
import type { AppModeEnum } from '@/types/app'
|
||||
import { type } from '@orpc/contract'
|
||||
import { base } from '../base'
|
||||
|
||||
export type ExploreAppsResponse = {
|
||||
categories: AppCategory[]
|
||||
recommended_apps: App[]
|
||||
}
|
||||
|
||||
export type ExploreAppDetailResponse = {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
icon_background: string
|
||||
mode: AppModeEnum
|
||||
export_data: string
|
||||
can_trial?: boolean
|
||||
}
|
||||
|
||||
export type InstalledAppsResponse = {
|
||||
installed_apps: InstalledApp[]
|
||||
}
|
||||
|
||||
export type InstalledAppMutationResponse = {
|
||||
result: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type AppAccessModeResponse = {
|
||||
accessMode: AccessMode
|
||||
}
|
||||
|
||||
export const exploreAppsContract = base
|
||||
.route({
|
||||
path: '/explore/apps',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{ query?: { language?: string } }>())
|
||||
.output(type<ExploreAppsResponse>())
|
||||
|
||||
export const exploreAppDetailContract = base
|
||||
.route({
|
||||
path: '/explore/apps/{id}',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{ params: { id: string } }>())
|
||||
.output(type<ExploreAppDetailResponse | null>())
|
||||
|
||||
export const exploreInstalledAppsContract = base
|
||||
.route({
|
||||
path: '/installed-apps',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{ query?: { app_id?: string } }>())
|
||||
.output(type<InstalledAppsResponse>())
|
||||
|
||||
export const exploreInstalledAppUninstallContract = base
|
||||
.route({
|
||||
path: '/installed-apps/{id}',
|
||||
method: 'DELETE',
|
||||
})
|
||||
.input(type<{ params: { id: string } }>())
|
||||
.output(type<unknown>())
|
||||
|
||||
export const exploreInstalledAppPinContract = base
|
||||
.route({
|
||||
path: '/installed-apps/{id}',
|
||||
method: 'PATCH',
|
||||
})
|
||||
.input(type<{
|
||||
params: { id: string }
|
||||
body: {
|
||||
is_pinned: boolean
|
||||
}
|
||||
}>())
|
||||
.output(type<InstalledAppMutationResponse>())
|
||||
|
||||
export const exploreInstalledAppAccessModeContract = base
|
||||
.route({
|
||||
path: '/enterprise/webapp/app/access-mode',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{ query: { appId: string } }>())
|
||||
.output(type<AppAccessModeResponse>())
|
||||
|
||||
export const exploreInstalledAppParametersContract = base
|
||||
.route({
|
||||
path: '/installed-apps/{appId}/parameters',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{
|
||||
params: {
|
||||
appId: string
|
||||
}
|
||||
}>())
|
||||
.output(type<ChatConfig>())
|
||||
|
||||
export const exploreInstalledAppMetaContract = base
|
||||
.route({
|
||||
path: '/installed-apps/{appId}/meta',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{
|
||||
params: {
|
||||
appId: string
|
||||
}
|
||||
}>())
|
||||
.output(type<AppMeta>())
|
||||
|
||||
export const exploreBannersContract = base
|
||||
.route({
|
||||
path: '/explore/banners',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{ query?: { language?: string } }>())
|
||||
.output(type<Banner[]>())
|
||||
@ -1,16 +1,5 @@
|
||||
import type { InferContractRouterInputs } from '@orpc/contract'
|
||||
import { bindPartnerStackContract, invoicesContract } from './console/billing'
|
||||
import {
|
||||
exploreAppDetailContract,
|
||||
exploreAppsContract,
|
||||
exploreBannersContract,
|
||||
exploreInstalledAppAccessModeContract,
|
||||
exploreInstalledAppMetaContract,
|
||||
exploreInstalledAppParametersContract,
|
||||
exploreInstalledAppPinContract,
|
||||
exploreInstalledAppsContract,
|
||||
exploreInstalledAppUninstallContract,
|
||||
} from './console/explore'
|
||||
import { systemFeaturesContract } from './console/system'
|
||||
import {
|
||||
triggerOAuthConfigContract,
|
||||
@ -42,17 +31,6 @@ export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRout
|
||||
|
||||
export const consoleRouterContract = {
|
||||
systemFeatures: systemFeaturesContract,
|
||||
explore: {
|
||||
apps: exploreAppsContract,
|
||||
appDetail: exploreAppDetailContract,
|
||||
installedApps: exploreInstalledAppsContract,
|
||||
uninstallInstalledApp: exploreInstalledAppUninstallContract,
|
||||
updateInstalledApp: exploreInstalledAppPinContract,
|
||||
appAccessMode: exploreInstalledAppAccessModeContract,
|
||||
installedAppParameters: exploreInstalledAppParametersContract,
|
||||
installedAppMeta: exploreInstalledAppMetaContract,
|
||||
banners: exploreBannersContract,
|
||||
},
|
||||
trialApps: {
|
||||
info: trialAppInfoContract,
|
||||
datasets: trialAppDatasetsContract,
|
||||
|
||||
@ -506,8 +506,14 @@
|
||||
}
|
||||
},
|
||||
"app/components/app/app-publisher/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 7
|
||||
},
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"app/components/app/app-publisher/suggested-action.tsx": {
|
||||
@ -1227,8 +1233,11 @@
|
||||
"react/no-nested-component-definitions": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 6
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"app/components/apps/empty.tsx": {
|
||||
@ -4044,6 +4053,16 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/explore/app-card/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/explore/app-list/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/explore/banner/banner-item.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 4
|
||||
@ -4073,6 +4092,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/explore/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/explore/item-operation/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
@ -4083,11 +4107,24 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/explore/sidebar/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/explore/sidebar/no-apps/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/explore/try-app/app-info/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/explore/try-app/app/chat.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
|
||||
@ -1,44 +1,30 @@
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import type { ExploreAppDetailResponse } from '@/contract/console/explore'
|
||||
import type { AppMeta } from '@/models/share'
|
||||
import { consoleClient } from './client'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import type { Banner } from '@/models/app'
|
||||
import type { App, AppCategory } from '@/models/explore'
|
||||
import { del, get, patch } from './base'
|
||||
|
||||
export const fetchAppList = (language?: string) => {
|
||||
if (!language)
|
||||
return consoleClient.explore.apps({})
|
||||
|
||||
return consoleClient.explore.apps({
|
||||
query: { language },
|
||||
})
|
||||
export const fetchAppList = () => {
|
||||
return get<{
|
||||
categories: AppCategory[]
|
||||
recommended_apps: App[]
|
||||
}>('/explore/apps')
|
||||
}
|
||||
|
||||
export const fetchAppDetail = async (id: string): Promise<ExploreAppDetailResponse> => {
|
||||
const response = await consoleClient.explore.appDetail({
|
||||
params: { id },
|
||||
})
|
||||
if (!response)
|
||||
throw new Error('Recommended app not found')
|
||||
return response
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
export const fetchAppDetail = (id: string): Promise<any> => {
|
||||
return get(`/explore/apps/${id}`)
|
||||
}
|
||||
|
||||
export const fetchInstalledAppList = (appId?: string | null) => {
|
||||
if (!appId)
|
||||
return consoleClient.explore.installedApps({})
|
||||
|
||||
return consoleClient.explore.installedApps({
|
||||
query: { app_id: appId },
|
||||
})
|
||||
export const fetchInstalledAppList = (app_id?: string | null) => {
|
||||
return get(`/installed-apps${app_id ? `?app_id=${app_id}` : ''}`)
|
||||
}
|
||||
|
||||
export const uninstallApp = (id: string) => {
|
||||
return consoleClient.explore.uninstallInstalledApp({
|
||||
params: { id },
|
||||
})
|
||||
return del(`/installed-apps/${id}`)
|
||||
}
|
||||
|
||||
export const updatePinStatus = (id: string, isPinned: boolean) => {
|
||||
return consoleClient.explore.updateInstalledApp({
|
||||
params: { id },
|
||||
return patch(`/installed-apps/${id}`, {
|
||||
body: {
|
||||
is_pinned: isPinned,
|
||||
},
|
||||
@ -46,28 +32,10 @@ export const updatePinStatus = (id: string, isPinned: boolean) => {
|
||||
}
|
||||
|
||||
export const getAppAccessModeByAppId = (appId: string) => {
|
||||
return consoleClient.explore.appAccessMode({
|
||||
query: { appId },
|
||||
})
|
||||
return get<{ accessMode: AccessMode }>(`/enterprise/webapp/app/access-mode?appId=${appId}`)
|
||||
}
|
||||
|
||||
export const fetchInstalledAppParams = (appId: string) => {
|
||||
return consoleClient.explore.installedAppParameters({
|
||||
params: { appId },
|
||||
}) as Promise<ChatConfig>
|
||||
}
|
||||
|
||||
export const fetchInstalledAppMeta = (appId: string) => {
|
||||
return consoleClient.explore.installedAppMeta({
|
||||
params: { appId },
|
||||
}) as Promise<AppMeta>
|
||||
}
|
||||
|
||||
export const fetchBanners = (language?: string) => {
|
||||
if (!language)
|
||||
return consoleClient.explore.banners({})
|
||||
|
||||
return consoleClient.explore.banners({
|
||||
query: { language },
|
||||
})
|
||||
export const fetchBanners = (language?: string): Promise<Banner[]> => {
|
||||
const url = language ? `/explore/banners?language=${language}` : '/explore/banners'
|
||||
return get<Banner[]>(url)
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { consoleQuery } from './client'
|
||||
import { fetchAppList, fetchBanners, fetchInstalledAppList, fetchInstalledAppMeta, fetchInstalledAppParams, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore'
|
||||
import { fetchAppList, fetchBanners, fetchInstalledAppList, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore'
|
||||
import { AppSourceType, fetchAppMeta, fetchAppParams } from './share'
|
||||
|
||||
const NAME_SPACE = 'explore'
|
||||
|
||||
type ExploreAppListData = {
|
||||
categories: AppCategory[]
|
||||
@ -13,15 +15,10 @@ type ExploreAppListData = {
|
||||
|
||||
export const useExploreAppList = () => {
|
||||
const locale = useLocale()
|
||||
const exploreAppsInput = locale
|
||||
? { query: { language: locale } }
|
||||
: {}
|
||||
const exploreAppsLanguage = exploreAppsInput?.query?.language
|
||||
|
||||
return useQuery<ExploreAppListData>({
|
||||
queryKey: [...consoleQuery.explore.apps.queryKey({ input: exploreAppsInput }), exploreAppsLanguage],
|
||||
queryKey: [NAME_SPACE, 'appList', locale],
|
||||
queryFn: async () => {
|
||||
const { categories, recommended_apps } = await fetchAppList(exploreAppsLanguage)
|
||||
const { categories, recommended_apps } = await fetchAppList()
|
||||
return {
|
||||
categories,
|
||||
allList: [...recommended_apps].sort((a, b) => a.position - b.position),
|
||||
@ -32,7 +29,7 @@ export const useExploreAppList = () => {
|
||||
|
||||
export const useGetInstalledApps = () => {
|
||||
return useQuery({
|
||||
queryKey: consoleQuery.explore.installedApps.queryKey({ input: {} }),
|
||||
queryKey: [NAME_SPACE, 'installedApps'],
|
||||
queryFn: () => {
|
||||
return fetchInstalledAppList()
|
||||
},
|
||||
@ -42,12 +39,10 @@ export const useGetInstalledApps = () => {
|
||||
export const useUninstallApp = () => {
|
||||
const client = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: consoleQuery.explore.uninstallInstalledApp.mutationKey(),
|
||||
mutationKey: [NAME_SPACE, 'uninstallApp'],
|
||||
mutationFn: (appId: string) => uninstallApp(appId),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({
|
||||
queryKey: consoleQuery.explore.installedApps.queryKey({ input: {} }),
|
||||
})
|
||||
client.invalidateQueries({ queryKey: [NAME_SPACE, 'installedApps'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -55,82 +50,62 @@ export const useUninstallApp = () => {
|
||||
export const useUpdateAppPinStatus = () => {
|
||||
const client = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: consoleQuery.explore.updateInstalledApp.mutationKey(),
|
||||
mutationKey: [NAME_SPACE, 'updateAppPinStatus'],
|
||||
mutationFn: ({ appId, isPinned }: { appId: string, isPinned: boolean }) => updatePinStatus(appId, isPinned),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({
|
||||
queryKey: consoleQuery.explore.installedApps.queryKey({ input: {} }),
|
||||
})
|
||||
client.invalidateQueries({ queryKey: [NAME_SPACE, 'installedApps'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => {
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const appAccessModeInput = { query: { appId: appId ?? '' } }
|
||||
const installedAppId = appAccessModeInput.query.appId
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
...consoleQuery.explore.appAccessMode.queryKey({ input: appAccessModeInput }),
|
||||
systemFeatures.webapp_auth.enabled,
|
||||
installedAppId,
|
||||
],
|
||||
queryKey: [NAME_SPACE, 'appAccessMode', appId, systemFeatures.webapp_auth.enabled],
|
||||
queryFn: () => {
|
||||
if (systemFeatures.webapp_auth.enabled === false) {
|
||||
return {
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
}
|
||||
}
|
||||
if (!installedAppId)
|
||||
return Promise.reject(new Error('App ID is required to get access mode'))
|
||||
if (!appId || appId.length === 0)
|
||||
return Promise.reject(new Error('App code is required to get access mode'))
|
||||
|
||||
return getAppAccessModeByAppId(installedAppId)
|
||||
return getAppAccessModeByAppId(appId)
|
||||
},
|
||||
enabled: !!installedAppId,
|
||||
enabled: !!appId,
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetInstalledAppParams = (appId: string | null) => {
|
||||
const installedAppParamsInput = { params: { appId: appId ?? '' } }
|
||||
const installedAppId = installedAppParamsInput.params.appId
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...consoleQuery.explore.installedAppParameters.queryKey({ input: installedAppParamsInput }), installedAppId],
|
||||
queryKey: [NAME_SPACE, 'appParams', appId],
|
||||
queryFn: () => {
|
||||
if (!installedAppId)
|
||||
if (!appId || appId.length === 0)
|
||||
return Promise.reject(new Error('App ID is required to get app params'))
|
||||
return fetchInstalledAppParams(installedAppId)
|
||||
return fetchAppParams(AppSourceType.installedApp, appId)
|
||||
},
|
||||
enabled: !!installedAppId,
|
||||
enabled: !!appId,
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetInstalledAppMeta = (appId: string | null) => {
|
||||
const installedAppMetaInput = { params: { appId: appId ?? '' } }
|
||||
const installedAppId = installedAppMetaInput.params.appId
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...consoleQuery.explore.installedAppMeta.queryKey({ input: installedAppMetaInput }), installedAppId],
|
||||
queryKey: [NAME_SPACE, 'appMeta', appId],
|
||||
queryFn: () => {
|
||||
if (!installedAppId)
|
||||
if (!appId || appId.length === 0)
|
||||
return Promise.reject(new Error('App ID is required to get app meta'))
|
||||
return fetchInstalledAppMeta(installedAppId)
|
||||
return fetchAppMeta(AppSourceType.installedApp, appId)
|
||||
},
|
||||
enabled: !!installedAppId,
|
||||
enabled: !!appId,
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetBanners = (locale?: string) => {
|
||||
const bannersInput = locale
|
||||
? { query: { language: locale } }
|
||||
: {}
|
||||
const bannersLanguage = bannersInput?.query?.language
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...consoleQuery.explore.banners.queryKey({ input: bannersInput }), bannersLanguage],
|
||||
queryKey: [NAME_SPACE, 'banners', locale],
|
||||
queryFn: () => {
|
||||
return fetchBanners(bannersLanguage)
|
||||
return fetchBanners(locale)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
import type { App } from '@/models/explore'
|
||||
|
||||
export type TryAppSelection = {
|
||||
appId: string
|
||||
app: App
|
||||
}
|
||||
|
||||
export type SetTryAppPanel = (showTryAppPanel: boolean, params?: TryAppSelection) => void
|
||||
Reference in New Issue
Block a user