Compare commits

..

7 Commits

Author SHA1 Message Date
a1295b66e4 feat(api): add shutdown hook for workspace join executor
Implemented a shutdown hook to ensure proper cleanup of the module-level executor used for workspace joining. This includes a best-effort cleanup method that cancels queued tasks and waits for currently running tasks to complete, enhancing the reliability of the service during process termination. Updated error handling to log any issues encountered during shutdown.
2026-02-14 14:55:22 +08:00
cc87cc8057 feat(api): implement asynchronous workspace joining for enterprise accounts
Refactored the account and registration services to utilize an asynchronous method for joining the default workspace during account creation. This change enhances performance by allowing the registration process to proceed without waiting for the workspace joining operation to complete. Updated unit tests to cover the new asynchronous behavior and ensure proper logging of any exceptions that occur during the process.
2026-02-14 14:55:22 +08:00
2020669cb0 feat(api): improve timeout handling in BaseRequest class
Updated the BaseRequest class to conditionally include the timeout parameter when making requests with httpx. This change preserves the library's default timeout behavior by only passing the timeout argument when it is explicitly set, enhancing request management and flexibility.
2026-02-14 14:55:22 +08:00
734d52c643 feat(api): conditionally join default workspace for enterprise accounts
Updated the account and registration services to conditionally attempt joining the default workspace based on the ENTERPRISE_ENABLED configuration. Enhanced the enterprise service to enforce required fields in the response payload, ensuring robust error handling. Added unit tests to verify the behavior for both enabled and disabled enterprise scenarios.
2026-02-14 14:55:22 +08:00
b640ad5ba1 feat(api): add timeout and error handling options to enterprise request
Enhanced the BaseRequest class to include optional timeout and raise_for_status parameters for improved request handling. Updated the EnterpriseService to utilize these new options during account addition to the default workspace, ensuring better control over request behavior. Additionally, modified unit tests to reflect these changes.
2026-02-14 14:55:22 +08:00
78bb79d640 feat(api): enhance account registration process with improved error handling
Implemented better error handling during account addition to the default workspace for enterprise users, ensuring smoother user registration experience even when workspace joining fails.
2026-02-14 14:55:22 +08:00
50f8647ea8 feat(api): implement best-effort account addition to default workspace for enterprise users
Added functionality to attempt adding accounts to the default workspace during account registration and creation processes. This includes a new method in the enterprise service to handle the workspace joining logic, ensuring it does not block user registration on failure.
2026-02-14 14:55:22 +08:00
34 changed files with 1301 additions and 789 deletions

View File

@ -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")

View File

@ -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()

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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)
})

View File

@ -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)

View File

@ -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()
})

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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()
})
})
})
})

View File

@ -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 })
})
})
})

View File

@ -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>

View File

@ -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()
})

View File

@ -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}
/>

View File

@ -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>
)
}

View File

@ -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')

View File

@ -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 />

View File

@ -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()

View File

@ -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>
)}

View File

@ -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)' })
})
})

View File

@ -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', () => {

View File

@ -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>

View File

@ -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]),
}
}))
}

View File

@ -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}

View File

@ -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
}

View 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

View File

@ -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[]>())

View File

@ -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,

View File

@ -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

View File

@ -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)
}

View File

@ -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)
},
})
}

View File

@ -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