From c1fdd6fb78a37daa16de7fd26ce8106cd076d5cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Wed, 29 Jul 2026 17:51:34 +0800 Subject: [PATCH] chore: paginate installed apps (#39739) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .../console/explore/installed_app.py | 252 +++--- api/openapi/markdown/console-openapi.md | 43 +- api/services/installed_app_service.py | 177 ++++ .../console/explore/test_installed_app.py | 298 ++++++- .../api/console/installed-apps/orpc.gen.ts | 18 +- .../api/console/installed-apps/types.gen.ts | 107 ++- .../api/console/installed-apps/zod.gen.ts | 119 ++- .../__tests__/hooks.spec.tsx | 16 +- .../base/chat/chat-with-history/hooks.tsx | 13 +- .../base/chat/chat-with-history/index.tsx | 4 +- .../explore/__tests__/index.spec.tsx | 14 - .../__tests__/app-nav-item.spec.tsx} | 65 +- .../infinite-scroll-sentinel.spec.tsx | 70 ++ .../app-nav-item.tsx} | 34 +- .../infinite-scroll-sentinel.tsx | 50 ++ .../pagination-skeleton.tsx | 9 + .../installed-app/__tests__/index.spec.tsx | 765 +++++------------- .../explore/installed-app/index.tsx | 152 +++- .../explore/installed-app/runtime.ts | 161 ++++ .../explore/sidebar/__tests__/index.spec.tsx | 338 ++++---- web/app/components/explore/sidebar/index.tsx | 226 ++++-- .../main-nav/__tests__/index.spec.tsx | 230 ++++-- .../main-nav/components/web-apps-section.tsx | 282 ++++--- .../share/text-generation/index.tsx | 2 - web/models/explore.ts | 7 - web/service/client.ts | 45 ++ web/service/explore.spec.ts | 37 +- web/service/explore.ts | 278 +------ web/service/use-explore.ts | 117 +-- 29 files changed, 2125 insertions(+), 1804 deletions(-) create mode 100644 api/services/installed_app_service.py rename web/app/components/explore/{sidebar/app-nav-item/__tests__/index.spec.tsx => installed-app-navigation/__tests__/app-nav-item.spec.tsx} (55%) create mode 100644 web/app/components/explore/installed-app-navigation/__tests__/infinite-scroll-sentinel.spec.tsx rename web/app/components/explore/{sidebar/app-nav-item/index.tsx => installed-app-navigation/app-nav-item.tsx} (87%) create mode 100644 web/app/components/explore/installed-app-navigation/infinite-scroll-sentinel.tsx create mode 100644 web/app/components/explore/installed-app-navigation/pagination-skeleton.tsx create mode 100644 web/app/components/explore/installed-app/runtime.ts diff --git a/api/controllers/console/explore/installed_app.py b/api/controllers/console/explore/installed_app.py index 1fe1201bab7..b631181390b 100644 --- a/api/controllers/console/explore/installed_app.py +++ b/api/controllers/console/explore/installed_app.py @@ -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/") 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): diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 4a3e277f4fb..e83c01ab291 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -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,
**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)
| + ### [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,
**Default:** 20 | Number of installed apps to return | No | +| name | string | App name to search for | No | #### InstructionGeneratePayload diff --git a/api/services/installed_app_service.py b/api/services/installed_app_service.py new file mode 100644 index 00000000000..e3ff637190e --- /dev/null +++ b/api/services/installed_app_service.py @@ -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)) diff --git a/api/tests/unit_tests/controllers/console/explore/test_installed_app.py b/api/tests/unit_tests/controllers/console/explore/test_installed_app.py index c5780e46ede..4d038a66b7b 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_installed_app.py +++ b/api/tests/unit_tests/controllers/console/explore/test_installed_app.py @@ -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) diff --git a/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts b/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts index 8179e0738ba..4a0941ab095 100644 --- a/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts @@ -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, } diff --git a/packages/contracts/generated/api/console/installed-apps/types.gen.ts b/packages/contracts/generated/api/console/installed-apps/types.gen.ts index 4abc67defa3..d2b2bc01f0a 100644 --- a/packages/contracts/generated/api/console/installed-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/types.gen.ts @@ -5,7 +5,9 @@ export type ClientOptions = { } export type InstalledAppListResponse = { + has_more: boolean installed_apps: Array + 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 -} - -export type ExploreMessageInfiniteScrollPaginationWritable = { - data: Array has_more: boolean - limit: number + installed_apps: Array + 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 + 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 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: { diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index 8be095ef6d4..753aa7d0160 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -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({ diff --git a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx index bd3b8dccdac..3d6a94b5f56 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx @@ -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: [] }) diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index c37f477d143..e7d1ebc63c3 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -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) => { diff --git a/web/app/components/base/chat/chat-with-history/index.tsx b/web/app/components/base/chat/chat-with-history/index.tsx index 49dd216657c..ee23a1c9179 100644 --- a/web/app/components/base/chat/chat-with-history/index.tsx +++ b/web/app/components/base/chat/chat-with-history/index.tsx @@ -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 = ({ className }) => { } type ChatWithHistoryWrapProps = { - installedAppInfo?: InstalledApp + installedAppInfo?: InstalledAppResponse className?: string isNewAgent?: boolean renderAgentContent?: ChatProps['renderAgentContent'] diff --git a/web/app/components/explore/__tests__/index.spec.tsx b/web/app/components/explore/__tests__/index.spec.tsx index a35d5926730..4355573cdb3 100644 --- a/web/app/components/explore/__tests__/index.spec.tsx +++ b/web/app/components/explore/__tests__/index.spec.tsx @@ -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() diff --git a/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx b/web/app/components/explore/installed-app-navigation/__tests__/app-nav-item.spec.tsx similarity index 55% rename from web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx rename to web/app/components/explore/installed-app-navigation/__tests__/app-nav-item.spec.tsx index 2b21665d092..1ecb8828935 100644 --- a/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx +++ b/web/app/components/explore/installed-app-navigation/__tests__/app-nav-item.spec.tsx @@ -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() - 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() + + 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() + it('should not render delete action when app is uninstallable', async () => { + const user = userEvent.setup() + render( + , + ) - 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() }) diff --git a/web/app/components/explore/installed-app-navigation/__tests__/infinite-scroll-sentinel.spec.tsx b/web/app/components/explore/installed-app-navigation/__tests__/infinite-scroll-sentinel.spec.tsx new file mode 100644 index 00000000000..2a195aa6622 --- /dev/null +++ b/web/app/components/explore/installed-app-navigation/__tests__/infinite-scroll-sentinel.spec.tsx @@ -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 = { + 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( + , + ) + + expect(fetchNextPage).toHaveBeenCalledOnce() + + act(() => { + rerender( + , + ) + }) + act(() => { + rerender( + , + ) + }) + + expect(observerConstructed).toHaveBeenCalledOnce() + expect(fetchNextPage).toHaveBeenCalledOnce() + }) +}) diff --git a/web/app/components/explore/sidebar/app-nav-item/index.tsx b/web/app/components/explore/installed-app-navigation/app-nav-item.tsx similarity index 87% rename from web/app/components/explore/sidebar/app-nav-item/index.tsx rename to web/app/components/explore/installed-app-navigation/app-nav-item.tsx index 4c76b719f4b..f042bd612f8 100644 --- a/web/app/components/explore/sidebar/app-nav-item/index.tsx +++ b/web/app/components/explore/installed-app-navigation/app-nav-item.tsx @@ -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({ > onTogglePin(id, !isPinned)} isShowDelete={!uninstallable && !isSelected} onDelete={() => onDelete(id)} /> diff --git a/web/app/components/explore/installed-app-navigation/infinite-scroll-sentinel.tsx b/web/app/components/explore/installed-app-navigation/infinite-scroll-sentinel.tsx new file mode 100644 index 00000000000..65528719714 --- /dev/null +++ b/web/app/components/explore/installed-app-navigation/infinite-scroll-sentinel.tsx @@ -0,0 +1,50 @@ +'use client' + +import type { RefObject } from 'react' +import { useEffect, useEffectEvent, useRef } from 'react' + +type InfiniteScrollSentinelProps = { + canFetchNextPage: boolean + fetchNextPage: () => Promise + isFetchingNextPage: boolean + scrollRootRef: RefObject +} + +export const InfiniteScrollSentinel = ({ + canFetchNextPage, + fetchNextPage, + isFetchingNextPage, + scrollRootRef, +}: InfiniteScrollSentinelProps) => { + const sentinelRef = useRef(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
+} diff --git a/web/app/components/explore/installed-app-navigation/pagination-skeleton.tsx b/web/app/components/explore/installed-app-navigation/pagination-skeleton.tsx new file mode 100644 index 00000000000..796813907bb --- /dev/null +++ b/web/app/components/explore/installed-app-navigation/pagination-skeleton.tsx @@ -0,0 +1,9 @@ +const skeletonClassName = + 'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none' + +export const InstalledAppPaginationSkeleton = () => ( +
+
+
+
+) diff --git a/web/app/components/explore/installed-app/__tests__/index.spec.tsx b/web/app/components/explore/installed-app/__tests__/index.spec.tsx index d0c81643570..e9de7f56390 100644 --- a/web/app/components/explore/installed-app/__tests__/index.spec.tsx +++ b/web/app/components/explore/installed-app/__tests__/index.spec.tsx @@ -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 - }) => ( -
- Text Generation App - {isWorkflow && ' (Workflow)'} - {isInstalledApp && ` - ${installedAppInfo?.id}`} -
+ default: ({ isWorkflow }: { isWorkflow?: boolean }) => ( +
{isWorkflow ? 'Workflow App' : 'Completion App'}
), })) vi.mock('@/app/components/base/chat/chat-with-history', () => ({ - default: ({ - installedAppInfo, - className, - }: { - installedAppInfo?: InstalledAppType - className?: string - }) => ( -
- Chat With History - {installedAppInfo?.id} -
- ), + default: () =>
Chat App
, })) +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(['chat', 'advanced-chat', 'agent-chat'])( + 'renders chat for the supported %s mode', + async (mode) => { + mocks.getInstalledApp.mockResolvedValue(createInstalledApp(mode)) - const { container } = render() - const svg = container.querySelector('svg.spin-animation') - expect(svg).toBeInTheDocument() + renderWithConsoleQuery() + + expect(await screen.findByText('Chat App')).toBeInTheDocument() + }, + ) + + it('renders completion and workflow surfaces explicitly', async () => { + mocks.getInstalledApp.mockResolvedValue(createInstalledApp('completion')) + const { rerender } = renderWithConsoleQuery() + expect(await screen.findByText('Completion App')).toBeInTheDocument() + + mocks.getInstalledApp.mockResolvedValue(createInstalledApp('workflow')) + rerender() + expect(await screen.findByText('Workflow App')).toBeInTheDocument() + }) + + it.each(['agent', 'channel', 'rag-pipeline'])( + 'fails closed for unsupported %s mode', + async (mode) => { + mocks.getInstalledApp.mockResolvedValue(createInstalledApp(mode)) + + renderWithConsoleQuery() + + 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(, { + 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() - 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() - 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() - const svg = container.querySelector('svg.spin-animation') - expect(svg).toBeInTheDocument() - }) - - it('should render app not found (404) when installedApp does not exist', () => { - setupMocks([]) - - render() - 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() - render() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - expect(screen.getByText(/404/)).toBeInTheDocument() - }) + renderWithConsoleQuery() + + 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() + it('distinguishes a missing installed app from a recoverable detail failure', async () => { + mocks.getInstalledApp.mockRejectedValue(new Response(null, { status: 404 })) + const { unmount } = renderWithConsoleQuery() + 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() - - await waitFor(() => { - expect(mockUpdateAppInfo).toHaveBeenCalledWith(null) - }) - }) - - it('should update app params when data is available', async () => { - render() - - await waitFor(() => { - expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams) - }) - }) - - it('should update app meta when data is available', async () => { - render() - - await waitFor(() => { - expect(mockUpdateWebAppMeta).toHaveBeenCalledWith(mockAppMeta) - }) - }) - - it('should update web app access mode when data is available', async () => { - render() - - await waitFor(() => { - expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC) - }) - }) - - it('should update user can access app when data is available', async () => { - render() - - 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() - - 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() - - 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() - - 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() - - 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() - - await waitFor(() => { - expect(mockUpdateAppInfo).toHaveBeenCalled() - }) - - expect(mockUpdateWebAppAccessMode).not.toHaveBeenCalled() - }) - }) - - describe('Edge Cases', () => { - it('should handle empty installedApps array', () => { - setupMocks([]) - - render() - 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() - 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() - expect(screen.getByText(/app-1/)).toBeInTheDocument() - - rerender() - expect(screen.getByText(/app-2/)).toBeInTheDocument() - }) - - it('should call service hooks with correct appId', () => { - render() - - 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() - - 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() - 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() - 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() - 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() - const svg = container.querySelector('svg.spin-animation') - expect(svg).toBeInTheDocument() - expect(screen.queryByText(/404/)).not.toBeInTheDocument() - }) + mocks.getInstalledApp.mockRejectedValue(new Error('Network unavailable')) + renderWithConsoleQuery() + expect(await screen.findByText('Network unavailable')).toBeInTheDocument() }) }) diff --git a/web/app/components/explore/installed-app/index.tsx b/web/app/components/explore/installed-app/index.tsx index ae5c95b5943..5e415f004c5 100644 --- a/web/app/components/explore/installed-app/index.tsx +++ b/web/app/components/explore/installed-app/index.tsx @@ -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
) +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 }) => { ) } + if (installedAppError) { + const isNotFound = installedAppError instanceof Response && installedAppError.status === 404 + return ( + +
+ {isNotFound ? ( + + ) : ( + + )} +
+
+ ) + } if (userCanAccessApp && !userCanAccessApp.result) { return ( @@ -156,9 +215,11 @@ const InstalledApp = ({ id }: { id: string }) => { ) } if ( - isPendingInstalledApps || - (!installedApp && isFetchingInstalledApps) || - (installedApp && (isPendingAppParams || isPendingAppMeta || isPendingWebAppAccessMode)) + isPendingInstalledApp || + isPendingAppParams || + isPendingAppMeta || + (webappAuthEnabled && isPendingWebAppAccessMode) || + isPendingUserCanAccessApp ) { return ( @@ -177,23 +238,34 @@ const InstalledApp = ({ id }: { id: string }) => { ) } + const surface = getInstalledAppSurface(installedApp.app.mode) + + if (surface === 'unsupported') { + return ( + +
+ +
+
+ ) + } + return ( - {installedApp?.app.mode !== AppModeEnum.COMPLETION && - installedApp?.app.mode !== AppModeEnum.WORKFLOW && ( - - )} - {installedApp?.app.mode === AppModeEnum.COMPLETION && ( + {surface === 'chat' && ( + + )} + {surface === 'completion' && ( - + )} - {installedApp?.app.mode === AppModeEnum.WORKFLOW && ( + {surface === 'workflow' && ( - + )} diff --git a/web/app/components/explore/installed-app/runtime.ts b/web/app/components/explore/installed-app/runtime.ts new file mode 100644 index 00000000000..a58080f1df2 --- /dev/null +++ b/web/app/components/explore/installed-app/runtime.ts @@ -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 => + 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 => isRecord(value) + +const isAnnotationReplyConfig = ( + value: unknown, +): value is NonNullable => isRecord(value) + +const isFileUploadConfig = (value: unknown): value is NonNullable => + 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 = {} + + 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.') +} diff --git a/web/app/components/explore/sidebar/__tests__/index.spec.tsx b/web/app/components/explore/sidebar/__tests__/index.spec.tsx index 6635ca051e9..4f18f77f878 100644 --- a/web/app/components/explore/sidebar/__tests__/index.spec.tsx +++ b/web/app/components/explore/sidebar/__tests__/index.spec.tsx @@ -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 => ({ - id: overrides.id ?? 'app-123', - uninstallable: overrides.uninstallable ?? false, +const createInstalledApp = ( + overrides: Partial = {}, +): 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() -} +const renderSideBar = () => renderWithConsoleQuery() 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 }, + }), + ) }) }) diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx index 0fb9ae10b5a..20628b3a15c 100644 --- a/web/app/components/explore/sidebar/index.tsx +++ b/web/app/components/explore/sidebar/index.tsx @@ -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) => + data.pages.flatMap((page) => page.installed_apps) + const SideBar = () => { const { t } = useTranslation() const pathname = usePathname() + const scrollRef = React.useRef(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(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, - ) => ( - - handleUpdatePinStatus(id, !is_pinned)} - uninstallable={uninstallable} - onDelete={(id) => { - setCurrId(id) - setShowConfirm(true) - }} - /> - {index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && } - - ), - ) + const installedAppItems = installedApps.map((installedApp, index) => ( + + + {index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && } + + )) return (
@@ -116,9 +152,32 @@ const SideBar = () => {
- {!isPending && installedApps.length === 0 && !isFold && ( -
- + {!installedAppsQuery.isPending && + !installedAppsQuery.isError && + installedApps.length === 0 && + !isFold && ( +
+ +
+ )} + + {!installedAppsQuery.isPending && installedAppsQuery.isError && !isFold && ( +
+ {t(($) => $['errorBoundary.title'], { ns: 'common' })} +
)} @@ -134,20 +193,52 @@ const SideBar = () => { )} {!isFold ? (
- - {installedAppItems} - + + + + {installedAppItems} + {installedAppsQuery.isFetchingNextPage && } + + installedAppsQuery.fetchNextPage({ + cancelRefetch: false, + }) + } + isFetchingNextPage={installedAppsQuery.isFetchingNextPage} + scrollRootRef={scrollRef} + /> + + + + + +
) : ( -
+
{installedAppItems} + {installedAppsQuery.isFetchingNextPage && } + + installedAppsQuery.fetchNextPage({ + cancelRefetch: false, + }) + } + isFetchingNextPage={installedAppsQuery.isFetchingNextPage} + scrollRootRef={scrollRef} + />
)}
@@ -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 ? (
- + { + if (!open) setUninstallDialogAppId(null) + }} + >
@@ -183,12 +279,12 @@ const SideBar = () => {
- + {t(($) => $['operation.cancel'], { ns: 'common' })} {t(($) => $['operation.confirm'], { ns: 'common' })} diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 946c79c2349..d0f8ccc1218 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -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() return { @@ -348,11 +388,10 @@ vi.mock('nuqs', async (importOriginal) => { const actual = await importOriginal() 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 => ({ +const createInstalledApp = ( + overrides: Partial = {}, +): 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 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') }) }) diff --git a/web/app/components/main-nav/components/web-apps-section.tsx b/web/app/components/main-nav/components/web-apps-section.tsx index d443f6ff800..3b15bd4a226 100644 --- a/web/app/components/main-nav/components/web-apps-section.tsx +++ b/web/app/components/main-nav/components/web-apps-section.tsx @@ -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) => + 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(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(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(() => { - 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]) => ( $['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 - - return renderAppNavItem(row.app) - } - return (
- {isPending ? ( + {installedAppsQuery.isPending ? ( ) : (
@@ -210,7 +188,10 @@ const WebAppsSectionContent = () => { )} onClick={() => { setAppsExpanded(true) - setSearchVisible((value) => !value) + setSearchVisible((value) => { + if (value) setSearchText('') + return !value + }) }} > @@ -220,7 +201,7 @@ const WebAppsSectionContent = () => {
)} - {!isPending && appsExpanded && searchVisible && ( + {!installedAppsQuery.isPending && appsExpanded && searchVisible && (
{ $['sidebar.webApps'], { ns: 'explore' })} className="overflow-x-hidden" role="region" > - {isPending && } - {!isPending && filteredApps.length === 0 && ( -
- {t(($) => $['mainNav.webApps.noResults'], { ns: 'common' })} + {installedAppsQuery.isPending && } + {!installedAppsQuery.isPending && installedAppsQuery.isError && ( +
+ {t(($) => $['errorBoundary.title'], { ns: 'common' })} +
)} - {!isPending && webAppRows.length > 0 && !shouldVirtualize && ( + {!installedAppsQuery.isPending && + !installedAppsQuery.isError && + installedApps.length === 0 && ( +
+ {t(($) => $['mainNav.webApps.noResults'], { ns: 'common' })} +
+ )} + {!installedAppsQuery.isPending && installedApps.length > 0 && (
- {webAppRows.map((row) => ( - {renderRow(row)} + {installedApps.map((installedApp, index) => ( + + {renderAppNavItem(installedApp)} + {index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && ( + + )} + ))}
)} - {!isPending && shouldVirtualize && ( -
- {virtualRows.map((virtualRow) => { - const row = webAppRows[virtualRow.index]! - - return ( -
- {renderRow(row)} -
- ) - })} -
- )} + {installedAppsQuery.isFetchingNextPage && } + + installedAppsQuery.fetchNextPage({ + cancelRefetch: false, + }) + } + isFetchingNextPage={installedAppsQuery.isFetchingNextPage} + scrollRootRef={scrollRef} + /> @@ -286,7 +279,12 @@ const WebAppsSectionContent = () => { )} - + { + if (!open) setUninstallDialogAppId(null) + }} + >
@@ -297,12 +295,12 @@ const WebAppsSectionContent = () => {
- + {t(($) => $['operation.cancel'], { ns: 'common' })} {t(($) => $['operation.confirm'], { ns: 'common' })} diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx index 7ffa36c5e2a..2f3c9307c88 100644 --- a/web/app/components/share/text-generation/index.tsx +++ b/web/app/components/share/text-generation/index.tsx @@ -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 = ({ isInstalledApp = false, isWorkflow = false }) => { diff --git a/web/models/explore.ts b/web/models/explore.ts index ab888e7a243..8d0314dfb81 100644 --- a/web/models/explore.ts +++ b/web/models/explore.ts @@ -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 -} diff --git a/web/service/client.ts b/web/service/client.ts index 0e6dfb3697d..896416ec3a0 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -558,6 +558,51 @@ export const consoleQuery: RouterUtils = 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: { diff --git a/web/service/explore.spec.ts b/web/service/explore.spec.ts index 45200f46024..6b472c7a5dd 100644 --- a/web/service/explore.spec.ts +++ b/web/service/explore.spec.ts @@ -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', }) }) }) diff --git a/web/service/explore.ts b/web/service/explore.ts index 2e0a1081950..2b86479567b 100644 --- a/web/service/explore.ts +++ b/web/service/explore.ts @@ -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 => { 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 = {} - - 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 => { - return isRecord(value) -} - -const isAnnotationReplyConfig = ( - value: unknown, -): value is NonNullable => { - return isRecord(value) -} - -const isFileUploadConfig = (value: unknown): value is NonNullable => { - 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 { - 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) diff --git a/web/service/use-explore.ts b/web/service/use-explore.ts index a974132dd0f..3319b424f4e 100644 --- a/web/service/use-explore.ts +++ b/web/service/use-explore.ts @@ -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, - }) -}