chore: paginate installed apps (#39739)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
非法操作 2026-07-29 17:51:34 +08:00 committed by GitHub
parent fd804dfcc0
commit c1fdd6fb78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2125 additions and 1804 deletions

View File

@ -1,11 +1,11 @@
import base64
import binascii
import logging
from datetime import datetime
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, computed_field, field_validator
from sqlalchemy import and_, exists, or_, select
from pydantic import BaseModel, Field, computed_field
from sqlalchemy import and_, select
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from controllers.common.fields import SimpleMessageResponse, SimpleResultMessageResponse
@ -22,13 +22,12 @@ from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.file import helpers as file_helpers
from libs.datetime_utils import naive_utc_now
from libs.helper import to_timestamp
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models import Account, App, AppModelConfig, InstalledApp, RecommendedApp, Workflow
from models import Account, App, InstalledApp, RecommendedApp
from models.model import AppMode, IconType
from services.account_service import TenantService
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.installed_app_service import InstalledAppCursor, InstalledAppService
class InstalledAppCreatePayload(BaseModel):
@ -41,65 +40,53 @@ class InstalledAppUpdatePayload(BaseModel):
class InstalledAppsListQuery(BaseModel):
app_id: str | None = Field(default=None, description="App ID to filter by")
name: str | None = Field(default=None, max_length=100, description="App name to search for")
cursor: str | None = Field(default=None, description="Opaque cursor returned by the previous page")
limit: int = Field(
default=20,
ge=1,
le=100,
description="Number of installed apps to return",
)
logger = logging.getLogger(__name__)
def _build_icon_url(icon_type: str | IconType | None, icon: str | None) -> str | None:
def _build_icon_url(icon_type: IconType | None, icon: str | None) -> str | None:
if icon is None or icon_type is None:
return None
icon_type_value = icon_type.value if isinstance(icon_type, IconType) else str(icon_type)
if icon_type_value.lower() != IconType.IMAGE:
if icon_type != IconType.IMAGE:
return None
return file_helpers.get_signed_file_url(icon)
def _safe_primitive(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool, datetime)):
return value
return None
def _encode_installed_app_cursor(cursor: InstalledAppCursor) -> str:
payload = cursor.model_dump_json().encode()
return base64.urlsafe_b64encode(payload).decode().rstrip("=")
def _published_app_filter():
"""Return the SQL predicate for installed-app web API availability.
def _decode_installed_app_cursor(cursor: str | None) -> InstalledAppCursor | None:
if cursor is None:
return None
The installed-app parameters endpoint reads the published workflow for
workflow-style apps and the published app model config for easy UI apps.
Keep the list endpoint aligned in SQL so it does not return entries that
will immediately fail with app_unavailable when opened.
"""
workflow_app_modes = (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW)
has_published_workflow = exists(select(Workflow.id).where(Workflow.id == App.workflow_id))
has_published_model_config = exists(select(AppModelConfig.id).where(AppModelConfig.id == App.app_model_config_id))
return and_(
App.mode != AppMode.AGENT,
or_(
and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow),
and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config),
),
)
try:
padded_cursor = cursor + "=" * (-len(cursor) % 4)
payload = base64.b64decode(padded_cursor, altchars=b"-_", validate=True)
return InstalledAppCursor.model_validate_json(payload)
except (binascii.Error, UnicodeDecodeError, ValueError):
raise BadRequest("Invalid cursor") from None
class InstalledAppInfoResponse(ResponseModel):
id: str
name: str | None = None
description: str | None = None
mode: str | None = None
icon_type: str | None = None
icon: str | None = None
icon_background: str | None = None
use_icon_as_answer_icon: bool | None = None
@field_validator("mode", "icon_type", mode="before")
@classmethod
def _normalize_enum_like(cls, value: Any) -> str | None:
if value is None:
return None
if isinstance(value, str):
return value
return str(getattr(value, "value", value))
name: str
description: str
mode: AppMode
icon_type: IconType | None
icon: str | None
icon_background: str | None
use_icon_as_answer_icon: bool
@computed_field(return_type=str | None) # type: ignore[prop-decorator]
@property
@ -112,34 +99,33 @@ class InstalledAppResponse(ResponseModel):
app: InstalledAppInfoResponse
app_owner_tenant_id: str
is_pinned: bool
last_used_at: int | None = None
last_used_at: int | None
editable: bool
uninstallable: bool
@field_validator("app", mode="before")
@classmethod
def _normalize_app(cls, value: Any) -> Any:
if isinstance(value, dict):
return value
return {
"id": _safe_primitive(getattr(value, "id", "")) or "",
"name": _safe_primitive(getattr(value, "name", None)),
"description": _safe_primitive(getattr(value, "description", None)),
"mode": _safe_primitive(getattr(value, "mode", None)),
"icon_type": _safe_primitive(getattr(value, "icon_type", None)),
"icon": _safe_primitive(getattr(value, "icon", None)),
"icon_background": _safe_primitive(getattr(value, "icon_background", None)),
"use_icon_as_answer_icon": _safe_primitive(getattr(value, "use_icon_as_answer_icon", None)),
}
@field_validator("last_used_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class InstalledAppListResponse(ResponseModel):
installed_apps: list[InstalledAppResponse]
has_more: bool
next_cursor: str | None
def _installed_app_response_data(
installed_app: InstalledApp,
app_model: App,
*,
current_tenant_id: str,
current_user: Account,
) -> InstalledAppResponse:
return InstalledAppResponse(
id=installed_app.id,
app=InstalledAppInfoResponse.model_validate(app_model),
app_owner_tenant_id=installed_app.app_owner_tenant_id,
is_pinned=installed_app.is_pinned,
last_used_at=to_timestamp(installed_app.last_used_at),
editable=current_user.role in {"owner", "admin"},
uninstallable=current_tenant_id == installed_app.app_owner_tenant_id,
)
register_schema_models(
@ -168,78 +154,40 @@ class InstalledAppsListApi(Resource):
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
query = InstalledAppsListQuery.model_validate(request.args.to_dict())
stmt = (
select(InstalledApp, App)
.join(App, App.id == InstalledApp.app_id)
.where(InstalledApp.tenant_id == current_tenant_id, _published_app_filter())
)
if query.app_id:
stmt = stmt.where(InstalledApp.app_id == query.app_id)
installed_apps = db.session.execute(stmt).all()
cursor = _decode_installed_app_cursor(query.cursor)
if current_user.current_tenant is None:
raise ValueError("current_user.current_tenant must not be None")
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session())
installed_app_list: list[dict[str, Any]] = []
for installed_app, app_model in installed_apps:
installed_app_list.append(
{
"id": installed_app.id,
"app": app_model,
"app_owner_tenant_id": installed_app.app_owner_tenant_id,
"is_pinned": installed_app.is_pinned,
"last_used_at": installed_app.last_used_at,
"editable": current_user.role in {"owner", "admin"},
"uninstallable": current_tenant_id == installed_app.app_owner_tenant_id,
}
)
# filter out apps that user doesn't have access to
if FeatureService.get_system_features().webapp_auth.enabled:
user_id = current_user.id
app_ids = [installed_app["app"].id for installed_app in installed_app_list]
webapp_settings = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids)
# Pre-filter out apps without setting or with sso_verified
filtered_installed_apps = []
for installed_app in installed_app_list:
app_id = installed_app["app"].id
webapp_setting = webapp_settings.get(app_id)
if not webapp_setting or webapp_setting.access_mode == "sso_verified":
continue
filtered_installed_apps.append(installed_app)
# Batch permission check
app_ids = [installed_app["app"].id for installed_app in filtered_installed_apps]
permissions = EnterpriseService.WebAppAuth.batch_is_user_allowed_to_access_webapps(
user_id=user_id,
app_ids=app_ids,
)
# Keep only allowed apps
res = []
for installed_app in filtered_installed_apps:
app_id = installed_app["app"].id
if permissions.get(app_id):
res.append(installed_app)
installed_app_list = res
logger.debug("installed_app_list: %s, user_id: %s", installed_app_list, user_id)
installed_app_list.sort(
key=lambda app: (
-app["is_pinned"],
app["last_used_at"] is None,
-app["last_used_at"].timestamp() if app["last_used_at"] is not None else 0,
)
installed_apps, has_more, next_cursor = InstalledAppService.get_visible_page(
tenant_id=current_tenant_id,
user_id=str(current_user.id),
cursor=cursor,
limit=query.limit,
app_id=query.app_id,
name=query.name,
session=db.session,
)
return InstalledAppListResponse.model_validate(
{"installed_apps": installed_app_list}, from_attributes=True
).model_dump(mode="json")
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session())
installed_app_list = [
_installed_app_response_data(
installed_app,
app_model,
current_tenant_id=current_tenant_id,
current_user=current_user,
)
for installed_app, app_model in installed_apps
]
logger.debug("installed_app_list: %s, user_id: %s", installed_app_list, current_user.id)
return dump_response(
InstalledAppListResponse,
{
"installed_apps": installed_app_list,
"has_more": has_more,
"next_cursor": _encode_installed_app_cursor(next_cursor) if next_cursor else None,
},
)
@login_required
@account_initialization_required
@ -290,10 +238,36 @@ class InstalledAppsListApi(Resource):
@console_ns.route("/installed-apps/<uuid:installed_app_id>")
class InstalledAppApi(InstalledAppResource):
"""
update and delete an installed app
get, update, and delete an installed app
use InstalledAppResource to apply default decorators and get installed_app
"""
@console_ns.response(200, "Success", console_ns.models[InstalledAppResponse.__name__])
@with_current_user
@with_current_tenant_id
def get(
self,
current_tenant_id: str,
current_user: Account,
installed_app: InstalledApp,
):
app_model = InstalledAppService.get_published_app(installed_app.app_id, session=db.session)
if app_model is None:
raise NotFound("Installed app not found")
if current_user.current_tenant is None:
raise ValueError("current_user.current_tenant must not be None")
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session())
return dump_response(
InstalledAppResponse,
_installed_app_response_data(
installed_app,
app_model,
current_tenant_id=current_tenant_id,
current_user=current_user,
),
)
@console_ns.response(204, "App uninstalled successfully")
@with_current_tenant_id
def delete(self, current_tenant_id: str, installed_app: InstalledApp):

View File

