mirror of
https://github.com/langgenius/dify.git
synced 2026-07-30 00:39:34 +08:00
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:
parent
fd804dfcc0
commit
c1fdd6fb78
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
177
api/services/installed_app_service.py
Normal file
177
api/services/installed_app_service.py
Normal 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))
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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: [] })
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)}
|
||||
/>
|
||||
@ -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" />
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
161
web/app/components/explore/installed-app/runtime.ts
Normal file
161
web/app/components/explore/installed-app/runtime.ts
Normal 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.')
|
||||
}
|
||||
@ -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 },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user