@ -7050,6 +7050,9 @@ Request body:
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | query | App ID to filter by | No | string |
| cursor | query | Opaque cursor returned by the previous page | No | string |
| limit | query | Number of installed apps to return | No | integer, <br>**Default:** 20 |
| name | query | App name to search for | No | string |
#### Responses
@ -7083,6 +7086,19 @@ Request body:
| ---- | ----------- |
| 204 | App uninstalled successfully |
### [GET] /installed-apps/{installed_app_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| installed_app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [InstalledAppResponse](#installedappresponse)<br> |
### [PATCH] /installed-apps/{installed_app_id}
#### Parameters
@ -15561,6 +15577,12 @@ AppMCPServer Status Enum
| ---- | ---- | ----------- | -------- |
| AppMCPServerStatus | string | AppMCPServer Status Enum | |
#### AppMode
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AppMode | string | | |
#### AppModelConfigResponse
| Name | Type | Description | Required |
@ -18663,21 +18685,23 @@ Input field definition for snippet parameters.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| description | string | | Yes |
| icon | string | | Yes |
| icon_background | string | | Yes |
| icon_type | [IconType](#icontype) | | Yes |
| icon_url | string | | Yes |
| id | string | | Yes |
| mode | string | | No |
| name | string | | No |
| use_icon_as_answer_icon | boolean | | No |
| mode | [AppMode](#appmode) | | Yes |
| name | string | | Yes |
| use_icon_as_answer_icon | boolean | | Yes |
#### InstalledAppListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| has_more | boolean | | Yes |
| installed_apps | [ [InstalledAppResponse](#installedappresponse) ] | | Yes |
| next_cursor | string | | Yes |
#### InstalledAppResponse
@ -18688,7 +18712,7 @@ Input field definition for snippet parameters.
| editable | boolean | | Yes |
| id | string | | Yes |
| is_pinned | boolean | | Yes |
| last_used_at | integer | | No |
| last_used_at | integer | | Yes |
| uninstallable | boolean | | Yes |
#### InstalledAppUpdatePayload
@ -18702,6 +18726,9 @@ Input field definition for snippet parameters.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | App ID to filter by | No |
| cursor | string | Opaque cursor returned by the previous page | No |
| limit | integer, <br>**Default:** 20 | Number of installed apps to return | No |
| name | string | App name to search for | No |
#### InstructionGeneratePayload

View File

@ -0,0 +1,177 @@
from datetime import datetime
from pydantic import BaseModel
from sqlalchemy import and_, exists, or_, select
from sqlalchemy.orm import Session, scoped_session
from libs.helper import escape_like_pattern
from models import App, AppModelConfig, InstalledApp, Workflow
from models.model import AppMode
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
class InstalledAppCursor(BaseModel):
is_pinned: bool
last_used_at: datetime | None
installed_app_id: str
def _published_app_filter():
"""Return the SQL predicate for installed-app web API availability.
The installed-app parameters endpoint reads the published workflow for
workflow-style apps and the published app model config for easy UI apps.
Keep the list endpoint aligned in SQL so it does not return entries that
will immediately fail with app_unavailable when opened.
"""
workflow_app_modes = (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW)
has_published_workflow = exists(select(Workflow.id).where(Workflow.id == App.workflow_id))
has_published_model_config = exists(select(AppModelConfig.id).where(AppModelConfig.id == App.app_model_config_id))
return and_(
App.mode != AppMode.AGENT,
or_(
and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow),
and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config),
),
)
def _installed_app_cursor_filter(cursor: InstalledAppCursor):
same_pin_group = InstalledApp.is_pinned == cursor.is_pinned
if cursor.last_used_at is None:
later_in_pin_group = and_(
InstalledApp.last_used_at.is_(None),
InstalledApp.id > cursor.installed_app_id,
)
else:
later_in_pin_group = or_(
InstalledApp.last_used_at < cursor.last_used_at,
InstalledApp.last_used_at.is_(None),
and_(
InstalledApp.last_used_at == cursor.last_used_at,
InstalledApp.id > cursor.installed_app_id,
),
)
if cursor.is_pinned:
return or_(
InstalledApp.is_pinned.is_(False),
and_(same_pin_group, later_in_pin_group),
)
return and_(same_pin_group, later_in_pin_group)
def _installed_app_order_by():
return (
InstalledApp.is_pinned.desc(),
InstalledApp.last_used_at.desc().nulls_last(),
InstalledApp.id.asc(),
)
def _filter_rows_by_webapp_auth(
rows: list[tuple[InstalledApp, App]],
*,
user_id: str,
) -> list[tuple[InstalledApp, App]]:
if not rows:
return []
app_ids = [app.id for _, app in rows]
webapp_settings = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids)
candidates = [
(installed_app, app)
for installed_app, app in rows
if (setting := webapp_settings.get(app.id)) is not None and setting.access_mode != "sso_verified"
]
permissions = EnterpriseService.WebAppAuth.batch_is_user_allowed_to_access_webapps(
user_id=user_id,
app_ids=[app.id for _, app in candidates],
)
return [(installed_app, app) for installed_app, app in candidates if permissions.get(app.id)]
class InstalledAppService:
@classmethod
def get_visible_page(
cls,
*,
tenant_id: str,
user_id: str,
cursor: InstalledAppCursor | None,
limit: int,
app_id: str | None,
name: str | None,
session: Session | scoped_session,
) -> tuple[list[tuple[InstalledApp, App]], bool, InstalledAppCursor | None]:
"""Scan ordered candidates until one page of authorized apps is complete."""
stmt = (
select(InstalledApp, App)
.join(App, App.id == InstalledApp.app_id)
.where(InstalledApp.tenant_id == tenant_id, _published_app_filter())
)
if app_id:
stmt = stmt.where(InstalledApp.app_id == app_id)
if name and (normalized_name := name.strip()):
escaped_name = escape_like_pattern(normalized_name)
stmt = stmt.where(App.name.ilike(f"%{escaped_name}%", escape="\\"))
webapp_auth_enabled = FeatureService.get_system_features().webapp_auth.enabled
scan_size = limit * 2 if webapp_auth_enabled else limit + 1
visible_rows: list[tuple[InstalledApp, App]] = []
scan_cursor = cursor
has_more = False
last_consumed_app: InstalledApp | None = None
while True:
page_stmt = stmt
if scan_cursor is not None:
page_stmt = page_stmt.where(_installed_app_cursor_filter(scan_cursor))
candidate_result = session.execute(page_stmt.order_by(*_installed_app_order_by()).limit(scan_size)).all()
candidate_rows = [(installed_app, app) for installed_app, app in candidate_result]
if not candidate_rows:
break
authorized_rows = candidate_rows
if webapp_auth_enabled:
authorized_rows = _filter_rows_by_webapp_auth(candidate_rows, user_id=user_id)
authorized_installed_app_ids = {installed_app.id for installed_app, _ in authorized_rows}
for row in candidate_rows:
installed_app = row[0]
if installed_app.id not in authorized_installed_app_ids:
last_consumed_app = installed_app
continue
if len(visible_rows) == limit:
has_more = True
break
visible_rows.append(row)
last_consumed_app = installed_app
if has_more:
break
if len(candidate_rows) < scan_size:
break
last_scanned_app = candidate_rows[-1][0]
scan_cursor = InstalledAppCursor(
is_pinned=last_scanned_app.is_pinned,
last_used_at=last_scanned_app.last_used_at,
installed_app_id=last_scanned_app.id,
)
next_cursor = (
InstalledAppCursor(
is_pinned=last_consumed_app.is_pinned,
last_used_at=last_consumed_app.last_used_at,
installed_app_id=last_consumed_app.id,
)
if has_more and last_consumed_app
else None
)
return visible_rows, has_more, next_cursor
@staticmethod
def get_published_app(app_id: str, *, session: Session | scoped_session) -> App | None:
return session.scalar(select(App).where(App.id == app_id, _published_app_filter()).limit(1))

View File

@ -1,6 +1,7 @@
from collections.abc import Callable
from contextlib import AbstractContextManager
from datetime import datetime
from inspect import unwrap
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
@ -8,12 +9,24 @@ from flask import Flask
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
import controllers.console.explore.installed_app as module
import services.installed_app_service as service_module
from models.model import AppMode, IconType
type Payload = dict[str, object]
type PayloadPatch = Callable[[Payload], AbstractContextManager[object]]
from inspect import unwrap
def make_app_model(app_id: str) -> MagicMock:
app_model = MagicMock()
app_model.id = app_id
app_model.name = f"App {app_id}"
app_model.description = "Description"
app_model.mode = AppMode.CHAT
app_model.icon_type = IconType.EMOJI
app_model.icon = "robot"
app_model.icon_background = "#FFFFFF"
app_model.use_icon_as_answer_icon = False
return app_model
@pytest.fixture
@ -33,7 +46,7 @@ def current_user(tenant_id: str) -> MagicMock:
def installed_app() -> MagicMock:
app = MagicMock()
app.id = "ia1"
app.app = MagicMock(id="a1")
app.app = make_app_model("a1")
app.app_owner_tenant_id = "t2"
app.is_pinned = False
app.last_used_at = datetime(2024, 1, 1)
@ -54,8 +67,30 @@ def payload_patch() -> PayloadPatch:
class TestInstalledAppsListApi:
def test_list_query_defaults_to_20(self) -> None:
assert module.InstalledAppsListQuery().limit == 20
def test_response_schema_preserves_installed_app_domain_types(self) -> None:
app_schema = module.InstalledAppInfoResponse.model_json_schema(mode="serialization")
list_schema = module.InstalledAppListResponse.model_json_schema(mode="serialization")
assert {
"id",
"name",
"description",
"mode",
"icon_type",
"icon",
"icon_background",
"use_icon_as_answer_icon",
"icon_url",
} <= set(app_schema["required"])
assert set(app_schema["$defs"]["AppMode"]["enum"]) == {mode.value for mode in AppMode}
assert set(app_schema["$defs"]["IconType"]["enum"]) == {icon_type.value for icon_type in IconType}
assert "next_cursor" in list_schema["required"]
def test_published_app_filter_checks_publish_targets(self) -> None:
compiled_filter = str(module._published_app_filter().compile(compile_kwargs={"literal_binds": True}))
compiled_filter = str(service_module._published_app_filter().compile(compile_kwargs={"literal_binds": True}))
assert "workflows" in compiled_filter
assert "app_model_configs" in compiled_filter
@ -77,7 +112,7 @@ class TestInstalledAppsListApi:
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="owner"),
patch.object(
module.FeatureService,
service_module.FeatureService,
"get_system_features",
return_value=MagicMock(webapp_auth=MagicMock(enabled=False)),
),
@ -87,6 +122,10 @@ class TestInstalledAppsListApi:
assert "installed_apps" in result
assert result["installed_apps"][0]["editable"] is True
assert result["installed_apps"][0]["uninstallable"] is False
assert result["has_more"] is False
assert result["next_cursor"] is None
executed_stmt = session.execute.call_args.args[0]
assert 21 in executed_stmt.compile().params.values()
def test_get_installed_apps_with_app_id_filter(self, app: Flask, current_user: MagicMock, tenant_id: str) -> None:
api = module.InstalledAppsListApi()
@ -100,14 +139,191 @@ class TestInstalledAppsListApi:
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="member"),
patch.object(
module.FeatureService,
service_module.FeatureService,
"get_system_features",
return_value=MagicMock(webapp_auth=MagicMock(enabled=False)),
),
):
result = method(api, tenant_id, current_user)
assert result == {"installed_apps": []}
assert result == {"installed_apps": [], "has_more": False, "next_cursor": None}
def test_get_installed_apps_escapes_name_search(self, app: Flask, current_user: MagicMock, tenant_id: str) -> None:
api = module.InstalledAppsListApi()
method = unwrap(api.get)
session = MagicMock()
session.execute.return_value.all.return_value = []
with (
app.test_request_context("/?name=Sales%25_Q3"),
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="owner"),
patch.object(
service_module.FeatureService,
"get_system_features",
return_value=MagicMock(webapp_auth=MagicMock(enabled=False)),
),
):
method(api, tenant_id, current_user)
executed_stmt = session.execute.call_args.args[0]
assert r"%Sales\%\_Q3%" in executed_stmt.compile().params.values()
def test_get_installed_apps_returns_cursor_when_more_apps_exist(
self, app: Flask, current_user: MagicMock, tenant_id: str
) -> None:
api = module.InstalledAppsListApi()
method = unwrap(api.get)
rows = []
for index in range(3):
installed_app = MagicMock(
id=f"ia{index}",
app_owner_tenant_id="t2",
is_pinned=index == 0,
last_used_at=datetime(2024, 1, 3 - index),
)
app_model = make_app_model(f"a{index}")
rows.append((installed_app, app_model))
session = MagicMock()
session.execute.return_value.all.return_value = rows
with (
app.test_request_context("/?limit=2"),
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="owner"),
patch.object(
service_module.FeatureService,
"get_system_features",
return_value=MagicMock(webapp_auth=MagicMock(enabled=False)),
),
):
result = method(api, tenant_id, current_user)
assert [item["id"] for item in result["installed_apps"]] == ["ia0", "ia1"]
assert result["has_more"] is True
assert result["next_cursor"]
decoded_cursor = module._decode_installed_app_cursor(result["next_cursor"])
assert decoded_cursor is not None
assert decoded_cursor.installed_app_id == "ia1"
def test_get_installed_apps_filters_permissions_before_filling_page(
self, app: Flask, current_user: MagicMock, tenant_id: str
) -> None:
api = module.InstalledAppsListApi()
method = unwrap(api.get)
rows = []
for index in range(3):
installed_app = MagicMock(
id=f"ia{index}",
app_owner_tenant_id="t2",
is_pinned=False,
last_used_at=datetime(2024, 1, 3 - index),
)
app_model = make_app_model(f"a{index}")
rows.append((installed_app, app_model))
session = MagicMock()
session.execute.return_value.all.return_value = rows
restricted = MagicMock(access_mode="restricted")
with (
app.test_request_context("/?limit=1"),
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="member"),
patch.object(
service_module.FeatureService,
"get_system_features",
return_value=MagicMock(webapp_auth=MagicMock(enabled=True)),
),
patch.object(
service_module.EnterpriseService.WebAppAuth,
"batch_get_app_access_mode_by_id",
return_value={"a0": restricted, "a1": restricted, "a2": restricted},
),
patch.object(
service_module.EnterpriseService.WebAppAuth,
"batch_is_user_allowed_to_access_webapps",
return_value={"a0": False, "a1": True, "a2": True},
),
):
result = method(api, tenant_id, current_user)
assert [item["id"] for item in result["installed_apps"]] == ["ia1"]
assert result["has_more"] is True
def test_get_installed_apps_scans_past_denied_candidate_batch(
self, app: Flask, current_user: MagicMock, tenant_id: str
) -> None:
api = module.InstalledAppsListApi()
method = unwrap(api.get)
allowed_rows = [
(
MagicMock(
id=f"allowed-{index}",
app_owner_tenant_id="t2",
is_pinned=False,
last_used_at=datetime(2024, 1, 2) if index == 0 else datetime(2023, 12, 31),
),
make_app_model(f"allowed-app-{index}"),
)
for index in range(2)
]
denied_rows = [
(
MagicMock(
id=f"denied-{index:03}",
app_owner_tenant_id="t2",
is_pinned=False,
last_used_at=datetime(2024, 1, 1),
),
make_app_model(f"denied-app-{index:03}"),
)
for index in range(1)
]
first_batch = [allowed_rows[0], *denied_rows]
first_result = MagicMock()
first_result.all.return_value = first_batch
second_result = MagicMock()
second_result.all.return_value = [allowed_rows[1]]
session = MagicMock()
session.execute.side_effect = [first_result, second_result]
with (
app.test_request_context("/?limit=1"),
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="member"),
patch.object(
service_module.FeatureService,
"get_system_features",
return_value=MagicMock(webapp_auth=MagicMock(enabled=True)),
),
patch.object(
service_module,
"_filter_rows_by_webapp_auth",
side_effect=[[allowed_rows[0]], [allowed_rows[1]]],
),
):
result = method(api, tenant_id, current_user)
assert [item["id"] for item in result["installed_apps"]] == ["allowed-0"]
assert result["has_more"] is True
assert session.execute.call_count == 2
second_stmt = session.execute.call_args_list[1].args[0]
assert "denied-000" in second_stmt.compile().params.values()
next_cursor = module._decode_installed_app_cursor(result["next_cursor"])
assert next_cursor is not None
assert next_cursor.installed_app_id == "denied-000"
def test_get_installed_apps_rejects_invalid_cursor(
self, app: Flask, current_user: MagicMock, tenant_id: str
) -> None:
api = module.InstalledAppsListApi()
method = unwrap(api.get)
with app.test_request_context("/?cursor=not-a-cursor"):
with pytest.raises(BadRequest, match="Invalid cursor"):
method(api, tenant_id, current_user)
def test_get_installed_apps_with_webapp_auth_enabled(
self, app: Flask, current_user: MagicMock, tenant_id: str, installed_app: MagicMock
@ -127,17 +343,17 @@ class TestInstalledAppsListApi:
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="owner"),
patch.object(
module.FeatureService,
service_module.FeatureService,
"get_system_features",
return_value=MagicMock(webapp_auth=MagicMock(enabled=True)),
),
patch.object(
module.EnterpriseService.WebAppAuth,
service_module.EnterpriseService.WebAppAuth,
"batch_get_app_access_mode_by_id",
return_value={"a1": mock_webapp_setting},
),
patch.object(
module.EnterpriseService.WebAppAuth,
service_module.EnterpriseService.WebAppAuth,
"batch_is_user_allowed_to_access_webapps",
return_value={"a1": True},
),
@ -145,6 +361,8 @@ class TestInstalledAppsListApi:
result = method(api, tenant_id, current_user)
assert len(result["installed_apps"]) == 1
executed_stmt = session.execute.call_args.args[0]
assert 40 in executed_stmt.compile().params.values()
def test_get_installed_apps_with_webapp_auth_user_denied(
self, app: Flask, current_user: MagicMock, tenant_id: str, installed_app: MagicMock
@ -164,17 +382,17 @@ class TestInstalledAppsListApi:
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="member"),
patch.object(
module.FeatureService,
service_module.FeatureService,
"get_system_features",
return_value=MagicMock(webapp_auth=MagicMock(enabled=True)),
),
patch.object(
module.EnterpriseService.WebAppAuth,
service_module.EnterpriseService.WebAppAuth,
"batch_get_app_access_mode_by_id",
return_value={"a1": mock_webapp_setting},
),
patch.object(
module.EnterpriseService.WebAppAuth,
service_module.EnterpriseService.WebAppAuth,
"batch_is_user_allowed_to_access_webapps",
return_value={"a1": False},
),
@ -201,12 +419,12 @@ class TestInstalledAppsListApi:
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="owner"),
patch.object(
module.FeatureService,
service_module.FeatureService,
"get_system_features",
return_value=MagicMock(webapp_auth=MagicMock(enabled=True)),
),
patch.object(
module.EnterpriseService.WebAppAuth,
service_module.EnterpriseService.WebAppAuth,
"batch_get_app_access_mode_by_id",
return_value={"a1": mock_webapp_setting},
),
@ -228,7 +446,7 @@ class TestInstalledAppsListApi:
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="owner"),
patch.object(
module.FeatureService,
service_module.FeatureService,
"get_system_features",
return_value=MagicMock(webapp_auth=MagicMock(enabled=False)),
),
@ -251,7 +469,7 @@ class TestInstalledAppsListApi:
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="owner"),
patch.object(
module.FeatureService,
service_module.FeatureService,
"get_system_features",
return_value=MagicMock(webapp_auth=MagicMock(enabled=False)),
),
@ -274,7 +492,7 @@ class TestInstalledAppsListApi:
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="owner"),
patch.object(
module.FeatureService,
service_module.FeatureService,
"get_system_features",
return_value=MagicMock(webapp_auth=MagicMock(enabled=False)),
),
@ -369,6 +587,52 @@ class TestInstalledAppsCreateApi:
class TestInstalledAppApi:
def test_get_installed_app(
self,
app: Flask,
current_user: MagicMock,
tenant_id: str,
installed_app: MagicMock,
) -> None:
api = module.InstalledAppApi()
method = unwrap(api.get)
app_model = installed_app.app
session = MagicMock()
session.scalar.return_value = app_model
with (
app.test_request_context("/"),
patch.object(module.db, "session", session),
patch.object(module.TenantService, "get_user_role", return_value="owner"),
):
result = method(api, tenant_id, current_user, installed_app)
assert result["id"] == installed_app.id
assert result["app"]["id"] == app_model.id
assert result["app"]["mode"] == AppMode.CHAT
assert result["app"]["icon_type"] == IconType.EMOJI
assert result["app"]["use_icon_as_answer_icon"] is False
assert result["editable"] is True
def test_get_installed_app_rejects_unpublished_app(
self,
app: Flask,
current_user: MagicMock,
tenant_id: str,
installed_app: MagicMock,
) -> None:
api = module.InstalledAppApi()
method = unwrap(api.get)
session = MagicMock()
session.scalar.return_value = None
with (
app.test_request_context("/"),
patch.object(module.db, "session", session),
):
with pytest.raises(NotFound, match="Installed app not found"):
method(api, tenant_id, current_user, installed_app)
def test_delete_success(self, tenant_id: str, installed_app: MagicMock) -> None:
api = module.InstalledAppApi()
method = unwrap(api.delete)

View File

@ -24,6 +24,8 @@ import {
zGetInstalledAppsByInstalledAppIdMetaResponse,
zGetInstalledAppsByInstalledAppIdParametersPath,
zGetInstalledAppsByInstalledAppIdParametersResponse,
zGetInstalledAppsByInstalledAppIdPath,
zGetInstalledAppsByInstalledAppIdResponse,
zGetInstalledAppsByInstalledAppIdSavedMessagesPath,
zGetInstalledAppsByInstalledAppIdSavedMessagesQuery,
zGetInstalledAppsByInstalledAppIdSavedMessagesResponse,
@ -520,6 +522,17 @@ export const delete3 = oc
.input(z.object({ params: zDeleteInstalledAppsByInstalledAppIdPath }))
.output(zDeleteInstalledAppsByInstalledAppIdResponse)
export const get8 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getInstalledAppsByInstalledAppId',
path: '/installed-apps/{installed_app_id}',
tags: ['console'],
})
.input(z.object({ params: zGetInstalledAppsByInstalledAppIdPath }))
.output(zGetInstalledAppsByInstalledAppIdResponse)
export const patch3 = oc
.route({
inputStructure: 'detailed',
@ -538,6 +551,7 @@ export const patch3 = oc
export const byInstalledAppId = {
delete: delete3,
get: get8,
patch: patch3,
audioToText,
chatMessages,
@ -551,7 +565,7 @@ export const byInstalledAppId = {
workflows,
}
export const get8 = oc
export const get9 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -574,7 +588,7 @@ export const post12 = oc
.output(zPostInstalledAppsResponse)
export const installedApps = {
get: get8,
get: get9,
post: post12,
byInstalledAppId,
}

View File

@ -5,7 +5,9 @@ export type ClientOptions = {
}
export type InstalledAppListResponse = {
has_more: boolean
installed_apps: Array<InstalledAppResponse>
next_cursor: string | null
}
export type InstalledAppCreatePayload = {
@ -16,6 +18,16 @@ export type SimpleMessageResponse = {
message: string
}
export type InstalledAppResponse = {
app: InstalledAppInfoResponse
app_owner_tenant_id: string
editable: boolean
id: string
is_pinned: boolean
last_used_at: number | null
uninstallable: boolean
}
export type InstalledAppUpdatePayload = {
is_pinned?: boolean | null
}
@ -169,14 +181,16 @@ export type WorkflowRunPayload = {
}
}
export type InstalledAppResponse = {
app: InstalledAppInfoResponse
app_owner_tenant_id: string
editable: boolean
export type InstalledAppInfoResponse = {
description: string
icon: string | null
icon_background: string | null
icon_type: IconType | null
readonly icon_url: string | null
id: string
is_pinned: boolean
last_used_at?: number | null
uninstallable: boolean
mode: AppMode
name: string
use_icon_as_answer_icon: boolean
}
export type JsonValue =
@ -240,17 +254,17 @@ export type SavedMessageItem = {
query: string
}
export type InstalledAppInfoResponse = {
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
readonly icon_url: string | null
id: string
mode?: string | null
name?: string | null
use_icon_as_answer_icon?: boolean | null
}
export type IconType = 'emoji' | 'image' | 'link'
export type AppMode =
| 'advanced-chat'
| 'agent'
| 'agent-chat'
| 'channel'
| 'chat'
| 'completion'
| 'rag-pipeline'
| 'workflow'
export type AgentThought = {
answer?: string | null
@ -413,13 +427,9 @@ export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url'
export type ValueSourceType = 'constant' | 'variable'
export type InstalledAppListResponseWritable = {
installed_apps: Array<InstalledAppResponseWritable>
}
export type ExploreMessageInfiniteScrollPaginationWritable = {
data: Array<ExploreMessageListItemWritable>
has_more: boolean
limit: number
installed_apps: Array<InstalledAppResponseWritable>
next_cursor: string | null
}
export type InstalledAppResponseWritable = {
@ -428,10 +438,27 @@ export type InstalledAppResponseWritable = {
editable: boolean
id: string
is_pinned: boolean
last_used_at?: number | null
last_used_at: number | null
uninstallable: boolean
}
export type ExploreMessageInfiniteScrollPaginationWritable = {
data: Array<ExploreMessageListItemWritable>
has_more: boolean
limit: number
}
export type InstalledAppInfoResponseWritable = {
description: string
icon: string | null
icon_background: string | null
icon_type: IconType | null
id: string
mode: AppMode
name: string
use_icon_as_answer_icon: boolean
}
export type ExploreMessageListItemWritable = {
agent_thoughts: Array<AgentThought>
answer: string
@ -457,22 +484,14 @@ export type ExploreMessageListItemWritable = {
total_price?: string | null
}
export type InstalledAppInfoResponseWritable = {
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
id: string
mode?: string | null
name?: string | null
use_icon_as_answer_icon?: boolean | null
}
export type GetInstalledAppsData = {
body?: never
path?: never
query?: {
app_id?: string
cursor?: string
limit?: number
name?: string
}
url: '/installed-apps'
}
@ -512,6 +531,22 @@ export type DeleteInstalledAppsByInstalledAppIdResponses = {
export type DeleteInstalledAppsByInstalledAppIdResponse =
DeleteInstalledAppsByInstalledAppIdResponses[keyof DeleteInstalledAppsByInstalledAppIdResponses]
export type GetInstalledAppsByInstalledAppIdData = {
body?: never
path: {
installed_app_id: string
}
query?: never
url: '/installed-apps/{installed_app_id}'
}
export type GetInstalledAppsByInstalledAppIdResponses = {
200: InstalledAppResponse
}
export type GetInstalledAppsByInstalledAppIdResponse =
GetInstalledAppsByInstalledAppIdResponses[keyof GetInstalledAppsByInstalledAppIdResponses]
export type PatchInstalledAppsByInstalledAppIdData = {
body: InstalledAppUpdatePayload
path: {

View File

@ -229,19 +229,38 @@ export const zParameters = z.object({
user_input_form: z.array(zJsonObject),
})
/**
* IconType
*/
export const zIconType = z.enum(['emoji', 'image', 'link'])
/**
* AppMode
*/
export const zAppMode = z.enum([
'advanced-chat',
'agent',
'agent-chat',
'channel',
'chat',
'completion',
'rag-pipeline',
'workflow',
])
/**
* InstalledAppInfoResponse
*/
export const zInstalledAppInfoResponse = z.object({
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
description: z.string(),
icon: z.string().nullable(),
icon_background: z.string().nullable(),
icon_type: zIconType.nullable(),
icon_url: z.string().nullable(),
id: z.string(),
mode: z.string().nullish(),
name: z.string().nullish(),
use_icon_as_answer_icon: z.boolean().nullish(),
mode: zAppMode,
name: z.string(),
use_icon_as_answer_icon: z.boolean(),
})
/**
@ -253,7 +272,7 @@ export const zInstalledAppResponse = z.object({
editable: z.boolean(),
id: z.string(),
is_pinned: z.boolean(),
last_used_at: z.int().nullish(),
last_used_at: z.int().nullable(),
uninstallable: z.boolean(),
})
@ -261,7 +280,9 @@ export const zInstalledAppResponse = z.object({
* InstalledAppListResponse
*/
export const zInstalledAppListResponse = z.object({
has_more: z.boolean(),
installed_apps: z.array(zInstalledAppResponse),
next_cursor: z.string().nullable(),
})
/**
@ -547,6 +568,42 @@ export const zExploreMessageInfiniteScrollPagination = z.object({
limit: z.int(),
})
/**
* InstalledAppInfoResponse
*/
export const zInstalledAppInfoResponseWritable = z.object({
description: z.string(),
icon: z.string().nullable(),
icon_background: z.string().nullable(),
icon_type: zIconType.nullable(),
id: z.string(),
mode: zAppMode,
name: z.string(),
use_icon_as_answer_icon: z.boolean(),
})
/**
* InstalledAppResponse
*/
export const zInstalledAppResponseWritable = z.object({
app: zInstalledAppInfoResponseWritable,
app_owner_tenant_id: z.string(),
editable: z.boolean(),
id: z.string(),
is_pinned: z.boolean(),
last_used_at: z.int().nullable(),
uninstallable: z.boolean(),
})
/**
* InstalledAppListResponse
*/
export const zInstalledAppListResponseWritable = z.object({
has_more: z.boolean(),
installed_apps: z.array(zInstalledAppResponseWritable),
next_cursor: z.string().nullable(),
})
/**
* ExploreMessageListItem
*/
@ -585,42 +642,11 @@ export const zExploreMessageInfiniteScrollPaginationWritable = z.object({
limit: z.int(),
})
/**
* InstalledAppInfoResponse
*/
export const zInstalledAppInfoResponseWritable = z.object({
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
id: z.string(),
mode: z.string().nullish(),
name: z.string().nullish(),
use_icon_as_answer_icon: z.boolean().nullish(),
})
/**
* InstalledAppResponse
*/
export const zInstalledAppResponseWritable = z.object({
app: zInstalledAppInfoResponseWritable,
app_owner_tenant_id: z.string(),
editable: z.boolean(),
id: z.string(),
is_pinned: z.boolean(),
last_used_at: z.int().nullish(),
uninstallable: z.boolean(),
})
/**
* InstalledAppListResponse
*/
export const zInstalledAppListResponseWritable = z.object({
installed_apps: z.array(zInstalledAppResponseWritable),
})
export const zGetInstalledAppsQuery = z.object({
app_id: z.string().optional(),
cursor: z.string().optional(),
limit: z.int().gte(1).lte(100).optional().default(20),
name: z.string().max(100).optional(),
})
/**
@ -644,6 +670,15 @@ export const zDeleteInstalledAppsByInstalledAppIdPath = z.object({
*/
export const zDeleteInstalledAppsByInstalledAppIdResponse = z.void()
export const zGetInstalledAppsByInstalledAppIdPath = z.object({
installed_app_id: z.uuid(),
})
/**
* Success
*/
export const zGetInstalledAppsByInstalledAppIdResponse = zInstalledAppResponse
export const zPatchInstalledAppsByInstalledAppIdBody = zInstalledAppUpdatePayload
export const zPatchInstalledAppsByInstalledAppIdPath = z.object({

View File

@ -1,6 +1,6 @@
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
import type { ReactNode } from 'react'
import type { ChatConfig } from '../../types'
import type { InstalledApp } from '@/models/explore'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { ToastHost } from '@langgenius/dify-ui/toast'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@ -1034,17 +1034,25 @@ describe('useChatWithHistory', () => {
describe('installedApp mode', () => {
it('should use installedApp source type and derive appData from installedAppInfo', async () => {
// Arrange
const installedAppInfo = {
const installedAppInfo: InstalledAppResponse = {
id: 'installed-app-id',
app_owner_tenant_id: 'tenant-id',
editable: true,
is_pinned: false,
last_used_at: null,
uninstallable: true,
app: {
id: 'app-id',
name: 'Installed App',
description: 'Installed app description',
mode: 'chat',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
icon_url: '',
icon_url: null,
use_icon_as_answer_icon: false,
},
} as unknown as InstalledApp
}
mockFetchConversations.mockResolvedValue(createConversationData())
mockFetchChatList.mockResolvedValue({ data: [] })

View File

@ -1,6 +1,6 @@
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
import type { ExtraContent } from '../chat/type'
import type { Callback, ChatConfig, ChatItem, Feedback } from '../types'
import type { InstalledApp } from '@/models/explore'
import type { AppData, ConversationItem } from '@/models/share'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
import { toast } from '@langgenius/dify-ui/toast'
@ -128,7 +128,7 @@ function getFormattedChatList(messages: any[]) {
})
return newChatList
}
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
export const useChatWithHistory = (installedAppInfo?: InstalledAppResponse) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const appInfo = useWebAppStore((s) => s.appInfo)
@ -150,7 +150,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
title: app.name,
description: app.description,
icon_type: app.icon_type,
icon: app.icon,
icon: app.icon ?? undefined,
icon_background: app.icon_background,
icon_url: app.icon_url,
prompt_public: false,
@ -158,9 +158,8 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
show_workflow_steps: true,
use_icon_as_answer_icon: app.use_icon_as_answer_icon,
},
plan: 'basic',
custom_config: null,
} as AppData
} satisfies AppData
}
return appInfo
}, [isInstalledApp, installedAppInfo, appInfo])
@ -539,7 +538,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
handleUpdateConversationList()
},
[
isInstalledApp,
appSourceType,
appId,
t,
handleUpdateConversationList,
@ -575,7 +574,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
setConversationRenaming(false)
}
},
[isInstalledApp, appId, t, conversationRenaming, originConversationList],
[appSourceType, appId, t, conversationRenaming, originConversationList],
)
const handleNewConversationCompleted = useCallback(
(newConversationId: string) => {

View File

@ -1,7 +1,7 @@
'use client'
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
import type { FC } from 'react'
import type { ChatProps } from '../chat'
import type { InstalledApp } from '@/models/explore'
import { cn } from '@langgenius/dify-ui/cn'
import { useEffect, useState } from 'react'
import Loading from '@/app/components/base/loading'
@ -87,7 +87,7 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({ className }) => {
}
type ChatWithHistoryWrapProps = {
installedAppInfo?: InstalledApp
installedAppInfo?: InstalledAppResponse
className?: string
isNewAgent?: boolean
renderAgentContent?: ChatProps['renderAgentContent']

View File

@ -5,7 +5,6 @@ import Explore from '../index'
const mockReplace = vi.fn()
const mockPush = vi.fn()
const mockInstalledAppsData = { installed_apps: [] as const }
type MediaTypeValue = (typeof MediaType)[keyof typeof MediaType]
let mockMediaType: MediaTypeValue = MediaType.pc
@ -28,19 +27,6 @@ vi.mock('@/hooks/use-breakpoints', () => ({
},
}))
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: () => ({
isPending: false,
data: mockInstalledAppsData,
}),
useUninstallApp: () => ({
mutateAsync: vi.fn(),
}),
useUpdateAppPinStatus: () => ({
mutateAsync: vi.fn(),
}),
}))
describe('Explore', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@ -1,17 +1,30 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AppNavItem from '../index'
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AppNavItem from '../app-nav-item'
const baseProps = {
name: 'My App',
id: 'app-123',
icon_type: 'emoji' as const,
icon: '🤖',
icon_background: '#fff',
icon_url: '',
app: {
id: 'app-123',
app_owner_tenant_id: 'tenant-1',
editable: true,
is_pinned: false,
last_used_at: null,
uninstallable: false,
app: {
id: 'source-app-123',
name: 'My App',
description: 'Description',
mode: 'chat',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
icon_url: null,
use_icon_as_answer_icon: false,
},
} satisfies InstalledAppResponse,
isSelected: false,
isPinned: false,
togglePin: vi.fn(),
uninstallable: false,
onTogglePin: vi.fn(),
onDelete: vi.fn(),
}
@ -59,20 +72,40 @@ describe('AppNavItem', () => {
})
it('should call onDelete with app id when delete action is clicked', async () => {
const user = userEvent.setup()
render(<AppNavItem {...baseProps} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(await screen.findByText('explore.sidebar.action.delete'))
expect(baseProps.onDelete).toHaveBeenCalledWith('app-123')
})
it('should request the next pin state', async () => {
const user = userEvent.setup()
render(<AppNavItem {...baseProps} />)
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(await screen.findByText('explore.sidebar.action.pin'))
expect(baseProps.onTogglePin).toHaveBeenCalledWith('app-123', true)
})
})
describe('Edge Cases', () => {
it('should not render delete action when app is uninstallable', () => {
render(<AppNavItem {...baseProps} uninstallable />)
it('should not render delete action when app is uninstallable', async () => {
const user = userEvent.setup()
render(
<AppNavItem
{...baseProps}
app={{
...baseProps.app,
uninstallable: true,
}}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument()
})

View File

@ -0,0 +1,70 @@
import type { RefObject } from 'react'
import { act, render } from '@testing-library/react'
import { InfiniteScrollSentinel } from '../infinite-scroll-sentinel'
describe('InfiniteScrollSentinel', () => {
it('does not observe again after a next-page request completes', () => {
const fetchNextPage = vi.fn(() => Promise.resolve())
const scrollRootRef: RefObject<HTMLDivElement | null> = {
current: document.createElement('div'),
}
const observerConstructed = vi.fn()
vi.stubGlobal(
'IntersectionObserver',
class MockIntersectionObserver {
private readonly callback: IntersectionObserverCallback
constructor(callback: IntersectionObserverCallback) {
this.callback = callback
observerConstructed()
}
observe() {
this.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
)
}
disconnect() {}
unobserve() {}
},
)
const { rerender } = render(
<InfiniteScrollSentinel
canFetchNextPage
fetchNextPage={fetchNextPage}
isFetchingNextPage={false}
scrollRootRef={scrollRootRef}
/>,
)
expect(fetchNextPage).toHaveBeenCalledOnce()
act(() => {
rerender(
<InfiniteScrollSentinel
canFetchNextPage
fetchNextPage={fetchNextPage}
isFetchingNextPage
scrollRootRef={scrollRootRef}
/>,
)
})
act(() => {
rerender(
<InfiniteScrollSentinel
canFetchNextPage
fetchNextPage={fetchNextPage}
isFetchingNextPage={false}
scrollRootRef={scrollRootRef}
/>,
)
})
expect(observerConstructed).toHaveBeenCalledOnce()
expect(fetchNextPage).toHaveBeenCalledOnce()
})
})

View File

@ -1,5 +1,5 @@
'use client'
import type { AppIconType } from '@/types/app'
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import AppIcon from '@/app/components/base/app-icon'
@ -9,35 +9,27 @@ import Link from '@/next/link'
type IAppNavItemProps = {
variant?: 'default' | 'mainNav'
name: string
ariaLabel?: string
id: string
icon_type: AppIconType | null
icon: string
icon_background: string
icon_url: string
app: InstalledAppResponse
isSelected: boolean
isPinned: boolean
togglePin: () => void
uninstallable: boolean
onTogglePin: (id: string, isPinned: boolean) => void
onDelete: (id: string) => void
}
export default function AppNavItem({
variant = 'default',
name,
ariaLabel,
id,
icon_type,
icon,
icon_background,
icon_url,
app: installedApp,
isSelected,
isPinned,
togglePin,
uninstallable,
onTogglePin,
onDelete,
}: IAppNavItemProps) {
const {
id,
is_pinned: isPinned,
uninstallable,
app: { name, icon_type, icon, icon_background, icon_url },
} = installedApp
const url = buildInstalledAppPath(id)
const isMainNav = variant === 'mainNav'
@ -68,7 +60,7 @@ export default function AppNavItem({
size="tiny"
className={cn(isMainNav && 'size-5 rounded-md text-sm')}
iconType={icon_type}
icon={icon}
icon={icon ?? undefined}
background={icon_background}
imageUrl={icon_url}
/>
@ -90,7 +82,7 @@ export default function AppNavItem({
>
<ItemOperation
isPinned={isPinned}
togglePin={togglePin}
togglePin={() => onTogglePin(id, !isPinned)}
isShowDelete={!uninstallable && !isSelected}
onDelete={() => onDelete(id)}
/>

View File

@ -0,0 +1,50 @@
'use client'
import type { RefObject } from 'react'
import { useEffect, useEffectEvent, useRef } from 'react'
type InfiniteScrollSentinelProps = {
canFetchNextPage: boolean
fetchNextPage: () => Promise<unknown>
isFetchingNextPage: boolean
scrollRootRef: RefObject<HTMLDivElement | null>
}
export const InfiniteScrollSentinel = ({
canFetchNextPage,
fetchNextPage,
isFetchingNextPage,
scrollRootRef,
}: InfiniteScrollSentinelProps) => {
const sentinelRef = useRef<HTMLDivElement>(null)
const handleIntersection = useEffectEvent((entry: IntersectionObserverEntry) => {
if (entry.isIntersecting && canFetchNextPage && !isFetchingNextPage) void fetchNextPage()
})
useEffect(() => {
const scrollRoot = scrollRootRef.current
const sentinel = sentinelRef.current
if (
!canFetchNextPage ||
!scrollRoot ||
!sentinel ||
typeof IntersectionObserver === 'undefined'
)
return
const observer = new IntersectionObserver(
([entry]) => {
if (entry) handleIntersection(entry)
},
{
root: scrollRoot,
rootMargin: '0px 0px 64px 0px',
},
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [canFetchNextPage, scrollRootRef])
return <div ref={sentinelRef} aria-hidden className="h-px" />
}

View File

@ -0,0 +1,9 @@
const skeletonClassName =
'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none'
export const InstalledAppPaginationSkeleton = () => (
<div aria-hidden className="flex h-8 items-center gap-2 px-2 py-0.5">
<div className={`${skeletonClassName} size-5 shrink-0 rounded-md`} />
<div className={`${skeletonClassName} h-3 w-24`} />
</div>
)

View File

@ -1,125 +1,149 @@
import type {
AppMode,
InstalledAppResponse,
} from '@dify/contracts/api/console/installed-apps/types.gen'
import type { Mock } from 'vitest'
import type { InstalledApp as InstalledAppType } from '@/models/explore'
import { render, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import {
useGetInstalledAppAccessModeByAppId,
useGetInstalledAppMeta,
useGetInstalledAppParams,
useGetInstalledApps,
} from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import InstalledApp from '../index'
const mocks = vi.hoisted(() => ({
getAccessMode: vi.fn(),
getInstalledApp: vi.fn(),
getInstalledAppMeta: vi.fn(),
getInstalledAppParameters: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
systemFeatures: {
get: {
queryKey: () => ['system-features'],
queryOptions: () => ({
queryKey: ['system-features'],
queryFn: async () => ({ webapp_auth: { enabled: true } }),
}),
},
},
enterprise: {
webAppAuth: {
getWebAppAccessMode: {
queryOptions: ({ input }: { input: { query: { appId: string } } }) => ({
queryKey: ['installed-app-access-mode', input.query.appId],
queryFn: () => mocks.getAccessMode(input.query.appId),
}),
},
},
},
installedApps: {
byInstalledAppId: {
get: {
queryOptions: ({ input }: { input: { params: { installed_app_id: string } } }) => ({
queryKey: ['installed-app', input.params.installed_app_id],
queryFn: () => mocks.getInstalledApp(input.params.installed_app_id),
retry: false,
}),
},
meta: {
get: {
queryOptions: ({ input }: { input: { params: { installed_app_id: string } } }) => ({
queryKey: ['installed-app-meta', input.params.installed_app_id],
queryFn: () => mocks.getInstalledAppMeta(input.params.installed_app_id),
}),
},
},
parameters: {
get: {
queryOptions: ({ input }: { input: { params: { installed_app_id: string } } }) => ({
queryKey: ['installed-app-parameters', input.params.installed_app_id],
queryFn: () => mocks.getInstalledAppParameters(input.params.installed_app_id),
}),
},
},
},
},
},
}))
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: vi.fn(),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
useGetUserCanAccessApp: vi.fn(),
}))
vi.mock('@/service/use-explore', () => ({
useGetInstalledAppAccessModeByAppId: vi.fn(),
useGetInstalledAppParams: vi.fn(),
useGetInstalledAppMeta: vi.fn(),
useGetInstalledApps: vi.fn(),
}))
vi.mock('@/app/components/share/text-generation', () => ({
default: ({
isInstalledApp,
installedAppInfo,
isWorkflow,
}: {
isInstalledApp?: boolean
installedAppInfo?: InstalledAppType
isWorkflow?: boolean
}) => (
<div data-testid="text-generation-app">
Text Generation App
{isWorkflow && ' (Workflow)'}
{isInstalledApp && ` - ${installedAppInfo?.id}`}
</div>
default: ({ isWorkflow }: { isWorkflow?: boolean }) => (
<div>{isWorkflow ? 'Workflow App' : 'Completion App'}</div>
),
}))
vi.mock('@/app/components/base/chat/chat-with-history', () => ({
default: ({
installedAppInfo,
className,
}: {
installedAppInfo?: InstalledAppType
className?: string
}) => (
<div data-testid="chat-with-history" className={className}>
Chat With History - {installedAppInfo?.id}
</div>
),
default: () => <div>Chat App</div>,
}))
const createInstalledApp = (mode: AppMode = 'chat'): InstalledAppResponse => ({
id: 'installed-app-123',
app_owner_tenant_id: 'tenant-1',
editable: true,
is_pinned: false,
last_used_at: null,
uninstallable: true,
app: {
id: 'app-123',
name: 'Test App',
description: 'Test description',
mode,
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FFFFFF',
icon_url: null,
use_icon_as_answer_icon: false,
},
})
describe('InstalledApp', () => {
const mockUpdateAppInfo = vi.fn()
const mockUpdateWebAppAccessMode = vi.fn()
const mockUpdateAppParams = vi.fn()
const mockUpdateWebAppMeta = vi.fn()
const mockUpdateUserCanAccessApp = vi.fn()
const mockInstalledApp = {
id: 'installed-app-123',
app: {
id: 'app-123',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon_type: 'emoji' as const,
icon: '🚀',
icon_background: '#FFFFFF',
icon_url: '',
description: 'Test description',
use_icon_as_answer_icon: false,
},
uninstallable: true,
is_pinned: false,
}
const mockAppParams = {
const updateAppInfo = vi.fn()
const updateWebAppAccessMode = vi.fn()
const updateAppParams = vi.fn()
const updateWebAppMeta = vi.fn()
const updateUserCanAccessApp = vi.fn()
const appParams = {
user_input_form: [],
file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } },
system_parameters: {},
}
const mockAppMeta = {
tool_icons: {},
}
const mockWebAppAccessMode = {
accessMode: AccessMode.PUBLIC,
}
const mockUserCanAccessApp = {
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,
})
}
const appMeta = { tool_icons: {} }
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
mocks.getAccessMode.mockResolvedValue({ accessMode: AccessMode.PUBLIC })
mocks.getInstalledApp.mockResolvedValue(createInstalledApp())
mocks.getInstalledAppMeta.mockResolvedValue(appMeta)
mocks.getInstalledAppParameters.mockResolvedValue({
annotation_reply: {},
file_upload: appParams.file_upload,
more_like_this: {},
opening_statement: null,
retriever_resource: {},
sensitive_word_avoidance: {},
speech_to_text: {},
suggested_questions: [],
suggested_questions_after_answer: {},
system_parameters: {
audio_file_size_limit: 10,
file_size_limit: 15,
image_file_size_limit: 10,
video_file_size_limit: 100,
workflow_file_upload_limit: 10,
},
text_to_speech: {},
user_input_form: [],
})
;(useWebAppStore as unknown as Mock).mockImplementation(
(
@ -130,492 +154,113 @@ describe('InstalledApp', () => {
updateWebAppMeta: Mock
updateUserCanAccessApp: Mock
}) => unknown,
) => {
const state = {
updateAppInfo: mockUpdateAppInfo,
updateWebAppAccessMode: mockUpdateWebAppAccessMode,
updateAppParams: mockUpdateAppParams,
updateWebAppMeta: mockUpdateWebAppMeta,
updateUserCanAccessApp: mockUpdateUserCanAccessApp,
}
return selector(state)
},
) =>
selector({
updateAppInfo,
updateWebAppAccessMode,
updateAppParams,
updateWebAppMeta,
updateUserCanAccessApp,
}),
)
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isPending: false,
data: mockWebAppAccessMode,
error: null,
})
;(useGetInstalledAppParams as Mock).mockReturnValue({
isPending: false,
data: mockAppParams,
error: null,
})
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isPending: false,
data: mockAppMeta,
error: null,
})
;(useGetUserCanAccessApp as Mock).mockReturnValue({
data: mockUserCanAccessApp,
data: { result: true },
error: null,
isPending: false,
})
})
describe('Rendering', () => {
it('should render loading state when fetching app params', () => {
;(useGetInstalledAppParams as Mock).mockReturnValue({
isPending: true,
data: null,
error: null,
})
it.each<AppMode>(['chat', 'advanced-chat', 'agent-chat'])(
'renders chat for the supported %s mode',
async (mode) => {
mocks.getInstalledApp.mockResolvedValue(createInstalledApp(mode))
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
renderWithConsoleQuery(<InstalledApp id="installed-app-123" />)
expect(await screen.findByText('Chat App')).toBeInTheDocument()
},
)
it('renders completion and workflow surfaces explicitly', async () => {
mocks.getInstalledApp.mockResolvedValue(createInstalledApp('completion'))
const { rerender } = renderWithConsoleQuery(<InstalledApp id="installed-app-123" />)
expect(await screen.findByText('Completion App')).toBeInTheDocument()
mocks.getInstalledApp.mockResolvedValue(createInstalledApp('workflow'))
rerender(<InstalledApp id="installed-app-456" />)
expect(await screen.findByText('Workflow App')).toBeInTheDocument()
})
it.each<AppMode>(['agent', 'channel', 'rag-pipeline'])(
'fails closed for unsupported %s mode',
async (mode) => {
mocks.getInstalledApp.mockResolvedValue(createInstalledApp(mode))
renderWithConsoleQuery(<InstalledApp id="installed-app-123" />)
expect(await screen.findByText('Unsupported installed app mode.')).toBeInTheDocument()
expect(screen.queryByText('Chat App')).not.toBeInTheDocument()
},
)
it('starts route-owned parameter, metadata, and access queries without waiting for detail', async () => {
mocks.getInstalledApp.mockReturnValue(new Promise(() => {}))
renderWithConsoleQuery(<InstalledApp id="installed-app-123" />, {
systemFeatures: { webapp_auth: { enabled: true } },
})
it('should render loading state when fetching app meta', () => {
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isPending: true,
data: null,
error: null,
})
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
})
it('should render loading state when fetching web app access mode', () => {
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isPending: true,
data: null,
error: null,
})
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
})
it('should render loading state when fetching installed apps', () => {
setupMocks([mockInstalledApp], { isPending: true })
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
})
it('should render app not found (404) when installedApp does not exist', () => {
setupMocks([])
render(<InstalledApp id="nonexistent-app" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
await waitFor(() => {
expect(mocks.getAccessMode).toHaveBeenCalledWith('installed-app-123')
expect(mocks.getInstalledAppParameters).toHaveBeenCalledWith('installed-app-123')
expect(mocks.getInstalledAppMeta).toHaveBeenCalledWith('installed-app-123')
})
})
describe('Error States', () => {
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,
data: null,
error,
})
it('writes one contract-derived app snapshot into the installed-app runtime store', async () => {
renderWithConsoleQuery(<InstalledApp id="installed-app-123" />)
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Failed to load app params/)).toBeInTheDocument()
})
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,
data: null,
error,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Failed to load app meta/)).toBeInTheDocument()
})
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,
data: null,
error,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Failed to load access mode/)).toBeInTheDocument()
})
it('should render error when user access check fails', () => {
const error = new Error('Failed to check user access')
;(useGetUserCanAccessApp as Mock).mockReturnValue({
data: null,
error,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Failed to check user access/)).toBeInTheDocument()
})
it('should render no permission (403) when user cannot access app', () => {
;(useGetUserCanAccessApp as Mock).mockReturnValue({
data: { result: false },
error: null,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/403/)).toBeInTheDocument()
expect(screen.getByText(/no permission/i)).toBeInTheDocument()
})
})
describe('App Mode Rendering', () => {
it('should render ChatWithHistory for CHAT mode', async () => {
render(<InstalledApp id="installed-app-123" />)
expect(await screen.findByText(/Chat With History/i)).toBeInTheDocument()
expect(screen.queryByText(/Text Generation App/i)).not.toBeInTheDocument()
})
it('should render ChatWithHistory for ADVANCED_CHAT mode', () => {
const advancedChatApp = {
...mockInstalledApp,
app: {
...mockInstalledApp.app,
mode: AppModeEnum.ADVANCED_CHAT,
await waitFor(() =>
expect(updateAppInfo).toHaveBeenCalledWith({
app_id: 'installed-app-123',
custom_config: null,
site: {
title: 'Test App',
description: 'Test description',
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FFFFFF',
icon_url: null,
prompt_public: false,
copyright: '',
show_workflow_steps: true,
use_icon_as_answer_icon: false,
},
}
setupMocks([advancedChatApp])
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
expect(screen.queryByText(/Text Generation App/i)).not.toBeInTheDocument()
})
it('should render ChatWithHistory for AGENT_CHAT mode', () => {
const agentChatApp = {
...mockInstalledApp,
app: {
...mockInstalledApp.app,
mode: AppModeEnum.AGENT_CHAT,
},
}
setupMocks([agentChatApp])
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
expect(screen.queryByText(/Text Generation App/i)).not.toBeInTheDocument()
})
it('should render TextGenerationApp for COMPLETION mode', () => {
const completionApp = {
...mockInstalledApp,
app: {
...mockInstalledApp.app,
mode: AppModeEnum.COMPLETION,
},
}
setupMocks([completionApp])
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Text Generation App/i)).toBeInTheDocument()
expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument()
})
it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => {
const workflowApp = {
...mockInstalledApp,
app: {
...mockInstalledApp.app,
mode: AppModeEnum.WORKFLOW,
},
}
setupMocks([workflowApp])
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Text Generation App/i)).toBeInTheDocument()
expect(screen.getByText(/Workflow/)).toBeInTheDocument()
})
}),
)
})
describe('Props', () => {
it('should use id prop to find installed app', () => {
const app1 = { ...mockInstalledApp, id: 'app-1' }
const app2 = { ...mockInstalledApp, id: 'app-2' }
setupMocks([app1, app2])
render(<InstalledApp id="app-2" />)
expect(screen.getByText(/app-2/)).toBeInTheDocument()
it('does not mount the app surface before the access query settles', async () => {
;(useGetUserCanAccessApp as Mock).mockReturnValue({
data: undefined,
error: null,
isPending: true,
})
it('should handle id that does not match any installed app', () => {
render(<InstalledApp id="nonexistent-id" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
renderWithConsoleQuery(<InstalledApp id="installed-app-123" />)
await waitFor(() => expect(mocks.getInstalledApp).toHaveBeenCalled())
expect(screen.queryByText('Chat App')).not.toBeInTheDocument()
})
describe('Effects', () => {
it('should update app info when installedApp is available', async () => {
render(<InstalledApp id="installed-app-123" />)
it('distinguishes a missing installed app from a recoverable detail failure', async () => {
mocks.getInstalledApp.mockRejectedValue(new Response(null, { status: 404 }))
const { unmount } = renderWithConsoleQuery(<InstalledApp id="missing-app" />)
expect(await screen.findByText(/404/)).toBeInTheDocument()
unmount()
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalledWith(
expect.objectContaining({
app_id: 'installed-app-123',
site: expect.objectContaining({
title: 'Test App',
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FFFFFF',
icon_url: '',
prompt_public: false,
copyright: '',
show_workflow_steps: true,
use_icon_as_answer_icon: false,
}),
plan: 'basic',
custom_config: null,
}),
)
})
})
it('should update app info to null when installedApp is not found', async () => {
setupMocks([])
render(<InstalledApp id="nonexistent-app" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalledWith(null)
})
})
it('should update app params when data is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams)
})
})
it('should update app meta when data is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateWebAppMeta).toHaveBeenCalledWith(mockAppMeta)
})
})
it('should update web app access mode when data is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC)
})
})
it('should update user can access app when data is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true)
})
})
it('should update user can access app to false when result is false', async () => {
;(useGetUserCanAccessApp as Mock).mockReturnValue({
data: { result: false },
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false)
})
})
it('should update user can access app to false when data is null', async () => {
;(useGetUserCanAccessApp as Mock).mockReturnValue({
data: null,
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false)
})
})
it('should not update app params when data is null', async () => {
;(useGetInstalledAppParams as Mock).mockReturnValue({
isPending: false,
data: null,
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalled()
})
expect(mockUpdateAppParams).not.toHaveBeenCalled()
})
it('should not update app meta when data is null', async () => {
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isPending: false,
data: null,
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalled()
})
expect(mockUpdateWebAppMeta).not.toHaveBeenCalled()
})
it('should not update access mode when data is null', async () => {
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isPending: false,
data: null,
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalled()
})
expect(mockUpdateWebAppAccessMode).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty installedApps array', () => {
setupMocks([])
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
it('should handle multiple installed apps and find the correct one', () => {
const otherApp = {
...mockInstalledApp,
id: 'other-app-id',
app: {
...mockInstalledApp.app,
name: 'Other App',
},
}
setupMocks([otherApp, mockInstalledApp])
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
expect(screen.getByText(/installed-app-123/)).toBeInTheDocument()
})
it('should handle rapid id prop changes', async () => {
const app1 = { ...mockInstalledApp, id: 'app-1' }
const app2 = { ...mockInstalledApp, id: 'app-2' }
setupMocks([app1, app2])
const { rerender } = render(<InstalledApp id="app-1" />)
expect(screen.getByText(/app-1/)).toBeInTheDocument()
rerender(<InstalledApp id="app-2" />)
expect(screen.getByText(/app-2/)).toBeInTheDocument()
})
it('should call service hooks with correct appId', () => {
render(<InstalledApp id="installed-app-123" />)
expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith('installed-app-123')
expect(useGetInstalledAppParams).toHaveBeenCalledWith('installed-app-123')
expect(useGetInstalledAppMeta).toHaveBeenCalledWith('installed-app-123')
expect(useGetUserCanAccessApp).toHaveBeenCalledWith({
appId: 'app-123',
isInstalledApp: true,
})
})
it('should call service hooks with null when installedApp is not found', () => {
setupMocks([])
render(<InstalledApp id="nonexistent-app" />)
expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith(null)
expect(useGetInstalledAppParams).toHaveBeenCalledWith(null)
expect(useGetInstalledAppMeta).toHaveBeenCalledWith(null)
expect(useGetUserCanAccessApp).toHaveBeenCalledWith({
appId: undefined,
isInstalledApp: true,
})
})
})
describe('Render Priority', () => {
it('should show error before loading state', () => {
;(useGetInstalledAppParams as Mock).mockReturnValue({
isPending: true,
data: null,
error: new Error('Some error'),
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Some error/)).toBeInTheDocument()
})
it('should show error before permission check', () => {
;(useGetInstalledAppParams as Mock).mockReturnValue({
isPending: false,
data: null,
error: new Error('Params error'),
})
;(useGetUserCanAccessApp as Mock).mockReturnValue({
data: { result: false },
error: null,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Params error/)).toBeInTheDocument()
expect(screen.queryByText(/403/)).not.toBeInTheDocument()
})
it('should show permission error before 404', () => {
setupMocks([])
;(useGetUserCanAccessApp as Mock).mockReturnValue({
data: { result: false },
error: null,
})
render(<InstalledApp id="nonexistent-app" />)
expect(screen.getByText(/403/)).toBeInTheDocument()
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
})
it('should show loading before 404 while installed apps are refetching', () => {
setupMocks([], { isFetching: true })
const { container } = render(<InstalledApp id="nonexistent-app" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
})
mocks.getInstalledApp.mockRejectedValue(new Error('Network unavailable'))
renderWithConsoleQuery(<InstalledApp id="offline-app" />)
expect(await screen.findByText('Network unavailable')).toBeInTheDocument()
})
})

View File

@ -1,21 +1,19 @@
'use client'
import type { AccessMode } from '@/models/access-control'
import type { AppMode } from '@dify/contracts/api/console/installed-apps/types.gen'
import type { AppData } from '@/models/share'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import TextGenerationApp from '@/app/components/share/text-generation'
import { useWebAppStore } from '@/context/web-app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode } from '@/models/access-control'
import dynamic from '@/next/dynamic'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import {
useGetInstalledAppAccessModeByAppId,
useGetInstalledAppMeta,
useGetInstalledAppParams,
useGetInstalledApps,
} from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import { consoleQuery } from '@/service/client'
import AppUnavailable from '../../base/app-unavailable'
import { toInstalledAppAccessMode, toInstalledAppMeta, toInstalledAppParameters } from './runtime'
const ChatWithHistory = dynamic(() => import('@/app/components/base/chat/chat-with-history'), {
ssr: false,
@ -34,13 +32,41 @@ const InstalledTextGenerationSurface = ({ children }: { children: React.ReactNod
</div>
)
const getInstalledAppSurface = (
mode: AppMode,
): 'chat' | 'completion' | 'unsupported' | 'workflow' => {
switch (mode) {
case 'chat':
case 'advanced-chat':
case 'agent-chat':
return 'chat'
case 'completion':
return 'completion'
case 'workflow':
return 'workflow'
case 'agent':
case 'channel':
case 'rag-pipeline':
return 'unsupported'
}
}
const InstalledApp = ({ id }: { id: string }) => {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const webappAuthEnabled = systemFeatures.webapp_auth.enabled
const {
data,
isPending: isPendingInstalledApps,
isFetching: isFetchingInstalledApps,
} = useGetInstalledApps()
const installedApp = data?.installed_apps?.find((item) => item.id === id)
data: installedApp,
isPending: isPendingInstalledApp,
error: installedAppError,
} = useQuery({
...consoleQuery.installedApps.byInstalledAppId.get.queryOptions({
input: {
params: {
installed_app_id: id,
},
},
}),
})
const updateAppInfo = useWebAppStore((s) => s.updateAppInfo)
const updateWebAppAccessMode = useWebAppStore((s) => s.updateWebAppAccessMode)
const updateAppParams = useWebAppStore((s) => s.updateAppParams)
@ -50,18 +76,41 @@ const InstalledApp = ({ id }: { id: string }) => {
isPending: isPendingWebAppAccessMode,
data: webAppAccessMode,
error: webAppAccessModeError,
} = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null)
} = useQuery({
...consoleQuery.enterprise.webAppAuth.getWebAppAccessMode.queryOptions({
input: { query: { appId: id } },
}),
enabled: webappAuthEnabled,
select: toInstalledAppAccessMode,
})
const {
isPending: isPendingAppParams,
data: appParams,
error: appParamsError,
} = useGetInstalledAppParams(installedApp?.id ?? null)
} = useQuery({
...consoleQuery.installedApps.byInstalledAppId.parameters.get.queryOptions({
input: { params: { installed_app_id: id } },
}),
select: toInstalledAppParameters,
})
const {
isPending: isPendingAppMeta,
data: appMeta,
error: appMetaError,
} = useGetInstalledAppMeta(installedApp?.id ?? null)
const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({
} = useQuery({
...consoleQuery.installedApps.byInstalledAppId.meta.get.queryOptions({
input: { params: { installed_app_id: id } },
}),
select: toInstalledAppMeta,
})
const resolvedWebAppAccessMode = webappAuthEnabled
? webAppAccessMode?.accessMode
: AccessMode.PUBLIC
const {
data: userCanAccessApp,
error: useCanAccessAppError,
isPending: isPendingUserCanAccessApp,
} = useGetUserCanAccessApp({
appId: installedApp?.app.id,
isInstalledApp: true,
})
@ -77,7 +126,7 @@ const InstalledApp = ({ id }: { id: string }) => {
title: app.name,
description: app.description,
icon_type: app.icon_type,
icon: app.icon,
icon: app.icon ?? undefined,
icon_background: app.icon_background,
icon_url: app.icon_url,
prompt_public: false,
@ -85,18 +134,14 @@ const InstalledApp = ({ id }: { id: string }) => {
show_workflow_steps: true,
use_icon_as_answer_icon: app.use_icon_as_answer_icon,
},
plan: 'basic',
custom_config: null,
} as AppData)
} satisfies AppData)
}
if (appParams) updateAppParams(appParams)
if (appMeta) updateWebAppMeta(appMeta)
if (webAppAccessMode)
updateWebAppAccessMode((webAppAccessMode as { accessMode: AccessMode }).accessMode)
updateUserCanAccessApp(
Boolean(userCanAccessApp && (userCanAccessApp as { result: boolean })?.result),
)
if (resolvedWebAppAccessMode) updateWebAppAccessMode(resolvedWebAppAccessMode)
updateUserCanAccessApp(Boolean(userCanAccessApp?.result))
}, [
installedApp,
appMeta,
@ -106,7 +151,7 @@ const InstalledApp = ({ id }: { id: string }) => {
updateUserCanAccessApp,
updateWebAppMeta,
userCanAccessApp,
webAppAccessMode,
resolvedWebAppAccessMode,
updateWebAppAccessMode,
])
@ -146,6 +191,20 @@ const InstalledApp = ({ id }: { id: string }) => {
</InstalledAppFrame>
)
}
if (installedAppError) {
const isNotFound = installedAppError instanceof Response && installedAppError.status === 404
return (
<InstalledAppFrame>
<div className="flex h-full items-center justify-center">
{isNotFound ? (
<AppUnavailable code={404} isUnknownReason />
) : (
<AppUnavailable unknownReason={installedAppError.message} />
)}
</div>
</InstalledAppFrame>
)
}
if (userCanAccessApp && !userCanAccessApp.result) {
return (
<InstalledAppFrame>
@ -156,9 +215,11 @@ const InstalledApp = ({ id }: { id: string }) => {
)
}
if (
isPendingInstalledApps ||
(!installedApp && isFetchingInstalledApps) ||
(installedApp && (isPendingAppParams || isPendingAppMeta || isPendingWebAppAccessMode))
isPendingInstalledApp ||
isPendingAppParams ||
isPendingAppMeta ||
(webappAuthEnabled && isPendingWebAppAccessMode) ||
isPendingUserCanAccessApp
) {
return (
<InstalledAppFrame>
@ -177,23 +238,34 @@ const InstalledApp = ({ id }: { id: string }) => {
</InstalledAppFrame>
)
}
const surface = getInstalledAppSurface(installedApp.app.mode)
if (surface === 'unsupported') {
return (
<InstalledAppFrame>
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason="Unsupported installed app mode." />
</div>
</InstalledAppFrame>
)
}
return (
<InstalledAppFrame>
{installedApp?.app.mode !== AppModeEnum.COMPLETION &&
installedApp?.app.mode !== AppModeEnum.WORKFLOW && (
<ChatWithHistory
installedAppInfo={installedApp}
className={`overflow-hidden rounded-2xl shadow-md ${installedAppSurfaceClassName}`}
/>
)}
{installedApp?.app.mode === AppModeEnum.COMPLETION && (
{surface === 'chat' && (
<ChatWithHistory
installedAppInfo={installedApp}
className={`overflow-hidden rounded-2xl shadow-md ${installedAppSurfaceClassName}`}
/>
)}
{surface === 'completion' && (
<InstalledTextGenerationSurface>
<TextGenerationApp isInstalledApp installedAppInfo={installedApp} />
<TextGenerationApp isInstalledApp />
</InstalledTextGenerationSurface>
)}
{installedApp?.app.mode === AppModeEnum.WORKFLOW && (
{surface === 'workflow' && (
<InstalledTextGenerationSurface>
<TextGenerationApp isWorkflow isInstalledApp installedAppInfo={installedApp} />
<TextGenerationApp isWorkflow isInstalledApp />
</InstalledTextGenerationSurface>
)}
</InstalledAppFrame>

View File

@ -0,0 +1,161 @@
import type {
ExploreAppMetaResponse,
Parameters as InstalledAppParametersResponse,
} from '@dify/contracts/api/console/installed-apps/types.gen'
import type { GetWebAppAccessModeRes } from '@dify/contracts/enterprise/types.gen'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AccessMode } from '@/models/access-control'
import type { AppMeta, ToolIcon } from '@/models/share'
import { isAccessMode } from '@/models/access-control'
import { PromptMode } from '@/models/debug'
import { RETRIEVE_TYPE, TtsAutoPlay } from '@/types/app'
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const getValue = (source: object, key: string): unknown => Reflect.get(source, key)
const getStringProperty = (source: object, key: string) => {
const value = getValue(source, key)
return typeof value === 'string' ? value : ''
}
const getBooleanProperty = (source: object, key: string) => {
const value = getValue(source, key)
return typeof value === 'boolean' ? value : false
}
const isTtsAutoPlay = (value: unknown): value is TtsAutoPlay =>
value === TtsAutoPlay.enabled || value === TtsAutoPlay.disabled
const isUserInputFormItem = (value: unknown): value is ChatConfig['user_input_form'][number] => {
if (!isRecord(value)) return false
return [
'text-input',
'select',
'paragraph',
'number',
'checkbox',
'file',
'file-list',
'external_data_tool',
'json_object',
].some((key) => isRecord(getValue(value, key)))
}
const isModel = (
value: unknown,
): value is NonNullable<ChatConfig['suggested_questions_after_answer']['model']> => isRecord(value)
const isAnnotationReplyConfig = (
value: unknown,
): value is NonNullable<ChatConfig['annotation_reply']> => isRecord(value)
const isFileUploadConfig = (value: unknown): value is NonNullable<ChatConfig['file_upload']> =>
isRecord(value)
const defaultDatasetConfigs = (): ChatConfig['dataset_configs'] => ({
retrieval_model: RETRIEVE_TYPE.oneWay,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 4,
score_threshold_enabled: false,
score_threshold: null,
datasets: {
datasets: [],
},
})
const normalizeEnabledConfig = (value: unknown): { enabled: boolean } => {
const record = isRecord(value) ? value : {}
return {
...record,
enabled: getBooleanProperty(record, 'enabled'),
}
}
const normalizeSuggestedQuestionsAfterAnswer = (
value: unknown,
): ChatConfig['suggested_questions_after_answer'] => {
const record = isRecord(value) ? value : {}
const model = getValue(record, 'model')
const prompt = getStringProperty(record, 'prompt')
return {
enabled: getBooleanProperty(record, 'enabled'),
...(isModel(model) ? { model } : {}),
...(prompt ? { prompt } : {}),
}
}
const normalizeTextToSpeech = (value: unknown): ChatConfig['text_to_speech'] => {
const record = isRecord(value) ? value : {}
const autoPlay = getValue(record, 'autoPlay')
const normalizedAutoPlay = isTtsAutoPlay(autoPlay) ? autoPlay : undefined
return {
...record,
enabled: getBooleanProperty(record, 'enabled'),
voice: getStringProperty(record, 'voice') || undefined,
language: getStringProperty(record, 'language') || undefined,
...(normalizedAutoPlay ? { autoPlay: normalizedAutoPlay } : {}),
}
}
const normalizeSystemParameters = (
systemParameters: InstalledAppParametersResponse['system_parameters'],
): ChatConfig['system_parameters'] => ({
audio_file_size_limit: systemParameters.audio_file_size_limit,
file_size_limit: systemParameters.file_size_limit,
image_file_size_limit: systemParameters.image_file_size_limit,
video_file_size_limit: systemParameters.video_file_size_limit,
workflow_file_upload_limit: systemParameters.workflow_file_upload_limit,
})
export const toInstalledAppParameters = (response: InstalledAppParametersResponse): ChatConfig => ({
opening_statement: response.opening_statement ?? '',
suggested_questions: response.suggested_questions,
pre_prompt: '',
prompt_type: PromptMode.simple,
user_input_form: response.user_input_form.filter(isUserInputFormItem),
more_like_this: normalizeEnabledConfig(response.more_like_this),
suggested_questions_after_answer: normalizeSuggestedQuestionsAfterAnswer(
response.suggested_questions_after_answer,
),
speech_to_text: normalizeEnabledConfig(response.speech_to_text),
text_to_speech: normalizeTextToSpeech(response.text_to_speech),
retriever_resource: normalizeEnabledConfig(response.retriever_resource),
sensitive_word_avoidance: normalizeEnabledConfig(response.sensitive_word_avoidance),
...(isAnnotationReplyConfig(response.annotation_reply)
? { annotation_reply: response.annotation_reply }
: {}),
agent_mode: {
enabled: false,
tools: [],
},
dataset_configs: defaultDatasetConfigs(),
...(isFileUploadConfig(response.file_upload) ? { file_upload: response.file_upload } : {}),
system_parameters: normalizeSystemParameters(response.system_parameters),
})
export const toInstalledAppMeta = (response: ExploreAppMetaResponse): AppMeta => {
const toolIcons: Record<string, ToolIcon> = {}
Object.entries(response.tool_icons ?? {}).forEach(([key, value]) => {
toolIcons[key] = value
})
return { tool_icons: toolIcons }
}
export const toInstalledAppAccessMode = (
response: GetWebAppAccessModeRes,
): { accessMode: AccessMode } => {
if (isAccessMode(response.accessMode)) return { accessMode: response.accessMode }
throw new Error('Web app access mode response returned an unsupported access mode.')
}

View File

@ -1,42 +1,73 @@
import type { InstalledApp } from '@/models/explore'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { expectLoadingButton } from '@/test/button'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import SideBar from '../index'
const { mockToastSuccess } = vi.hoisted(() => ({
mockToastSuccess: vi.fn(),
const mocks = vi.hoisted(() => ({
fetchNextPage: vi.fn(),
installedApps: [] as InstalledAppResponse[],
hasNextPage: false,
uninstall: vi.fn(),
updatePinStatus: vi.fn(),
toastSuccess: vi.fn(),
}))
const mockSegments = ['apps']
const mockPush = vi.fn()
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockIsPending = false
let mockIsUninstallPending = false
let mockInstalledApps: InstalledApp[] = []
let intersectionCallback: IntersectionObserverCallback | undefined
vi.mock('@/next/navigation', () => ({
usePathname: () => '/',
useSelectedLayoutSegments: () => mockSegments,
useRouter: () => ({
push: mockPush,
}),
useSelectedLayoutSegments: () => ['apps'],
}))
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: () => ({
isPending: mockIsPending,
data: { installed_apps: mockInstalledApps },
}),
useUninstallApp: () => ({
mutateAsync: mockUninstall,
isPending: mockIsUninstallPending,
}),
useUpdateAppPinStatus: () => ({
mutateAsync: mockUpdatePinStatus,
}),
vi.mock('@/service/client', () => ({
consoleQuery: {
systemFeatures: {
get: {
queryKey: () => ['system-features'],
queryOptions: () => ({
queryKey: ['system-features'],
queryFn: () => new Promise(() => {}),
}),
},
},
installedApps: {
get: {
infiniteOptions: (options: {
getNextPageParam: (page: {
has_more: boolean
next_cursor: string | null
}) => string | undefined
initialPageParam: undefined
input: (pageParam: string | undefined) => unknown
select?: (data: unknown) => unknown
}) => ({
...options,
queryKey: ['installed-apps'],
queryFn: async ({ pageParam }: { pageParam: string | undefined }) => {
if (pageParam) mocks.fetchNextPage(pageParam)
return {
installed_apps: pageParam ? [] : mocks.installedApps,
has_more: pageParam ? false : mocks.hasNextPage,
next_cursor: pageParam || !mocks.hasNextPage ? null : 'next-page',
}
},
}),
},
byInstalledAppId: {
delete: {
mutationOptions: () => ({
mutationFn: (input: unknown) => mocks.uninstall(input),
}),
},
patch: {
mutationOptions: () => ({
mutationFn: (input: unknown) => mocks.updatePinStatus(input),
}),
},
},
},
},
}))
vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
@ -45,193 +76,122 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
...actual,
toast: {
...actual.toast,
success: mockToastSuccess,
success: mocks.toastSuccess,
},
}
})
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
id: overrides.id ?? 'app-123',
uninstallable: overrides.uninstallable ?? false,
const createInstalledApp = (
overrides: Partial<InstalledAppResponse> = {},
): InstalledAppResponse => ({
id: overrides.id ?? 'installed-app-1',
app_owner_tenant_id: overrides.app_owner_tenant_id ?? 'tenant-1',
editable: overrides.editable ?? true,
is_pinned: overrides.is_pinned ?? false,
last_used_at: overrides.last_used_at ?? null,
uninstallable: overrides.uninstallable ?? false,
app: {
id: overrides.app?.id ?? 'app-basic-id',
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
id: overrides.app?.id ?? 'app-1',
name: overrides.app?.name ?? 'My App',
description: overrides.app?.description ?? 'Description',
mode: overrides.app?.mode ?? 'chat',
icon_type: overrides.app?.icon_type ?? 'emoji',
icon: overrides.app?.icon ?? '🤖',
icon_background: overrides.app?.icon_background ?? '#fff',
icon_url: overrides.app?.icon_url ?? '',
name: overrides.app?.name ?? 'My App',
description: overrides.app?.description ?? 'desc',
icon_background: overrides.app?.icon_background ?? '#FFFFFF',
icon_url: overrides.app?.icon_url ?? null,
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
},
})
const renderSideBar = () => {
return render(<SideBar />)
}
const renderSideBar = () => renderWithConsoleQuery(<SideBar />)
describe('SideBar', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsPending = false
mockIsUninstallPending = false
mockInstalledApps = []
mocks.installedApps = []
mocks.hasNextPage = false
mocks.uninstall.mockResolvedValue(undefined)
mocks.updatePinStatus.mockResolvedValue({ result: 'success', message: 'updated' })
intersectionCallback = undefined
vi.stubGlobal(
'IntersectionObserver',
class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
if (options?.root) intersectionCallback = callback
}
observe() {}
unobserve() {}
disconnect() {}
},
)
})
describe('Rendering', () => {
it('should render discovery link', () => {
renderSideBar()
it('renders the empty state after the installed-app query settles', async () => {
renderSideBar()
expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
expect(await screen.findByText('explore.sidebar.noApps.title')).toBeInTheDocument()
})
it('renders installed apps and folds to icon-only navigation', async () => {
const user = userEvent.setup()
mocks.installedApps = [createInstalledApp()]
renderSideBar()
expect(await screen.findByRole('link', { name: 'My App' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'My App' })).toBeInTheDocument()
})
it('automatically fetches the next page when the sentinel enters the scroll viewport', async () => {
mocks.installedApps = [createInstalledApp()]
mocks.hasNextPage = true
renderSideBar()
await screen.findByRole('region', { name: 'explore.sidebar.webApps' })
expect(intersectionCallback).toBeDefined()
act(() => {
intersectionCallback?.(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
it('should render workspace items when installed apps exist', () => {
mockInstalledApps = [createInstalledApp()]
renderSideBar()
await waitFor(() => expect(mocks.fetchNextPage).toHaveBeenCalledWith('next-page'))
})
expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'explore.sidebar.webApps' })).toBeInTheDocument()
expect(screen.getByText('My App')).toBeInTheDocument()
})
it('uninstalls the selected app and closes the confirmation after success', async () => {
const user = userEvent.setup()
mocks.installedApps = [createInstalledApp()]
renderSideBar()
it('should render NoApps component when no installed apps on desktop', () => {
renderSideBar()
await user.click(await screen.findByRole('button', { name: 'common.operation.more' }))
await user.click(await screen.findByText('explore.sidebar.action.delete'))
await user.click(screen.getByText('common.operation.confirm'))
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()
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
})
it('should render divider between pinned and unpinned apps', () => {
mockInstalledApps = [
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 dividers = container.querySelectorAll('[class*="divider"], hr')
expect(dividers.length).toBeGreaterThan(0)
})
it('should render a button for toggling the sidebar and update its accessible name', () => {
renderSideBar()
const toggleButton = screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' })
fireEvent.click(toggleButton)
expect(
screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' }),
).toBeInTheDocument()
})
it('should render icon-only content when folded', () => {
mockInstalledApps = [createInstalledApp()]
renderSideBar()
fireEvent.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'My App' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' }),
).toBeInTheDocument()
await waitFor(() => {
expect(mocks.uninstall).toHaveBeenCalledWith({
params: { installed_app_id: 'installed-app-1' },
})
expect(mocks.toastSuccess).toHaveBeenCalledWith('common.api.remove')
})
})
describe('User Interactions', () => {
it('should uninstall app and show toast when delete is confirmed', async () => {
mockInstalledApps = [createInstalledApp()]
mockUninstall.mockResolvedValue(undefined)
renderSideBar()
it('updates pin state through the generated mutation input', async () => {
const user = userEvent.setup()
mocks.installedApps = [createInstalledApp()]
renderSideBar()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
fireEvent.click(await screen.findByText('common.operation.confirm'))
await user.click(await screen.findByRole('button', { name: 'common.operation.more' }))
await user.click(await screen.findByText('explore.sidebar.action.pin'))
await waitFor(() => {
expect(mockUninstall).toHaveBeenCalledWith('app-123')
expect(mockToastSuccess).toHaveBeenCalledWith('common.api.remove')
})
})
it('should update pin status and show toast when pin is clicked', async () => {
mockInstalledApps = [createInstalledApp({ is_pinned: false })]
mockUpdatePinStatus.mockResolvedValue(undefined)
renderSideBar()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
await waitFor(() => {
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: true })
expect(mockToastSuccess).toHaveBeenCalledWith('common.api.success')
})
})
it('should unpin an already pinned app', async () => {
mockInstalledApps = [createInstalledApp({ is_pinned: true })]
mockUpdatePinStatus.mockResolvedValue(undefined)
renderSideBar()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByText('explore.sidebar.action.unpin'))
await waitFor(() => {
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: false })
})
})
it('should open and close confirm dialog for delete', async () => {
mockInstalledApps = [createInstalledApp()]
renderSideBar()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument()
fireEvent.click(screen.getByText('common.operation.cancel'))
await waitFor(() => {
expect(mockUninstall).not.toHaveBeenCalled()
})
})
it('should disable dialog actions while uninstall is pending', async () => {
mockInstalledApps = [createInstalledApp()]
mockIsUninstallPending = true
renderSideBar()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
expect(screen.getByText('common.operation.cancel')).toBeDisabled()
expectLoadingButton(screen.getByText('common.operation.confirm').closest('button'))
})
await waitFor(() =>
expect(mocks.updatePinStatus).toHaveBeenCalledWith({
params: { installed_app_id: 'installed-app-1' },
body: { is_pinned: true },
}),
)
})
})

View File

@ -1,4 +1,9 @@
'use client'
import type {
InstalledAppListResponse,
InstalledAppResponse,
} from '@dify/contracts/api/console/installed-apps/types.gen'
import type { InfiniteData } from '@tanstack/react-query'
import {
AlertDialog,
AlertDialogActions,
@ -9,81 +14,112 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import {
ScrollAreaContent,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { toast } from '@langgenius/dify-ui/toast'
import { useBoolean } from 'ahooks'
import { useInfiniteQuery, useMutation } from '@tanstack/react-query'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import AppNavItem from '@/app/components/explore/installed-app-navigation/app-nav-item'
import { InfiniteScrollSentinel } from '@/app/components/explore/installed-app-navigation/infinite-scroll-sentinel'
import { InstalledAppPaginationSkeleton } from '@/app/components/explore/installed-app-navigation/pagination-skeleton'
import Link from '@/next/link'
import { usePathname, useSelectedLayoutSegments } from '@/next/navigation'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
import Item from './app-nav-item'
import { consoleQuery } from '@/service/client'
import NoApps from './no-apps'
const emptyInstalledApps: InstalledAppResponse[] = []
const selectInstalledApps = (data: InfiniteData<InstalledAppListResponse, string | undefined>) =>
data.pages.flatMap((page) => page.installed_apps)
const SideBar = () => {
const { t } = useTranslation()
const pathname = usePathname()
const scrollRef = React.useRef<HTMLDivElement>(null)
const segments = useSelectedLayoutSegments()
const lastSegment = segments.slice(-1)[0]
const isDiscoverySelected = pathname === '/' || lastSegment === 'apps'
const { data, isPending } = useGetInstalledApps()
const installedApps = data?.installed_apps ?? []
const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp()
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
const installedAppsQuery = useInfiniteQuery(
consoleQuery.installedApps.get.infiniteOptions({
input: (pageParam: string | undefined) => ({
query: {
limit: 20,
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
},
}),
getNextPageParam: (lastPage) =>
lastPage.has_more && lastPage.next_cursor ? lastPage.next_cursor : undefined,
initialPageParam: undefined,
select: selectInstalledApps,
}),
)
const installedApps = installedAppsQuery.data ?? emptyInstalledApps
const uninstallAppMutation = useMutation(
consoleQuery.installedApps.byInstalledAppId.delete.mutationOptions(),
)
const updatePinStatusMutation = useMutation(
consoleQuery.installedApps.byInstalledAppId.patch.mutationOptions(),
)
const [isFold, { toggle: toggleIsFold }] = useBoolean(false)
const [isFold, setIsFold] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [currId, setCurrId] = useState('')
const handleDelete = async () => {
const id = currId
await uninstallApp(id)
setShowConfirm(false)
toast.success(t(($) => $['api.remove'], { ns: 'common' }))
const [uninstallDialogAppId, setUninstallDialogAppId] = useState<string | null>(null)
const handleDelete = () => {
if (!uninstallDialogAppId) return
uninstallAppMutation.mutate(
{
params: { installed_app_id: uninstallDialogAppId },
},
{
onSuccess: () => {
setUninstallDialogAppId(null)
toast.success(t(($) => $['api.remove'], { ns: 'common' }))
},
},
)
}
const handleUpdatePinStatus = async (id: string, isPinned: boolean) => {
await updatePinStatus({ appId: id, isPinned })
toast.success(t(($) => $['api.success'], { ns: 'common' }))
const handleUpdatePinStatus = (id: string, isPinned: boolean) => {
updatePinStatusMutation.mutate(
{
params: { installed_app_id: id },
body: { is_pinned: isPinned },
},
{
onSuccess: () => toast.success(t(($) => $['api.success'], { ns: 'common' })),
},
)
}
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
const webAppsLabelId = React.useId()
const installedAppItems = installedApps.map(
(
{ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } },
index,
) => (
<React.Fragment key={id}>
<Item
name={name}
icon_type={icon_type}
icon={icon}
icon_background={icon_background}
icon_url={icon_url}
id={id}
isSelected={lastSegment?.toLowerCase() === id}
isPinned={is_pinned}
togglePin={() => handleUpdatePinStatus(id, !is_pinned)}
uninstallable={uninstallable}
onDelete={(id) => {
setCurrId(id)
setShowConfirm(true)
}}
/>
{index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && <Divider />}
</React.Fragment>
),
)
const installedAppItems = installedApps.map((installedApp, index) => (
<React.Fragment key={installedApp.id}>
<AppNavItem
app={installedApp}
isSelected={lastSegment?.toLowerCase() === installedApp.id}
onTogglePin={handleUpdatePinStatus}
onDelete={setUninstallDialogAppId}
/>
{index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && <Divider />}
</React.Fragment>
))
return (
<div
data-folded={isFold ? 'true' : undefined}
className={cn(
'group/sidebar flex h-full w-fit shrink-0 cursor-pointer flex-col px-3 pt-6 sm:w-[240px]',
isFold && 'sm:w-[56px]',
isFold && 'sm:w-14',
)}
>
<div className={cn(isDiscoverySelected ? 'text-text-accent' : 'text-text-tertiary')}>
@ -116,9 +152,32 @@ const SideBar = () => {
</Link>
</div>
{!isPending && installedApps.length === 0 && !isFold && (
<div className="mt-5">
<NoApps />
{!installedAppsQuery.isPending &&
!installedAppsQuery.isError &&
installedApps.length === 0 &&
!isFold && (
<div className="mt-5">
<NoApps />
</div>
)}
{!installedAppsQuery.isPending && installedAppsQuery.isError && !isFold && (
<div
className="mt-5 flex flex-col items-start gap-1 px-2 system-xs-regular text-text-tertiary"
role="alert"
>
<span>{t(($) => $['errorBoundary.title'], { ns: 'common' })}</span>
<button
type="button"
className="text-text-accent outline-hidden hover:underline focus-visible:underline"
onClick={() => {
if (installedAppsQuery.isFetchNextPageError)
void installedAppsQuery.fetchNextPage({ cancelRefetch: false })
else void installedAppsQuery.refetch()
}}
>
{t(($) => $['operation.retry'], { ns: 'common' })}
</button>
</div>
)}
@ -134,20 +193,52 @@ const SideBar = () => {
)}
{!isFold ? (
<div className="min-h-0 flex-1">
<ScrollArea
className="h-full"
slotClassNames={{
viewport: 'overscroll-contain',
content: 'space-y-0.5 pr-3',
}}
labelledBy={webAppsLabelId}
>
{installedAppItems}
</ScrollArea>
<ScrollAreaRoot className="h-full">
<ScrollAreaViewport
ref={scrollRef}
aria-busy={installedAppsQuery.isFetchingNextPage}
aria-labelledby={webAppsLabelId}
className="overscroll-contain"
role="region"
>
<ScrollAreaContent className="space-y-0.5 pr-3">
{installedAppItems}
{installedAppsQuery.isFetchingNextPage && <InstalledAppPaginationSkeleton />}
<InfiniteScrollSentinel
canFetchNextPage={installedAppsQuery.hasNextPage && !installedAppsQuery.error}
fetchNextPage={() =>
installedAppsQuery.fetchNextPage({
cancelRefetch: false,
})
}
isFetchingNextPage={installedAppsQuery.isFetchingNextPage}
scrollRootRef={scrollRef}
/>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</div>
) : (
<div className="h-full min-h-0 flex-1 space-y-0.5 overflow-x-hidden overflow-y-auto">
<div
ref={scrollRef}
aria-busy={installedAppsQuery.isFetchingNextPage}
className="h-full min-h-0 flex-1 space-y-0.5 overflow-x-hidden overflow-y-auto"
>
{installedAppItems}
{installedAppsQuery.isFetchingNextPage && <InstalledAppPaginationSkeleton />}
<InfiniteScrollSentinel
canFetchNextPage={installedAppsQuery.hasNextPage && !installedAppsQuery.error}
fetchNextPage={() =>
installedAppsQuery.fetchNextPage({
cancelRefetch: false,
})
}
isFetchingNextPage={installedAppsQuery.isFetchingNextPage}
scrollRootRef={scrollRef}
/>
</div>
)}
</div>
@ -162,7 +253,7 @@ const SideBar = () => {
: t(($) => $['sidebar.collapseSidebar'], { ns: 'layout' })
}
className="flex size-8 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-state-base-hover focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-hover focus-visible:outline-hidden"
onClick={toggleIsFold}
onClick={() => setIsFold((value) => !value)}
>
{isFold ? (
<span aria-hidden="true" className="i-ri-expand-right-line" />
@ -172,7 +263,12 @@ const SideBar = () => {
</button>
</div>
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
<AlertDialog
open={uninstallDialogAppId !== null}
onOpenChange={(open) => {
if (!open) setUninstallDialogAppId(null)
}}
>
<AlertDialogContent>
<div className="flex flex-col items-start gap-2 self-stretch px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
@ -183,12 +279,12 @@ const SideBar = () => {
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={isUninstalling}>
<AlertDialogCancelButton disabled={uninstallAppMutation.isPending}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isUninstalling}
disabled={isUninstalling}
loading={uninstallAppMutation.isPending}
disabled={uninstallAppMutation.isPending}
onClick={handleDelete}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}

View File

@ -1,3 +1,4 @@
import type { InstalledAppResponse } from '@dify/contracts/api/console/installed-apps/types.gen'
import type {
StepByStepTourStatePatchPayload,
StepByStepTourStateResponse,
@ -8,10 +9,9 @@ import type { StepByStepTourSessionState } from '@/app/components/step-by-step-t
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import type { ICurrentWorkspace, IWorkspace } from '@/models/common'
import type { InstalledApp } from '@/models/explore'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
@ -27,7 +27,6 @@ import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { usePathname, useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture'
import { AppModeEnum } from '@/types/app'
@ -39,10 +38,22 @@ const activeGradientMaskClassName = 'aria-[current=page]:dify-blue-glass-surface
const activeStackingClassName = 'aria-[current=page]:z-1'
const mockTrackEvent = vi.hoisted(() => vi.fn())
const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess } = vi.hoisted(() => ({
const {
mockFetchNextInstalledAppsPage,
mockInstalledAppsRequest,
mockIsAgentV2Enabled,
mockSwitchWorkspace,
mockToastSuccess,
mockUninstall,
mockUpdatePinStatus,
} = vi.hoisted(() => ({
mockFetchNextInstalledAppsPage: vi.fn(),
mockInstalledAppsRequest: vi.fn(),
mockIsAgentV2Enabled: vi.fn(() => true),
mockSwitchWorkspace: vi.fn(),
mockToastSuccess: vi.fn(),
mockIsAgentV2Enabled: vi.fn(() => true),
mockUninstall: vi.fn(),
mockUpdatePinStatus: vi.fn(),
}))
const mockStepByStepTour = vi.hoisted(() => {
const stateQueryKey = ['console', 'onboarding', 'step-by-step-tour', 'state'] as const
@ -295,6 +306,41 @@ vi.mock('@/service/client', async (importOriginal) => {
},
}
}
if (prop === 'installedApps') {
return {
get: {
infiniteOptions: (options: {
getNextPageParam: (page: {
has_more: boolean
next_cursor: string | null
}) => string | undefined
initialPageParam: undefined
input: (pageParam: string | undefined) => {
query: { cursor?: string; limit: number; name?: string }
}
placeholderData?: unknown
select?: (data: unknown) => unknown
}) => ({
...options,
queryKey: ['installed-apps', options.input(undefined).query.name ?? ''],
queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
mockInstalledAppsRequest(options.input(pageParam)),
}),
},
byInstalledAppId: {
delete: {
mutationOptions: () => ({
mutationFn: (input: unknown) => mockUninstall(input),
}),
},
patch: {
mutationOptions: () => ({
mutationFn: (input: unknown) => mockUpdatePinStatus(input),
}),
},
},
}
}
return Reflect.get(target, prop, receiver)
},
@ -306,12 +352,6 @@ vi.mock('@/service/client', async (importOriginal) => {
}
})
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: vi.fn(),
useUninstallApp: vi.fn(),
useUpdateAppPinStatus: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@langgenius/dify-ui/toast')>()
return {
@ -348,11 +388,10 @@ vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockPathname = '/apps'
let mockInstalledApps: InstalledApp[] = []
let mockInstalledApps: InstalledAppResponse[] = []
let mockInstalledAppsPending = false
let mockInstalledAppsHasNextPage = false
let mockWorkspaces: IWorkspace[] = []
const ownerWorkspacePermissionKeys = [
@ -373,8 +412,13 @@ const datasetOperatorWorkspacePermissionKeys = [
'dataset.external.connect',
]
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
const createInstalledApp = (
overrides: Partial<InstalledAppResponse> = {},
): InstalledAppResponse => ({
id: overrides.id ?? 'installed-1',
app_owner_tenant_id: overrides.app_owner_tenant_id ?? 'tenant-1',
editable: overrides.editable ?? true,
last_used_at: overrides.last_used_at ?? null,
uninstallable: overrides.uninstallable ?? false,
is_pinned: overrides.is_pinned ?? false,
app: {
@ -383,7 +427,7 @@ const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp
icon_type: overrides.app?.icon_type ?? 'emoji',
icon: overrides.app?.icon ?? '🤖',
icon_background: overrides.app?.icon_background ?? '#fff',
icon_url: overrides.app?.icon_url ?? '',
icon_url: overrides.app?.icon_url ?? null,
name: overrides.app?.name ?? 'Alpha App',
description: overrides.app?.description ?? '',
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
@ -501,6 +545,7 @@ describe('MainNav', () => {
mockPathname = '/apps'
mockInstalledApps = []
mockInstalledAppsPending = false
mockInstalledAppsHasNextPage = false
mockWorkspaces = [
{
id: 'workspace-1',
@ -542,17 +587,28 @@ describe('MainNav', () => {
;(useModalContext as Mock).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
} as unknown as ModalContextState)
;(useGetInstalledApps as Mock).mockImplementation(() => ({
isPending: mockInstalledAppsPending,
data: { installed_apps: mockInstalledApps },
}))
;(useUninstallApp as Mock).mockReturnValue({
mutateAsync: mockUninstall,
isPending: false,
})
;(useUpdateAppPinStatus as Mock).mockReturnValue({
mutateAsync: mockUpdatePinStatus,
})
mockInstalledAppsRequest.mockImplementation(
async ({ query }: { query: { cursor?: string; name?: string } }) => {
if (mockInstalledAppsPending) return new Promise(() => {})
if (query.cursor) {
mockFetchNextInstalledAppsPage(query.cursor)
return { installed_apps: [], has_more: false, next_cursor: null }
}
const installedApps = query.name
? mockInstalledApps.filter((installedApp) =>
installedApp.app.name.toLowerCase().includes(query.name!.toLowerCase()),
)
: mockInstalledApps
return {
installed_apps: installedApps,
has_more: mockInstalledAppsHasNextPage,
next_cursor: mockInstalledAppsHasNextPage ? 'next-page' : null,
}
},
)
mockUninstall.mockResolvedValue(undefined)
mockUpdatePinStatus.mockResolvedValue({ result: 'success', message: 'updated' })
mockSwitchWorkspace.mockReturnValue(new Promise(() => {}))
})
@ -1212,7 +1268,8 @@ describe('MainNav', () => {
expect(screen.queryByText('common.mainNav.workspace.inviteMembers')).not.toBeInTheDocument()
})
it('filters installed web apps and renders installed app navigation link', () => {
it('searches installed web apps and renders the matching navigation link', async () => {
const user = userEvent.setup()
mockInstalledApps = [
createInstalledApp({
id: 'installed-1',
@ -1226,13 +1283,15 @@ describe('MainNav', () => {
renderMainNav()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.search' }))
fireEvent.change(screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder'), {
target: { value: 'beta' },
})
await user.click(await screen.findByRole('button', { name: 'common.operation.search' }))
const searchInput = screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder')
await user.type(searchInput, 'beta')
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
expect(screen.getByText('Beta Tool')).toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
expect(screen.getByText('Beta Tool')).toBeInTheDocument()
})
expect(searchInput).toHaveFocus()
expect(
screen.getByRole('link', { name: 'common.mainNav.webApps.openApp:{"name":"Beta Tool"}' }),
).toHaveAttribute('href', '/installed/installed-2')
@ -1257,22 +1316,24 @@ describe('MainNav', () => {
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
})
it('hides the installed web apps section when no web apps are available', () => {
it('hides the installed web apps section when no web apps are available', async () => {
renderMainNav()
expect(
screen.queryByRole('button', { name: 'explore.sidebar.webApps' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('region', { name: 'explore.sidebar.webApps' }),
).not.toBeInTheDocument()
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'explore.sidebar.webApps' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('region', { name: 'explore.sidebar.webApps' }),
).not.toBeInTheDocument()
})
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'common.operation.search' }),
).not.toBeInTheDocument()
})
it('separates pinned and unpinned installed web apps', () => {
it('separates pinned and unpinned installed web apps', async () => {
mockInstalledApps = [
createInstalledApp({
id: 'installed-1',
@ -1288,12 +1349,12 @@ describe('MainNav', () => {
renderMainNav()
expect(screen.getByText('Pinned App')).toBeInTheDocument()
expect(await screen.findByText('Pinned App')).toBeInTheDocument()
expect(screen.getByText('Unpinned App')).toBeInTheDocument()
expect(screen.getByTestId('divider')).toBeInTheDocument()
})
it('keeps long installed web app names truncated in the main nav item', () => {
it('keeps long installed web app names truncated in the main nav item', async () => {
const longName = 'A very long installed web app name that should stay on one line and truncate'
mockInstalledApps = [
createInstalledApp({
@ -1304,39 +1365,42 @@ describe('MainNav', () => {
renderMainNav()
expect(screen.getByText(longName)).toHaveClass('truncate')
expect(await screen.findByText(longName)).toHaveClass('truncate')
})
it('virtualizes large installed web app lists', async () => {
const offsetHeightSpy = vi
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
.mockReturnValue(320)
const offsetWidthSpy = vi
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
.mockReturnValue(240)
mockInstalledApps = Array.from({ length: 100 }, (_, index) =>
createInstalledApp({
id: `installed-${index}`,
app: {
...createInstalledApp().app,
id: `app-${index}`,
name: `Web App ${index}`,
},
}),
it('fetches the next installed web app page when the bottom sentinel enters the viewport', async () => {
let intersectionCallback: IntersectionObserverCallback | undefined
vi.stubGlobal(
'IntersectionObserver',
class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
if (options?.root) intersectionCallback = callback
}
observe() {}
unobserve() {}
disconnect() {}
},
)
mockInstalledApps = [createInstalledApp()]
mockInstalledAppsHasNextPage = true
renderMainNav()
await screen.findByText('Alpha App')
try {
renderMainNav()
act(() => {
intersectionCallback?.(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(await screen.findByText('Web App 0')).toBeInTheDocument()
expect(screen.queryByText('Web App 99')).not.toBeInTheDocument()
} finally {
offsetHeightSpy.mockRestore()
offsetWidthSpy.mockRestore()
}
await waitFor(() => {
expect(mockFetchNextInstalledAppsPage).toHaveBeenCalledWith('next-page')
})
})
it('collapses and expands installed web apps from the section arrow', async () => {
const user = userEvent.setup()
mockInstalledApps = [createInstalledApp()]
renderMainNav()
@ -1345,39 +1409,45 @@ describe('MainNav', () => {
expect(webAppsButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Alpha App')).toBeInTheDocument()
fireEvent.click(webAppsButton)
await user.click(webAppsButton)
expect(webAppsButton).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
fireEvent.click(webAppsButton)
await user.click(webAppsButton)
expect(webAppsButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Alpha App')).toBeInTheDocument()
})
it('updates pin status and reuses the existing delete confirmation for installed web apps', async () => {
const user = userEvent.setup()
mockInstalledApps = [createInstalledApp()]
mockUninstall.mockResolvedValue(undefined)
mockUpdatePinStatus.mockResolvedValue(undefined)
renderMainNav()
fireEvent.mouseEnter(screen.getByText('Alpha App'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
await user.hover(await screen.findByText('Alpha App'))
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(await screen.findByText('explore.sidebar.action.pin'))
await waitFor(() => {
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'installed-1', isPinned: true })
expect(mockUpdatePinStatus).toHaveBeenCalledWith({
params: { installed_app_id: 'installed-1' },
body: { is_pinned: true },
})
})
fireEvent.mouseEnter(screen.getByText('Alpha App'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
fireEvent.click(await screen.findByText('common.operation.confirm'))
await user.hover(screen.getByText('Alpha App'))
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(await screen.findByText('explore.sidebar.action.delete'))
await user.click(await screen.findByText('common.operation.confirm'))
await waitFor(() => {
expect(mockUninstall).toHaveBeenCalledWith('installed-1')
expect(mockUninstall).toHaveBeenCalledWith({
params: { installed_app_id: 'installed-1' },
})
expect(mockToastSuccess).toHaveBeenCalledWith('common.api.remove')
})
})

View File

@ -1,6 +1,10 @@
'use client'
import type { InstalledApp } from '@/models/explore'
import type {
InstalledAppListResponse,
InstalledAppResponse,
} from '@dify/contracts/api/console/installed-apps/types.gen'
import type { InfiniteData } from '@tanstack/react-query'
import {
AlertDialog,
AlertDialogActions,
@ -19,26 +23,28 @@ import {
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { toast } from '@langgenius/dify-ui/toast'
import { useVirtualizer } from '@tanstack/react-virtual'
import { keepPreviousData, useInfiniteQuery, useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { Fragment, useMemo, useRef, useState } from 'react'
import { Fragment, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { SearchInput } from '@/app/components/base/search-input'
import AppNavItem from '@/app/components/explore/installed-app-navigation/app-nav-item'
import { InfiniteScrollSentinel } from '@/app/components/explore/installed-app-navigation/infinite-scroll-sentinel'
import { InstalledAppPaginationSkeleton } from '@/app/components/explore/installed-app-navigation/pagination-skeleton'
import { isInstalledAppPath } from '@/app/components/explore/installed-app/routes'
import AppNavItem from '@/app/components/explore/sidebar/app-nav-item'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { usePathname } from '@/next/navigation'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
import { consoleQuery } from '@/service/client'
import { hasPermission } from '@/utils/permission'
const appNavItemHeight = 32
const appNavItemGap = 2
const appNavSeparatorHeight = 17
const virtualizationThreshold = 50
const webAppSkeletonClassName =
'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none'
const webAppSkeletonWidths = ['w-24', 'w-32', 'w-28']
const emptyInstalledApps: InstalledAppResponse[] = []
const selectInstalledApps = (data: InfiniteData<InstalledAppListResponse, string | undefined>) =>
data.pages.flatMap((page) => page.installed_apps)
function WebAppsHeaderSkeleton() {
return (
@ -65,123 +71,95 @@ function WebAppsSkeleton() {
)
}
type WebAppListRow =
| {
key: string
kind: 'app'
app: InstalledApp
}
| {
key: string
kind: 'separator'
}
const WebAppsSectionContent = () => {
const { t } = useTranslation()
const pathname = usePathname()
const scrollRef = useRef<HTMLDivElement>(null)
const { data, isPending } = useGetInstalledApps()
const installedApps = useMemo(() => data?.installed_apps ?? [], [data?.installed_apps])
const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp()
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
const [appsExpanded, setAppsExpanded] = useState(true)
const [searchVisible, setSearchVisible] = useState(false)
const [searchText, setSearchText] = useState('')
const [showConfirm, setShowConfirm] = useState(false)
const [currentId, setCurrentId] = useState('')
const [uninstallDialogAppId, setUninstallDialogAppId] = useState<string | null>(null)
const normalizedSearchText = searchText.trim()
const filteredApps = useMemo(() => {
const normalizedSearch = searchText.trim().toLowerCase()
if (!normalizedSearch) return installedApps
return installedApps.filter((item) => item.app.name.toLowerCase().includes(normalizedSearch))
}, [installedApps, searchText])
const webAppRows = useMemo<WebAppListRow[]>(() => {
const pinnedAppsCount = filteredApps.filter(({ is_pinned }) => is_pinned).length
return filteredApps.flatMap((app, index) => {
const rows: WebAppListRow[] = [
{
key: app.id,
kind: 'app',
app,
const installedAppsQuery = useInfiniteQuery(
consoleQuery.installedApps.get.infiniteOptions({
input: (pageParam: string | undefined) => ({
query: {
limit: 20,
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
...(normalizedSearchText ? { name: normalizedSearchText } : {}),
},
]
}),
getNextPageParam: (lastPage) =>
lastPage.has_more && lastPage.next_cursor ? lastPage.next_cursor : undefined,
initialPageParam: undefined,
placeholderData: keepPreviousData,
select: selectInstalledApps,
}),
)
const installedApps = installedAppsQuery.data ?? emptyInstalledApps
const uninstallAppMutation = useMutation(
consoleQuery.installedApps.byInstalledAppId.delete.mutationOptions(),
)
const updatePinStatusMutation = useMutation(
consoleQuery.installedApps.byInstalledAppId.patch.mutationOptions(),
)
if (index === pinnedAppsCount - 1 && index !== filteredApps.length - 1) {
rows.push({
key: `${app.id}-separator`,
kind: 'separator',
})
}
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
return rows
})
}, [filteredApps])
const shouldVirtualize = webAppRows.length > virtualizationThreshold
const handleDelete = () => {
if (!uninstallDialogAppId) return
const rowVirtualizer = useVirtualizer({
count: webAppRows.length,
estimateSize: (index) =>
webAppRows[index]?.kind === 'separator' ? appNavSeparatorHeight : appNavItemHeight,
gap: appNavItemGap,
getItemKey: (index) => webAppRows[index]?.key ?? index,
getScrollElement: () => scrollRef.current,
overscan: 6,
paddingEnd: 8,
})
const virtualRows = rowVirtualizer.getVirtualItems()
const handleDelete = async () => {
await uninstallApp(currentId)
setShowConfirm(false)
toast.success(t(($) => $['api.remove'], { ns: 'common' }))
uninstallAppMutation.mutate(
{
params: { installed_app_id: uninstallDialogAppId },
},
{
onSuccess: () => {
setUninstallDialogAppId(null)
toast.success(t(($) => $['api.remove'], { ns: 'common' }))
},
},
)
}
const handleUpdatePinStatus = async (id: string, isPinned: boolean) => {
await updatePinStatus({ appId: id, isPinned })
toast.success(t(($) => $['api.success'], { ns: 'common' }))
const handleUpdatePinStatus = (id: string, isPinned: boolean) => {
updatePinStatusMutation.mutate(
{
params: { installed_app_id: id },
body: { is_pinned: isPinned },
},
{
onSuccess: () => toast.success(t(($) => $['api.success'], { ns: 'common' })),
},
)
}
if (!isPending && installedApps.length === 0) return null
if (
!installedAppsQuery.isPending &&
!installedAppsQuery.isError &&
installedApps.length === 0 &&
!normalizedSearchText
)
return null
const renderAppNavItem = ({
id,
is_pinned,
uninstallable,
app,
}: (typeof filteredApps)[number]) => (
const renderAppNavItem = (installedApp: (typeof installedApps)[number]) => (
<AppNavItem
key={id}
key={installedApp.id}
variant="mainNav"
name={app.name}
ariaLabel={t(($) => $['mainNav.webApps.openApp'], { ns: 'common', name: app.name })}
icon_type={app.icon_type}
icon={app.icon}
icon_background={app.icon_background}
icon_url={app.icon_url}
id={id}
isSelected={isInstalledAppPath(pathname, id)}
isPinned={is_pinned}
togglePin={() => {
void handleUpdatePinStatus(id, !is_pinned)
}}
uninstallable={uninstallable}
onDelete={(id) => {
setCurrentId(id)
setShowConfirm(true)
}}
app={installedApp}
ariaLabel={t(($) => $['mainNav.webApps.openApp'], {
ns: 'common',
name: installedApp.app.name,
})}
isSelected={isInstalledAppPath(pathname, installedApp.id)}
onTogglePin={handleUpdatePinStatus}
onDelete={setUninstallDialogAppId}
/>
)
const renderRow = (row: WebAppListRow) => {
if (row.kind === 'separator') return <Divider />
return renderAppNavItem(row.app)
}
return (
<div className="flex min-h-0 flex-1 flex-col">
{isPending ? (
{installedAppsQuery.isPending ? (
<WebAppsHeaderSkeleton />
) : (
<div className="flex items-center justify-between py-1 pr-2 pl-2">
@ -210,7 +188,10 @@ const WebAppsSectionContent = () => {
)}
onClick={() => {
setAppsExpanded(true)
setSearchVisible((value) => !value)
setSearchVisible((value) => {
if (value) setSearchText('')
return !value
})
}}
>
<span className="flex size-5 shrink-0 items-center justify-center">
@ -220,7 +201,7 @@ const WebAppsSectionContent = () => {
</div>
</div>
)}
{!isPending && appsExpanded && searchVisible && (
{!installedAppsQuery.isPending && appsExpanded && searchVisible && (
<div className="px-2 pb-2">
<SearchInput
value={searchText}
@ -235,50 +216,62 @@ const WebAppsSectionContent = () => {
<ScrollAreaRoot className="relative min-h-0 flex-1 overflow-hidden overscroll-contain">
<ScrollAreaViewport
ref={scrollRef}
aria-busy={isPending}
aria-busy={installedAppsQuery.isPending || installedAppsQuery.isFetchingNextPage}
aria-label={t(($) => $['sidebar.webApps'], { ns: 'explore' })}
className="overflow-x-hidden"
role="region"
>
<ScrollAreaContent className="w-full max-w-full min-w-0! px-2">
{isPending && <WebAppsSkeleton />}
{!isPending && filteredApps.length === 0 && (
<div className="px-2 py-1 system-xs-regular">
{t(($) => $['mainNav.webApps.noResults'], { ns: 'common' })}
{installedAppsQuery.isPending && <WebAppsSkeleton />}
{!installedAppsQuery.isPending && installedAppsQuery.isError && (
<div
className="flex flex-col items-start gap-1 px-2 py-2 system-xs-regular text-text-tertiary"
role="alert"
>
<span>{t(($) => $['errorBoundary.title'], { ns: 'common' })}</span>
<button
type="button"
className="text-text-accent outline-hidden hover:underline focus-visible:underline"
onClick={() => {
if (installedAppsQuery.isFetchNextPageError)
void installedAppsQuery.fetchNextPage({ cancelRefetch: false })
else void installedAppsQuery.refetch()
}}
>
{t(($) => $['operation.retry'], { ns: 'common' })}
</button>
</div>
)}
{!isPending && webAppRows.length > 0 && !shouldVirtualize && (
{!installedAppsQuery.isPending &&
!installedAppsQuery.isError &&
installedApps.length === 0 && (
<div className="px-2 py-1 system-xs-regular">
{t(($) => $['mainNav.webApps.noResults'], { ns: 'common' })}
</div>
)}
{!installedAppsQuery.isPending && installedApps.length > 0 && (
<div className="space-y-0.5 pb-2">
{webAppRows.map((row) => (
<Fragment key={row.key}>{renderRow(row)}</Fragment>
{installedApps.map((installedApp, index) => (
<Fragment key={installedApp.id}>
{renderAppNavItem(installedApp)}
{index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && (
<Divider />
)}
</Fragment>
))}
</div>
)}
{!isPending && shouldVirtualize && (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{virtualRows.map((virtualRow) => {
const row = webAppRows[virtualRow.index]!
return (
<div
key={virtualRow.key}
className="absolute top-0 left-0 w-full"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderRow(row)}
</div>
)
})}
</div>
)}
{installedAppsQuery.isFetchingNextPage && <InstalledAppPaginationSkeleton />}
<InfiniteScrollSentinel
canFetchNextPage={installedAppsQuery.hasNextPage && !installedAppsQuery.error}
fetchNextPage={() =>
installedAppsQuery.fetchNextPage({
cancelRefetch: false,
})
}
isFetchingNextPage={installedAppsQuery.isFetchingNextPage}
scrollRootRef={scrollRef}
/>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
@ -286,7 +279,12 @@ const WebAppsSectionContent = () => {
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)}
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
<AlertDialog
open={uninstallDialogAppId !== null}
onOpenChange={(open) => {
if (!open) setUninstallDialogAppId(null)
}}
>
<AlertDialogContent>
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-6 pb-4 pl-6">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
@ -297,12 +295,12 @@ const WebAppsSectionContent = () => {
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={isUninstalling}>
<AlertDialogCancelButton disabled={uninstallAppMutation.isPending}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isUninstalling}
disabled={isUninstalling}
loading={uninstallAppMutation.isPending}
disabled={uninstallAppMutation.isPending}
onClick={handleDelete}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}

View File

@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import type { InputValueTypes, TextGenerationRunControl, TextGenerationTranslate } from './types'
import type { InstalledApp } from '@/models/explore'
import type { VisionFile } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
@ -18,7 +17,6 @@ import TextGenerationSidebar from './text-generation-sidebar'
type IMainProps = {
isInstalledApp?: boolean
installedAppInfo?: InstalledApp
isWorkflow?: boolean
}
const TextGeneration: FC<IMainProps> = ({ isInstalledApp = false, isWorkflow = false }) => {

View File

@ -30,10 +30,3 @@ export type App = {
is_agent: boolean
can_trial: boolean
}
export type InstalledApp = {
app: AppBasicInfo
id: string
uninstallable: boolean
is_pinned: boolean
}

View File

@ -558,6 +558,51 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
},
},
},
installedApps: {
byInstalledAppId: {
get: {
queryOptions: {
retry: (failureCount, error) => {
if (error instanceof Response && error.status === 404) return false
return failureCount < 3
},
},
},
delete: {
mutationOptions: {
onSuccess: (_response, variables, _onMutateResult, context) => {
context.client.removeQueries({
queryKey: consoleQuery.installedApps.byInstalledAppId.get.queryKey({
input: {
params: variables.params,
},
}),
})
context.client.invalidateQueries({
queryKey: consoleQuery.installedApps.get.key(),
})
},
},
},
patch: {
mutationOptions: {
onSuccess: (_response, variables, _onMutateResult, context) => {
context.client.invalidateQueries({
queryKey: consoleQuery.installedApps.get.key(),
})
context.client.invalidateQueries({
queryKey: consoleQuery.installedApps.byInstalledAppId.get.queryKey({
input: {
params: variables.params,
},
}),
})
},
},
},
},
},
agent: {
post: {
mutationOptions: {

View File

@ -1,9 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchAppDetail, fetchAppList, fetchInstalledAppMeta } from './explore'
import { fetchAppDetail, fetchAppList, fetchInstalledAppList } from './explore'
const mockExploreAppsGet = vi.hoisted(() => vi.fn())
const mockExploreAppDetailGet = vi.hoisted(() => vi.fn())
const mockInstalledAppMetaGet = vi.hoisted(() => vi.fn())
const mockInstalledAppsGet = vi.hoisted(() => vi.fn())
vi.mock('./client', () => ({
consoleClient: {
@ -16,11 +16,7 @@ vi.mock('./client', () => ({
},
},
installedApps: {
byInstalledAppId: {
meta: {
get: mockInstalledAppMetaGet,
},
},
get: mockInstalledAppsGet,
},
},
}))
@ -70,26 +66,17 @@ describe('explore service normalizers', () => {
})
})
it('preserves provider-defined tool icon payload objects', async () => {
const providerIcon = {
type: 'custom',
value: {
content: 'tool',
background: '#fff',
},
}
mockInstalledAppMetaGet.mockResolvedValue({
tool_icons: {
builtin: '/tool.svg',
provider: providerIcon,
},
it('preserves installed app pagination metadata', async () => {
mockInstalledAppsGet.mockResolvedValue({
installed_apps: [],
has_more: true,
next_cursor: 'next-page',
})
await expect(fetchInstalledAppMeta('installed-app-id')).resolves.toEqual({
tool_icons: {
builtin: '/tool.svg',
provider: providerIcon,
},
await expect(fetchInstalledAppList()).resolves.toEqual({
installed_apps: [],
has_more: true,
next_cursor: 'next-page',
})
})
})

View File

@ -7,21 +7,9 @@ import type {
RecommendedAppInfoResponse,
RecommendedAppResponse,
} from '@dify/contracts/api/console/explore/types.gen'
import type {
ExploreAppMetaResponse,
InstalledAppInfoResponse,
InstalledAppListResponse,
Parameters as InstalledAppParametersResponse,
InstalledAppResponse,
} from '@dify/contracts/api/console/installed-apps/types.gen'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { Banner } from '@/models/app'
import type { App, AppCategory, InstalledApp } from '@/models/explore'
import type { AppMeta, ToolIcon } from '@/models/share'
import type { App, AppCategory } from '@/models/explore'
import type { AppIconType } from '@/types/app'
import { AccessMode } from '@/models/access-control'
import { PromptMode } from '@/models/debug'
import { RETRIEVE_TYPE, TtsAutoPlay } from '@/types/app'
import { consoleClient } from './client'
type ExploreAppsResponse = {
@ -43,16 +31,6 @@ type ExploreAppDetailResponse = {
can_trial: boolean
}
type InstalledAppsResponse = {
installed_apps: InstalledApp[]
}
type AppAccessModeResponse = {
accessMode: AccessMode
}
type InstalledAppParametersViewModel = ChatConfig
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@ -66,11 +44,6 @@ const getStringProperty = (source: object, key: string, fallback = '') => {
return typeof value === 'string' ? value : fallback
}
const getBooleanProperty = (source: object, key: string, fallback = false) => {
const value = getValue(source, key)
return typeof value === 'boolean' ? value : fallback
}
const normalizeAppMode = (value: unknown) => {
return typeof value === 'string' ? value : ''
}
@ -79,40 +52,14 @@ const isAppIconType = (value: unknown): value is AppIconType => {
return value === 'image' || value === 'emoji' || value === 'link'
}
const isAccessMode = (value: unknown): value is AccessMode => {
return (
value === AccessMode.PUBLIC ||
value === AccessMode.SPECIFIC_GROUPS_MEMBERS ||
value === AccessMode.ORGANIZATION ||
value === AccessMode.EXTERNAL_MEMBERS
)
}
const normalizeAccessMode = (value: unknown) => {
if (isAccessMode(value)) return value
throw new Error('Web app access mode response returned an unsupported access mode.')
}
const normalizeAppIconType = (value: unknown) => {
return isAppIconType(value) ? value : null
}
const normalizeAppBasicInfo = (
source: RecommendedAppInfoResponse | InstalledAppInfoResponse | null | undefined,
source: RecommendedAppInfoResponse | null | undefined,
fallbackId: string,
): App['app'] => {
const description =
source && 'description' in source && typeof source.description === 'string'
? source.description
: ''
const useIconAsAnswerIcon =
source &&
'use_icon_as_answer_icon' in source &&
typeof source.use_icon_as_answer_icon === 'boolean'
? source.use_icon_as_answer_icon
: false
return {
id: source?.id ?? fallbackId,
mode: normalizeAppMode(source?.mode),
@ -121,8 +68,8 @@ const normalizeAppBasicInfo = (
icon_background: source?.icon_background ?? '',
icon_url: source?.icon_url ?? '',
name: source?.name ?? '',
description,
use_icon_as_answer_icon: useIconAsAnswerIcon,
description: '',
use_icon_as_answer_icon: false,
}
}
@ -172,23 +119,6 @@ const normalizeAppDetail = (response: RecommendedAppDetailResponse): ExploreAppD
}
}
const normalizeInstalledApp = (installedApp: InstalledAppResponse): InstalledApp => {
return {
app: normalizeAppBasicInfo(installedApp.app, installedApp.app.id),
id: installedApp.id,
uninstallable: installedApp.uninstallable,
is_pinned: installedApp.is_pinned,
}
}
const normalizeInstalledAppsResponse = (
response: InstalledAppListResponse,
): InstalledAppsResponse => {
return {
installed_apps: response.installed_apps.map(normalizeInstalledApp),
}
}
const normalizeBannerContent = (content: unknown): Banner['content'] => {
const record = isRecord(content) ? content : {}
@ -215,153 +145,6 @@ const normalizeBannersResponse = (response: BannerListResponse): Banner[] => {
return response.map(normalizeBanner)
}
const normalizeToolIcons = (value: unknown) => {
const record = isRecord(value) ? value : {}
const result: Record<string, ToolIcon> = {}
Object.entries(record).forEach(([key, item]) => {
if (typeof item === 'string') result[key] = item
else if (isRecord(item)) result[key] = item
})
return result
}
const normalizeAppMeta = (response: ExploreAppMetaResponse): AppMeta => {
return {
tool_icons: normalizeToolIcons(response.tool_icons),
}
}
const isTtsAutoPlay = (value: unknown): value is TtsAutoPlay => {
return value === TtsAutoPlay.enabled || value === TtsAutoPlay.disabled
}
const isUserInputFormItem = (value: unknown): value is ChatConfig['user_input_form'][number] => {
if (!isRecord(value)) return false
return [
'text-input',
'select',
'paragraph',
'number',
'checkbox',
'file',
'file-list',
'external_data_tool',
'json_object',
].some((key) => isRecord(getValue(value, key)))
}
const isModel = (
value: unknown,
): value is NonNullable<ChatConfig['suggested_questions_after_answer']['model']> => {
return isRecord(value)
}
const isAnnotationReplyConfig = (
value: unknown,
): value is NonNullable<ChatConfig['annotation_reply']> => {
return isRecord(value)
}
const isFileUploadConfig = (value: unknown): value is NonNullable<ChatConfig['file_upload']> => {
return isRecord(value)
}
const defaultDatasetConfigs = (): ChatConfig['dataset_configs'] => ({
retrieval_model: RETRIEVE_TYPE.oneWay,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 4,
score_threshold_enabled: false,
score_threshold: null,
datasets: {
datasets: [],
},
})
const normalizeEnabledConfig = (value: unknown): { enabled: boolean } => {
const record = isRecord(value) ? value : {}
return {
...record,
enabled: getBooleanProperty(record, 'enabled'),
}
}
const normalizeSuggestedQuestionsAfterAnswer = (
value: unknown,
): ChatConfig['suggested_questions_after_answer'] => {
const record = isRecord(value) ? value : {}
const model = getValue(record, 'model')
const prompt = getStringProperty(record, 'prompt')
return {
enabled: getBooleanProperty(record, 'enabled'),
...(isModel(model) ? { model } : {}),
...(prompt ? { prompt } : {}),
}
}
const normalizeTextToSpeech = (value: unknown): ChatConfig['text_to_speech'] => {
const record = isRecord(value) ? value : {}
const autoPlay = getValue(record, 'autoPlay')
const normalizedAutoPlay = isTtsAutoPlay(autoPlay) ? autoPlay : undefined
return {
...record,
enabled: getBooleanProperty(record, 'enabled'),
voice: getStringProperty(record, 'voice') || undefined,
language: getStringProperty(record, 'language') || undefined,
...(normalizedAutoPlay ? { autoPlay: normalizedAutoPlay } : {}),
}
}
const normalizeSystemParameters = (
systemParameters: InstalledAppParametersResponse['system_parameters'],
): ChatConfig['system_parameters'] => {
return {
audio_file_size_limit: systemParameters.audio_file_size_limit,
file_size_limit: systemParameters.file_size_limit,
image_file_size_limit: systemParameters.image_file_size_limit,
video_file_size_limit: systemParameters.video_file_size_limit,
workflow_file_upload_limit: systemParameters.workflow_file_upload_limit,
}
}
const normalizeInstalledAppParametersViewModel = (
response: InstalledAppParametersResponse,
): InstalledAppParametersViewModel => {
return {
opening_statement: response.opening_statement ?? '',
suggested_questions: response.suggested_questions,
pre_prompt: '',
prompt_type: PromptMode.simple,
user_input_form: response.user_input_form.filter(isUserInputFormItem),
more_like_this: normalizeEnabledConfig(response.more_like_this),
suggested_questions_after_answer: normalizeSuggestedQuestionsAfterAnswer(
response.suggested_questions_after_answer,
),
speech_to_text: normalizeEnabledConfig(response.speech_to_text),
text_to_speech: normalizeTextToSpeech(response.text_to_speech),
retriever_resource: normalizeEnabledConfig(response.retriever_resource),
sensitive_word_avoidance: normalizeEnabledConfig(response.sensitive_word_avoidance),
...(isAnnotationReplyConfig(response.annotation_reply)
? { annotation_reply: response.annotation_reply }
: {}),
agent_mode: {
enabled: false,
tools: [],
},
dataset_configs: defaultDatasetConfigs(),
...(isFileUploadConfig(response.file_upload) ? { file_upload: response.file_upload } : {}),
system_parameters: normalizeSystemParameters(response.system_parameters),
}
}
export const fetchAppList = (language?: string) => {
if (!language) return consoleClient.explore.apps.get({}).then(normalizeExploreAppsResponse)
@ -392,60 +175,13 @@ export const fetchAppDetail = async (id: string): Promise<ExploreAppDetailRespon
}
export const fetchInstalledAppList = (appId?: string | null) => {
if (!appId) return consoleClient.installedApps.get({}).then(normalizeInstalledAppsResponse)
if (!appId) return consoleClient.installedApps.get({})
return consoleClient.installedApps
.get({
query: { app_id: appId },
})
.then(normalizeInstalledAppsResponse)
}
export const uninstallApp = (id: string) => {
return consoleClient.installedApps.byInstalledAppId.delete({
params: { installed_app_id: id },
return consoleClient.installedApps.get({
query: { app_id: appId },
})
}
export const updatePinStatus = (id: string, isPinned: boolean) => {
return consoleClient.installedApps.byInstalledAppId.patch({
params: { installed_app_id: id },
body: {
is_pinned: isPinned,
},
})
}
export const getAppAccessModeByAppId = (appId: string) => {
return consoleClient.enterprise.webAppAuth
.getWebAppAccessMode({
query: { appId },
})
.then(
(response): AppAccessModeResponse => ({
accessMode: normalizeAccessMode(
isRecord(response) ? getValue(response, 'accessMode') : undefined,
),
}),
)
}
export const fetchInstalledAppParams = (appId: string) => {
return consoleClient.installedApps.byInstalledAppId.parameters
.get({
params: { installed_app_id: appId },
})
.then(normalizeInstalledAppParametersViewModel)
}
export const fetchInstalledAppMeta = (appId: string) => {
return consoleClient.installedApps.byInstalledAppId.meta
.get({
params: { installed_app_id: appId },
})
.then(normalizeAppMeta)
}
export const fetchBanners = (language?: string) => {
if (!language) return consoleClient.explore.banners.get({}).then(normalizeBannersResponse)

View File

@ -1,19 +1,8 @@
import type { App, AppCategory } from '@/models/explore'
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { useLocale } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode } from '@/models/access-control'
import { consoleQuery } from './client'
import {
fetchAppList,
fetchInstalledAppList,
fetchInstalledAppMeta,
fetchInstalledAppParams,
fetchLearnDifyAppList,
getAppAccessModeByAppId,
uninstallApp,
updatePinStatus,
} from './explore'
import { fetchAppList, fetchLearnDifyAppList } from './explore'
type ExploreAppListData = {
categories: AppCategory[]
@ -57,105 +46,3 @@ export const useLearnDifyAppList = () => {
},
})
}
export const useGetInstalledApps = () => {
return useQuery({
queryKey: consoleQuery.installedApps.get.queryKey({ input: {} }),
queryFn: () => {
return fetchInstalledAppList()
},
})
}
export const useUninstallApp = () => {
const client = useQueryClient()
return useMutation({
mutationKey: consoleQuery.installedApps.byInstalledAppId.delete.mutationKey(),
mutationFn: (appId: string) => uninstallApp(appId),
onSuccess: () => {
client.invalidateQueries({
queryKey: consoleQuery.installedApps.get.queryKey({ input: {} }),
})
},
})
}
export const useUpdateAppPinStatus = () => {
const client = useQueryClient()
return useMutation({
mutationKey: consoleQuery.installedApps.byInstalledAppId.patch.mutationKey(),
mutationFn: ({ appId, isPinned }: { appId: string; isPinned: boolean }) =>
updatePinStatus(appId, isPinned),
onSuccess: () => {
client.invalidateQueries({
queryKey: consoleQuery.installedApps.get.queryKey({ input: {} }),
})
},
})
}
export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const webappAuthEnabled = systemFeatures.webapp_auth.enabled
const appAccessModeInput = { query: { appId: appId ?? '' } }
const installedAppId = appAccessModeInput.query.appId
return useQuery({
queryKey: [
...consoleQuery.enterprise.webAppAuth.getWebAppAccessMode.queryKey({
input: appAccessModeInput,
}),
webappAuthEnabled,
installedAppId,
],
queryFn: () => {
if (webappAuthEnabled === false) {
return {
accessMode: AccessMode.PUBLIC,
}
}
if (!installedAppId) return Promise.reject(new Error('App ID is required to get access mode'))
return getAppAccessModeByAppId(installedAppId)
},
enabled: !!installedAppId,
})
}
export const useGetInstalledAppParams = (appId: string | null) => {
const installedAppParamsInput = { params: { installed_app_id: appId ?? '' } }
const installedAppId = installedAppParamsInput.params.installed_app_id
return useQuery({
queryKey: [
...consoleQuery.installedApps.byInstalledAppId.parameters.get.queryKey({
input: installedAppParamsInput,
}),
installedAppId,
],
queryFn: () => {
if (!installedAppId) return Promise.reject(new Error('App ID is required to get app params'))
return fetchInstalledAppParams(installedAppId)
},
enabled: !!installedAppId,
})
}
export const useGetInstalledAppMeta = (appId: string | null) => {
const installedAppMetaInput = { params: { installed_app_id: appId ?? '' } }
const installedAppId = installedAppMetaInput.params.installed_app_id
return useQuery({
queryKey: [
...consoleQuery.installedApps.byInstalledAppId.meta.get.queryKey({
input: installedAppMetaInput,
}),
installedAppId,
],
queryFn: () => {
if (!installedAppId) return Promise.reject(new Error('App ID is required to get app meta'))
return fetchInstalledAppMeta(installedAppId)
},
enabled: !!installedAppId,
})
}