diff --git a/api/.env.example b/api/.env.example index 3aa107130f9..48d8707d1ad 100644 --- a/api/.env.example +++ b/api/.env.example @@ -36,6 +36,9 @@ FILES_ACCESS_TIMEOUT=300 # Collaboration mode toggle ENABLE_COLLABORATION_MODE=true +# Learn app feature toggle +ENABLE_LEARN_APP=true + # Access token expiration time in minutes ACCESS_TOKEN_EXPIRE_MINUTES=60 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index dc8c840da9c..f664274ba75 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1073,6 +1073,12 @@ class MailConfig(BaseSettings): default=None, ) + +class HomepageConfig(BaseSettings): + """ + Configuration for homepage feature toggles exposed through system features. + """ + ENABLE_TRIAL_APP: bool = Field( description="Enable trial app", default=False, @@ -1083,6 +1089,11 @@ class MailConfig(BaseSettings): default=False, ) + ENABLE_LEARN_APP: bool = Field( + description="Enable Learn App", + default=True, + ) + class RagEtlConfig(BaseSettings): """ @@ -1489,6 +1500,7 @@ class FeatureConfig( EndpointConfig, FileAccessConfig, FileUploadConfig, + HomepageConfig, HttpConfig, InnerAPIConfig, IndexingConfig, diff --git a/api/migrations/versions/2026_06_23_1800-d9e8f7a6b5c4_add_cloud_only_flag_to_recommended_apps.py b/api/migrations/versions/2026_06_23_1800-d9e8f7a6b5c4_add_cloud_only_flag_to_recommended_apps.py new file mode 100644 index 00000000000..77bf5118bec --- /dev/null +++ b/api/migrations/versions/2026_06_23_1800-d9e8f7a6b5c4_add_cloud_only_flag_to_recommended_apps.py @@ -0,0 +1,26 @@ +"""add cloud only flag to recommended apps + +Revision ID: d9e8f7a6b5c4 +Revises: c8f4a6b2d3e1 +Create Date: 2026-06-23 18:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "d9e8f7a6b5c4" +down_revision = "c8f4a6b2d3e1" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.add_column(sa.Column("is_cloud_only", sa.Boolean(), server_default=sa.text("false"), nullable=False)) + + +def downgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.drop_column("is_cloud_only") diff --git a/api/models/model.py b/api/models/model.py index 947cbf6fe4a..38d67004de4 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -925,6 +925,9 @@ class RecommendedApp(TypeBase): is_learn_dify: Mapped[bool] = mapped_column( sa.Boolean, nullable=False, server_default=sa.text("false"), default=False ) + is_cloud_only: Mapped[bool] = mapped_column( + sa.Boolean, nullable=False, server_default=sa.text("false"), default=False + ) install_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) language: Mapped[str] = mapped_column( String(255), diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 6041662f303..b3a0b8a6a71 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -19605,6 +19605,7 @@ Model class for provider system configuration response. | enable_email_code_login | boolean | | Yes | | enable_email_password_login | boolean, **Default:** true | | Yes | | enable_explore_banner | boolean | | Yes | +| enable_learn_app | boolean, **Default:** true | | Yes | | enable_marketplace | boolean | | Yes | | enable_social_oauth_login | boolean | | Yes | | enable_trial_app | boolean | | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 569e3706caa..0f368895ab6 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1603,6 +1603,7 @@ Default configuration for form inputs. | enable_email_code_login | boolean | | Yes | | enable_email_password_login | boolean, **Default:** true | | Yes | | enable_explore_banner | boolean | | Yes | +| enable_learn_app | boolean, **Default:** true | | Yes | | enable_marketplace | boolean | | Yes | | enable_social_oauth_login | boolean | | Yes | | enable_trial_app | boolean | | Yes | diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 2ae0c63ff75..c9d86ee4578 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -181,6 +181,7 @@ class SystemFeatureModel(FeatureResponseModel): enable_creators_platform: bool = False enable_trial_app: bool = False enable_explore_banner: bool = False + enable_learn_app: bool = True rbac_enabled: bool = False @@ -282,6 +283,7 @@ class FeatureService: system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != "" system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER + system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP @classmethod def _fulfill_trial_models_from_env(cls) -> list[str]: diff --git a/api/services/recommend_app/buildin/buildin_retrieval.py b/api/services/recommend_app/buildin/buildin_retrieval.py index e48286303cb..03b72a4f57c 100644 --- a/api/services/recommend_app/buildin/buildin_retrieval.py +++ b/api/services/recommend_app/buildin/buildin_retrieval.py @@ -5,6 +5,7 @@ from typing import Any, override from flask import current_app +from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase from services.recommend_app.recommend_app_type import RecommendAppType @@ -25,6 +26,11 @@ class BuildInRecommendAppRetrieval(RecommendAppRetrievalBase): result = self.fetch_recommended_apps_from_builtin(language) return result + @override + def get_learn_dify_apps(self, language: str): + result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language) + return result + @override def get_recommend_app_detail(self, app_id: str): result = self.fetch_recommended_app_detail_from_builtin(app_id) diff --git a/api/services/recommend_app/database/database_retrieval.py b/api/services/recommend_app/database/database_retrieval.py index 9d6c28c2117..f6786175896 100644 --- a/api/services/recommend_app/database/database_retrieval.py +++ b/api/services/recommend_app/database/database_retrieval.py @@ -49,6 +49,11 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): result = self.fetch_recommended_apps_from_db(language) return result + @override + def get_learn_dify_apps(self, language: str) -> RecommendedAppsResultDict: + result = self.fetch_learn_dify_apps_from_db(language) + return result + @override def get_recommend_app_detail(self, app_id: str) -> RecommendedAppDetailDict | None: result = self.fetch_recommended_app_detail_from_db(app_id) diff --git a/api/services/recommend_app/recommend_app_base.py b/api/services/recommend_app/recommend_app_base.py index 4214d56e4aa..f819cc3a937 100644 --- a/api/services/recommend_app/recommend_app_base.py +++ b/api/services/recommend_app/recommend_app_base.py @@ -6,6 +6,8 @@ class RecommendAppRetrievalBase(Protocol): def get_recommended_apps_and_categories(self, language: str) -> Any: ... + def get_learn_dify_apps(self, language: str) -> Any: ... + def get_recommend_app_detail(self, app_id: str) -> Any: ... def get_type(self) -> str: ... diff --git a/api/services/recommend_app/remote/remote_retrieval.py b/api/services/recommend_app/remote/remote_retrieval.py index 890fb132faa..2e3222bb978 100644 --- a/api/services/recommend_app/remote/remote_retrieval.py +++ b/api/services/recommend_app/remote/remote_retrieval.py @@ -2,15 +2,28 @@ import logging from typing import Any, override import httpx +from flask import has_request_context, request from configs import dify_config from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval +from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase from services.recommend_app.recommend_app_type import RecommendAppType logger = logging.getLogger(__name__) +def _current_origin_headers() -> dict[str, str]: + origin = request.headers.get("Origin") if has_request_context() else None + if origin: + return {"Origin": origin} + + console_web_url = getattr(dify_config, "CONSOLE_WEB_URL", "") + if not isinstance(console_web_url, str) or not console_web_url: + return {} + return {"Origin": console_web_url} + + class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): """ Retrieval recommended app from dify official. @@ -37,6 +50,15 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): result = BuildInRecommendAppRetrieval.fetch_recommended_apps_from_builtin(language) return result + @override + def get_learn_dify_apps(self, language: str): + try: + result = self.fetch_learn_dify_apps_from_dify_official(language) + except Exception as e: + logger.warning("fetch learn dify apps from dify official failed: %s, switch to database.", e) + result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language) + return result + @override def get_type(self) -> str: return RecommendAppType.REMOTE @@ -50,7 +72,7 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): """ domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN url = f"{domain}/apps/{app_id}" - response = httpx.get(url, timeout=httpx.Timeout(10.0, connect=3.0)) + response = httpx.get(url, headers=_current_origin_headers(), timeout=httpx.Timeout(10.0, connect=3.0)) if response.status_code != 200: return None data: dict[str, Any] = response.json() @@ -65,9 +87,25 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): """ domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN url = f"{domain}/apps?language={language}" - response = httpx.get(url, timeout=httpx.Timeout(10.0, connect=3.0)) + response = httpx.get(url, headers=_current_origin_headers(), timeout=httpx.Timeout(10.0, connect=3.0)) if response.status_code != 200: raise ValueError(f"fetch recommended apps failed, status code: {response.status_code}") result: dict[str, Any] = response.json() return result + + @classmethod + def fetch_learn_dify_apps_from_dify_official(cls, language: str): + """ + Fetch Learn Dify apps from dify official. + :param language: language + :return: + """ + domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN + url = f"{domain}/apps/learn-dify?language={language}" + response = httpx.get(url, headers=_current_origin_headers(), timeout=httpx.Timeout(10.0, connect=3.0)) + if response.status_code != 200: + raise ValueError(f"fetch learn dify apps failed, status code: {response.status_code}") + + result: dict[str, Any] = response.json() + return result diff --git a/api/services/recommended_app_service.py b/api/services/recommended_app_service.py index bc8bb58acba..2d247ba5b71 100644 --- a/api/services/recommended_app_service.py +++ b/api/services/recommended_app_service.py @@ -6,7 +6,6 @@ from sqlalchemy.orm import scoped_session from configs import dify_config from models.model import AccountTrialAppRecord, TrialApp from services.feature_service import FeatureService -from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory @@ -38,11 +37,13 @@ class RecommendedAppService: @classmethod def get_learn_dify_apps(cls, session: scoped_session, language: str) -> dict[str, Any]: """ - Get database-backed recommended apps marked as Learn Dify. + Get recommended apps marked for the Learn Dify section. :param language: language :return: """ - result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language) + mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE + retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)() + result = retrieval_instance.get_learn_dify_apps(language) if FeatureService.get_system_features().enable_trial_app: for app in result["recommended_apps"]: diff --git a/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py index 750e35843be..9b8eec08ef4 100644 --- a/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py @@ -267,36 +267,45 @@ class TestRecommendedAppServiceGetDetail: class TestRecommendedAppServiceGetLearnDifyApps: - def test_returns_database_learn_dify_apps_without_remote_factory(self, monkeypatch: pytest.MonkeyPatch) -> None: + @patch("services.recommended_app_service.FeatureService", autospec=True) + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config") + def test_uses_configured_retrieval_source( + self, mock_config: MagicMock, mock_factory_class: MagicMock, mock_feature_service: MagicMock + ) -> None: + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=False) expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow") - mock_database_retrieval = MagicMock() - mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = { + mock_instance = MagicMock() + mock_instance.get_learn_dify_apps.return_value = { "recommended_apps": [expected_app], "categories": ["Workflow"], } - monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock(return_value=SimpleNamespace(enable_trial_app=False)), - ) - factory_mock = MagicMock() - monkeypatch.setattr(service_module.RecommendAppRetrievalFactory, "get_recommend_app_factory", factory_mock) + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) result = RecommendedAppService.get_learn_dify_apps(db.session, "en-US") assert result == {"recommended_apps": [expected_app]} - mock_database_retrieval.fetch_learn_dify_apps_from_db.assert_called_once_with("en-US") - factory_mock.assert_not_called() + mock_factory_class.get_recommend_app_factory.assert_called_once_with("remote") + mock_instance.get_learn_dify_apps.assert_called_once_with("en-US") - def test_sets_can_trial_when_trial_feature_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None: + @patch("services.recommended_app_service.dify_config") + def test_sets_can_trial_when_trial_feature_enabled( + self, mock_config: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "db" app = RecommendedAppPayload(app_id="app-1", category="Workflow") - mock_database_retrieval = MagicMock() - mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = { + mock_retrieval_instance = MagicMock() + mock_retrieval_instance.get_learn_dify_apps.return_value = { "recommended_apps": [app], "categories": ["Workflow"], } - monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval) + mock_retrieval_factory = MagicMock(return_value=mock_retrieval_instance) + monkeypatch.setattr( + service_module.RecommendAppRetrievalFactory, + "get_recommend_app_factory", + MagicMock(return_value=mock_retrieval_factory), + ) monkeypatch.setattr( service_module.FeatureService, "get_system_features", diff --git a/api/tests/unit_tests/controllers/console/test_feature.py b/api/tests/unit_tests/controllers/console/test_feature.py index 3e804583a6e..5b524cf9c64 100644 --- a/api/tests/unit_tests/controllers/console/test_feature.py +++ b/api/tests/unit_tests/controllers/console/test_feature.py @@ -94,7 +94,7 @@ class TestSystemFeatureApi: "controllers.console.feature.current_account_with_tenant_optional", return_value=(account, "tenant-123"), ) - system_features = SystemFeatureModel(is_allow_register=True) + system_features = SystemFeatureModel(is_allow_register=True, enable_learn_app=True) get_system_features = mocker.patch( "controllers.console.feature.FeatureService.get_system_features", return_value=system_features, @@ -104,6 +104,7 @@ class TestSystemFeatureApi: result = api.get() assert result == system_features.model_dump() + assert result["enable_learn_app"] is True current_account.assert_called_once_with() get_system_features.assert_called_once_with(is_authenticated=True) diff --git a/api/tests/unit_tests/services/recommend_app/test_buildin_retrieval.py b/api/tests/unit_tests/services/recommend_app/test_buildin_retrieval.py index f5f27f7296f..e8fcbaf96ad 100644 --- a/api/tests/unit_tests/services/recommend_app/test_buildin_retrieval.py +++ b/api/tests/unit_tests/services/recommend_app/test_buildin_retrieval.py @@ -42,6 +42,16 @@ class TestBuildInRecommendAppRetrieval: mock_fetch.assert_called_once_with("en-US") assert result == {"apps": []} + @patch("services.recommend_app.buildin.buildin_retrieval.DatabaseRecommendAppRetrieval") + def test_get_learn_dify_apps_delegates_to_database(self, mock_database_retrieval): + expected = {"recommended_apps": [{"id": "learn-dify-app"}]} + mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = expected + + result = BuildInRecommendAppRetrieval().get_learn_dify_apps("en-US") + + assert result == expected + mock_database_retrieval.fetch_learn_dify_apps_from_db.assert_called_once_with("en-US") + def test_get_recommend_app_detail_delegates(self): with patch.object( BuildInRecommendAppRetrieval, diff --git a/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py b/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py index c7b86e5743d..55165deec25 100644 --- a/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py +++ b/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +from flask import Flask from services.recommend_app.recommend_app_type import RecommendAppType from services.recommend_app.remote.remote_retrieval import RemoteRecommendAppRetrieval @@ -58,6 +59,32 @@ class TestRemoteRecommendAppRetrieval: result = RemoteRecommendAppRetrieval().get_recommended_apps_and_categories("en-US") assert result == {"recommended_apps": [{"id": "builtin"}]} + @patch.object( + RemoteRecommendAppRetrieval, + "fetch_learn_dify_apps_from_dify_official", + return_value={"recommended_apps": [{"id": "learn-dify-app"}]}, + ) + def test_get_learn_dify_apps_success(self, mock_fetch): + result = RemoteRecommendAppRetrieval().get_learn_dify_apps("en-US") + + assert result == {"recommended_apps": [{"id": "learn-dify-app"}]} + mock_fetch.assert_called_once_with("en-US") + + @patch( + "services.recommend_app.remote.remote_retrieval.DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db", + return_value={"recommended_apps": [{"id": "db-fallback"}]}, + ) + @patch.object( + RemoteRecommendAppRetrieval, + "fetch_learn_dify_apps_from_dify_official", + side_effect=ValueError("server error"), + ) + def test_get_learn_dify_apps_falls_back_to_database_on_error(self, mock_fetch, mock_database): + result = RemoteRecommendAppRetrieval().get_learn_dify_apps("en-US") + + assert result == {"recommended_apps": [{"id": "db-fallback"}]} + mock_database.assert_called_once_with("en-US") + class TestFetchFromDifyOfficial: @patch("services.recommend_app.remote.remote_retrieval.dify_config") @@ -118,3 +145,84 @@ class TestFetchFromDifyOfficial: result = RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") assert "categories" not in result + assert mock_get.call_args.kwargs["headers"] == {} + + @patch("services.recommend_app.remote.remote_retrieval.dify_config") + @patch("services.recommend_app.remote.remote_retrieval.httpx.get") + def test_apps_forwards_request_origin_header(self, mock_get, mock_config): + mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" + mock_config.CONSOLE_WEB_URL = "https://saas.dify.dev" + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"recommended_apps": []} + mock_get.return_value = mock_response + + flask_app = Flask(__name__) + with flask_app.test_request_context(headers={"Origin": "https://cloud.example.com"}): + RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") + + assert mock_get.call_args.kwargs["headers"] == {"Origin": "https://cloud.example.com"} + + @patch("services.recommend_app.remote.remote_retrieval.dify_config") + @patch("services.recommend_app.remote.remote_retrieval.httpx.get") + def test_apps_falls_back_to_console_web_url_origin(self, mock_get, mock_config): + mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" + mock_config.CONSOLE_WEB_URL = "https://saas.dify.dev/console" + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"recommended_apps": []} + mock_get.return_value = mock_response + + flask_app = Flask(__name__) + with flask_app.test_request_context(): + RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") + + assert mock_get.call_args.kwargs["headers"] == {"Origin": "https://saas.dify.dev/console"} + + @patch("services.recommend_app.remote.remote_retrieval.dify_config") + @patch("services.recommend_app.remote.remote_retrieval.httpx.get") + def test_apps_falls_back_to_console_web_url_without_request_context(self, mock_get, mock_config): + mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" + mock_config.CONSOLE_WEB_URL = "http://localhost:3000/console" + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"recommended_apps": []} + mock_get.return_value = mock_response + + RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") + + assert mock_get.call_args.kwargs["headers"] == {"Origin": "http://localhost:3000/console"} + + @patch("services.recommend_app.remote.remote_retrieval.dify_config") + @patch("services.recommend_app.remote.remote_retrieval.httpx.get") + def test_apps_uses_console_web_url_without_scheme(self, mock_get, mock_config): + mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" + mock_config.CONSOLE_WEB_URL = "saas.dify.dev" + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"recommended_apps": []} + mock_get.return_value = mock_response + + flask_app = Flask(__name__) + with flask_app.test_request_context(): + RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") + + assert mock_get.call_args.kwargs["headers"] == {"Origin": "saas.dify.dev"} + + @patch("services.recommend_app.remote.remote_retrieval.dify_config") + @patch("services.recommend_app.remote.remote_retrieval.httpx.get") + def test_learn_dify_apps_returns_json_on_200(self, mock_get, mock_config): + mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"recommended_apps": [{"id": "learn-dify-app"}]} + mock_get.return_value = mock_response + + result = RemoteRecommendAppRetrieval.fetch_learn_dify_apps_from_dify_official("en-US") + + assert result == {"recommended_apps": [{"id": "learn-dify-app"}]} + assert mock_get.call_args.args[0] == "https://example.com/apps/learn-dify?language=en-US" + + @patch("services.recommend_app.remote.remote_retrieval.dify_config") + @patch("services.recommend_app.remote.remote_retrieval.httpx.get") + def test_learn_dify_apps_raises_on_non_200(self, mock_get, mock_config): + mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" + mock_get.return_value = MagicMock(status_code=500) + + with pytest.raises(ValueError, match="fetch learn dify apps failed"): + RemoteRecommendAppRetrieval.fetch_learn_dify_apps_from_dify_official("en-US") diff --git a/api/tests/unit_tests/services/test_feature_service_learn_app.py b/api/tests/unit_tests/services/test_feature_service_learn_app.py new file mode 100644 index 00000000000..ed64c4d08dc --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_learn_app.py @@ -0,0 +1,17 @@ +import pytest + +from services import feature_service as feature_service_module +from services.feature_service import FeatureService, SystemFeatureModel + + +def test_system_feature_model_defaults_enable_learn_app(): + assert SystemFeatureModel().enable_learn_app is True + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_get_system_features_reads_enable_learn_app(monkeypatch: pytest.MonkeyPatch, enabled: bool): + monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_LEARN_APP", enabled) + + result = FeatureService.get_system_features() + + assert result.enable_learn_app is enabled diff --git a/docker/.env.example b/docker/.env.example index 78ebc3e4df1..9646eeeb735 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -41,6 +41,9 @@ FILES_ACCESS_TIMEOUT=300 # Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service. ENABLE_COLLABORATION_MODE=true +# Learn app feature toggle +ENABLE_LEARN_APP=true + # Logging and server workers LOG_LEVEL=INFO LOG_OUTPUT_FORMAT=text diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index 26274fe87d2..391dba2e21a 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -18,6 +18,9 @@ MIGRATION_ENABLED=true FILES_ACCESS_TIMEOUT=300 # Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service. ENABLE_COLLABORATION_MODE=true + +# Learn app feature toggle +ENABLE_LEARN_APP=true CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1 CELERY_TASK_ANNOTATIONS=null AZURE_BLOB_ACCOUNT_URL=https://.blob.core.windows.net diff --git a/packages/contracts/generated/api/console/system-features/types.gen.ts b/packages/contracts/generated/api/console/system-features/types.gen.ts index f1dcc7fc4b4..a510faa22da 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -13,6 +13,7 @@ export type SystemFeatureModel = { enable_email_code_login: boolean enable_email_password_login: boolean enable_explore_banner: boolean + enable_learn_app: boolean enable_marketplace: boolean enable_social_oauth_login: boolean enable_trial_app: boolean diff --git a/packages/contracts/generated/api/console/system-features/zod.gen.ts b/packages/contracts/generated/api/console/system-features/zod.gen.ts index 80b27e7843a..e6f2b2fc5a7 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -105,6 +105,7 @@ export const zSystemFeatureModel = z.object({ enable_email_code_login: z.boolean().default(false), enable_email_password_login: z.boolean().default(true), enable_explore_banner: z.boolean().default(false), + enable_learn_app: z.boolean().default(true), enable_marketplace: z.boolean().default(false), enable_social_oauth_login: z.boolean().default(false), enable_trial_app: z.boolean().default(false), diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 61c9cf103be..722b3042841 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -556,6 +556,7 @@ export type SystemFeatureModel = { enable_email_code_login: boolean enable_email_password_login: boolean enable_explore_banner: boolean + enable_learn_app: boolean enable_marketplace: boolean enable_social_oauth_login: boolean enable_trial_app: boolean diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 8045ad341e3..d555ad5f85c 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -840,6 +840,7 @@ export const zSystemFeatureModel = z.object({ enable_email_code_login: z.boolean().default(false), enable_email_password_login: z.boolean().default(true), enable_explore_banner: z.boolean().default(false), + enable_learn_app: z.boolean().default(true), enable_marketplace: z.boolean().default(false), enable_social_oauth_login: z.boolean().default(false), enable_trial_app: z.boolean().default(false), diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index ada0afe17fa..81e27381649 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -379,6 +379,22 @@ describe('Apps', () => { }) }) + it('should hide categories without templates even when the API returns them', () => { + mockUseExploreAppList.mockReturnValueOnce({ + data: { + categories: ['Cat A', 'v'], + allList: [createAppEntry('Alpha', 'Cat A')], + }, + isLoading: false, + }) + + render() + + expect(screen.getByText('Cat A'))!.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'v' })).not.toBeInTheDocument() + expect(screen.getByText('Alpha'))!.toBeInTheDocument() + }) + it('should clear the search, hide the sidebar during search, and close the modal when requested', async () => { render() diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index c97b8b5fcb5..ac660cdb1fc 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -77,14 +77,30 @@ const Apps = ({ isLoading, } = useExploreAppList() + const visibleCategories = useMemo(() => { + if (!data) + return [] + + const categoriesWithApps = new Set() + data.allList.forEach((app) => { + app.categories.forEach(category => categoriesWithApps.add(category)) + }) + + return data.categories.filter(category => categoriesWithApps.has(category)) + }, [data]) + + const activeCategory = visibleCategories.includes(currCategory) + ? currCategory + : allCategoriesEn + const filteredList = useMemo(() => { if (!data) return [] const { allList } = data const filteredByCategory = allList.filter((item) => { - if (currCategory === allCategoriesEn) + if (activeCategory === allCategoriesEn) return true - return item.categories?.includes(currCategory) ?? false + return item.categories?.includes(activeCategory) ?? false }) if (currentType.length === 0) return filteredByCategory @@ -101,7 +117,7 @@ const Apps = ({ return true return false }) - }, [currentType, currCategory, allCategoriesEn, data]) + }, [currentType, activeCategory, allCategoriesEn, data]) const searchFilteredList = useMemo(() => { if (!searchKeywords || !filteredList || filteredList.length === 0) @@ -189,7 +205,7 @@ const Apps = ({ {!searchKeywords && ( - { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} /> + { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} /> )} @@ -200,7 +216,7 @@ const Apps = ({ ? {searchFilteredList.length > 1 ? t('newApp.foundResults', { ns: 'app', count: searchFilteredList.length }) : t('newApp.foundResult', { ns: 'app', count: searchFilteredList.length })} : ( - + )} diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 47f328d50d6..2e198eb5cb6 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -1,3 +1,4 @@ +import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' @@ -324,10 +325,14 @@ beforeAll(() => { // Render helper wrapping with shared nuqs testing helper plus a seeded // systemFeatures cache so List can resolve its useSuspenseQuery. -const renderList = (searchParams = '') => { +type RenderListOptions = { + systemFeatures?: Partial +} + +const renderList = (searchParams = '', options: RenderListOptions = {}) => { mockSearchParams = new URLSearchParams(searchParams) const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({ - systemFeatures: { branding: { enabled: false } }, + systemFeatures: { branding: { enabled: false }, ...options.systemFeatures }, }) return renderWithNuqs(, { searchParams }) } @@ -502,7 +507,7 @@ describe('List', () => { it('should render first empty state when there are no apps and no active filters', () => { mockAppData = { pages: [{ data: [], total: 0 }] } - renderList() + renderList('', { systemFeatures: { enable_learn_app: true } }) expect(screen.getByText('app.firstEmpty.title'))!.toBeInTheDocument() expect(screen.getByText('app.firstEmpty.learnDifyTitle'))!.toBeInTheDocument() @@ -512,6 +517,15 @@ describe('List', () => { expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() }) + it('should hide learn dify in first empty state when learn app is disabled', () => { + mockAppData = { pages: [{ data: [], total: 0 }] } + + renderList('', { systemFeatures: { enable_learn_app: false } }) + + expect(screen.getByText('app.firstEmpty.title'))!.toBeInTheDocument() + expect(screen.queryByText('app.firstEmpty.learnDifyTitle')).not.toBeInTheDocument() + }) + it('should not render first empty state before the first app list page resolves', () => { mockAppData = { pages: [] } diff --git a/web/app/components/apps/first-empty-state/index.tsx b/web/app/components/apps/first-empty-state/index.tsx index 603a6dd9896..f1dc101073d 100644 --- a/web/app/components/apps/first-empty-state/index.tsx +++ b/web/app/components/apps/first-empty-state/index.tsx @@ -19,12 +19,14 @@ type Props = { onCreateBlank: () => void onCreateTemplate: () => void onImportDSL: () => void + showLearnDify: boolean } function FirstEmptyState({ onCreateBlank, onCreateTemplate, onImportDSL, + showLearnDify, }: Props) { const { t } = useTranslation() @@ -102,13 +104,15 @@ function FirstEmptyState({ - + {showLearnDify && ( + + )} ) } diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 7af2adc1a05..20a197e8ffe 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -263,6 +263,7 @@ function List({ onCreateBlank={openCreateBlankModal} onCreateTemplate={openCreateTemplateDialog} onImportDSL={openCreateFromDSLModal} + showLearnDify={systemFeatures.enable_learn_app} /> ) : ( diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index a11efc38143..f42e34af994 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -286,6 +286,7 @@ const mockAppCreatePermission = (hasEditPermission: boolean) => { type RenderOptions = { enableExploreBanner?: boolean + enableLearnApp?: boolean isCloudEdition?: boolean } @@ -302,7 +303,10 @@ const renderAppList = ( mockConfig.isCloudEdition = options.isCloudEdition ?? false mockAppCreatePermission(hasEditPermission) const { wrapper: SystemFeaturesWrapper, queryClient } = createSystemFeaturesWrapper({ - systemFeatures: { enable_explore_banner: options.enableExploreBanner ?? false }, + systemFeatures: { + enable_explore_banner: options.enableExploreBanner ?? false, + enable_learn_app: options.enableLearnApp ?? true, + }, }) if (!mockIsLoading && !mockIsError && mockExploreData) queryClient.setQueryData(exploreAppListQueryKey, mockExploreData) @@ -542,6 +546,18 @@ describe('AppList', () => { expect(screen.queryByText('3 min')).not.toBeInTheDocument() }) + it('should hide learn dify templates when learn app is disabled', () => { + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + + renderAppList(false, undefined, undefined, { enableLearnApp: false }) + + expect(screen.queryByRole('heading', { name: 'explore.learnDify.title' })).not.toBeInTheDocument() + expect(screen.queryByText('Learn Workflow Basics')).not.toBeInTheDocument() + }) + it('should collapse learn dify and persist hidden state when hide is clicked', async () => { mockExploreData = { categories: ['Writing'], @@ -578,6 +594,18 @@ describe('AppList', () => { expect(screen.queryByText('Beta')).not.toBeInTheDocument() }) + it('should hide categories without apps even when the API returns them', () => { + mockExploreData = { + categories: ['Writing', 'c'], + allList: [createApp()], + } + + renderAppList(false, undefined, { category: 'c' }) + + expect(screen.queryByRole('radio', { name: 'c' })).not.toBeInTheDocument() + expect(screen.getByText('Alpha')).toBeInTheDocument() + }) + it('should keep selected category when clearing search text', async () => { mockExploreData = { categories: ['Writing', 'Translate'], diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 00431c36486..feb253173e7 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -142,15 +142,31 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { defaultValue: allCategoriesEn, }) + const visibleCategories = useMemo(() => { + if (!homeQueries.appListData) + return [] + + const categoriesWithApps = new Set() + homeQueries.appListData.allList.forEach((app) => { + app.categories.forEach(category => categoriesWithApps.add(category)) + }) + + return homeQueries.appListData.categories.filter(category => categoriesWithApps.has(category)) + }, [homeQueries.appListData]) + + const activeCategory = visibleCategories.includes(currCategory) + ? currCategory + : allCategoriesEn + const filteredList = useMemo(() => { if (!homeQueries.appListData) return [] return homeQueries.appListData.allList.filter( item => - currCategory === allCategoriesEn - || item.categories?.includes(currCategory), + activeCategory === allCategoriesEn + || item.categories?.includes(activeCategory), ) - }, [homeQueries.appListData, currCategory, allCategoriesEn]) + }, [homeQueries.appListData, activeCategory, allCategoriesEn]) const searchFilteredList = useMemo(() => { if (!searchKeywords || !filteredList || filteredList.length === 0) @@ -292,8 +308,8 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { { } const LearnDify = (props: LearnDifyProps) => { + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + + if (!systemFeatures.enable_learn_app) + return null + if (props.dismissible === false) return diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 38ea4644ee3..b46cf22875c 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -905,7 +905,7 @@ describe('MainNav', () => { it('shows Learn Dify switch in help menu and restores it from localStorage', async () => { localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true') - renderMainNav() + renderMainNav({ enable_learn_app: true }) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) const learnDifyItem = await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.learnDify' }) @@ -919,8 +919,17 @@ describe('MainNav', () => { expect(mockPush).not.toHaveBeenCalled() }) + it('hides Learn Dify switch in help menu when learn app is disabled', async () => { + renderMainNav({ enable_learn_app: false }) + + fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) + + await screen.findByText('common.mainNav.help.docs') + expect(screen.queryByRole('menuitemcheckbox', { name: 'common.mainNav.help.learnDify' })).not.toBeInTheDocument() + }) + it('orders help menu items to match the nav shell design', async () => { - renderMainNav() + renderMainNav({ enable_learn_app: true }) fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) diff --git a/web/app/components/main-nav/components/help-menu.tsx b/web/app/components/main-nav/components/help-menu.tsx index 647ef49bdb2..f46834424b2 100644 --- a/web/app/components/main-nav/components/help-menu.tsx +++ b/web/app/components/main-nav/components/help-menu.tsx @@ -56,6 +56,7 @@ const HelpMenu = ({ const setLearnDifyHidden = useSetLearnDifyHidden() const [aboutVisible, setAboutVisible] = useState(false) const [open, setOpen] = useState(false) + const shouldShowLearnDifySwitch = systemFeatures.enable_learn_app if (systemFeatures.branding.enabled) return null @@ -95,31 +96,33 @@ const HelpMenu = ({ trailing={} /> - setLearnDifyHidden(!checked)} - > - - - {t('mainNav.help.learnDify', { ns: 'common' })} - - setLearnDifyHidden(!checked)} > + + + {t('mainNav.help.learnDify', { ns: 'common' })} + - - + > + + + + )} {IS_CLOUD_EDITION && isCurrentWorkspaceOwner && } diff --git a/web/docker/entrypoint.sh b/web/docker/entrypoint.sh index 48600464f69..7fddc825610 100755 --- a/web/docker/entrypoint.sh +++ b/web/docker/entrypoint.sh @@ -42,6 +42,7 @@ export NEXT_PUBLIC_ENABLE_CHANGE_EMAIL=${NEXT_PUBLIC_ENABLE_CHANGE_EMAIL:-${ENAB export NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED=${NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED:-${CREATORS_PLATFORM_FEATURES_ENABLED}} export NEXT_PUBLIC_ENABLE_TRIAL_APP=${NEXT_PUBLIC_ENABLE_TRIAL_APP:-${ENABLE_TRIAL_APP}} export NEXT_PUBLIC_ENABLE_EXPLORE_BANNER=${NEXT_PUBLIC_ENABLE_EXPLORE_BANNER:-${ENABLE_EXPLORE_BANNER}} +export NEXT_PUBLIC_ENABLE_LEARN_APP=${NEXT_PUBLIC_ENABLE_LEARN_APP:-${ENABLE_LEARN_APP}} export NEXT_PUBLIC_RBAC_ENABLED=${NEXT_PUBLIC_RBAC_ENABLED:-${RBAC_ENABLED}} export NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=${TEXT_GENERATION_TIMEOUT_MS} diff --git a/web/env.ts b/web/env.ts index 30f32aa2a4a..15d93fa11ea 100644 --- a/web/env.ts +++ b/web/env.ts @@ -87,6 +87,7 @@ const clientSchema = { NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: coercedBoolean.default(true), NEXT_PUBLIC_ENABLE_TRIAL_APP: coercedBoolean.default(true), NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: coercedBoolean.default(true), + NEXT_PUBLIC_ENABLE_LEARN_APP: coercedBoolean.default(true), NEXT_PUBLIC_RBAC_ENABLED: coercedBoolean.default(false), /** @@ -217,6 +218,7 @@ export const env = createEnv({ NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: isServer ? process.env.NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED : getRuntimeEnvFromBody('creatorsPlatformFeaturesEnabled'), NEXT_PUBLIC_ENABLE_TRIAL_APP: isServer ? process.env.NEXT_PUBLIC_ENABLE_TRIAL_APP : getRuntimeEnvFromBody('enableTrialApp'), NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: isServer ? process.env.NEXT_PUBLIC_ENABLE_EXPLORE_BANNER : getRuntimeEnvFromBody('enableExploreBanner'), + NEXT_PUBLIC_ENABLE_LEARN_APP: isServer ? process.env.NEXT_PUBLIC_ENABLE_LEARN_APP : getRuntimeEnvFromBody('enableLearnApp'), NEXT_PUBLIC_RBAC_ENABLED: isServer ? process.env.NEXT_PUBLIC_RBAC_ENABLED : getRuntimeEnvFromBody('rbacEnabled'), NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: isServer ? process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX : getRuntimeEnvFromBody('enableSingleDollarLatex'), diff --git a/web/features/system-features/__tests__/system-features.spec.ts b/web/features/system-features/__tests__/system-features.spec.ts index 78b94cc94f6..3e33679a8cc 100644 --- a/web/features/system-features/__tests__/system-features.spec.ts +++ b/web/features/system-features/__tests__/system-features.spec.ts @@ -18,6 +18,7 @@ const defaultCloudEnv = { NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN: true, NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: false, NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: true, + NEXT_PUBLIC_ENABLE_LEARN_APP: true, NEXT_PUBLIC_ENABLE_MARKETPLACE: true, NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN: true, NEXT_PUBLIC_ENABLE_TRIAL_APP: true, @@ -140,6 +141,7 @@ describe('systemFeaturesQueryOptions', () => { enable_email_password_login: false, enable_social_oauth_login: true, enable_trial_app: true, + enable_learn_app: true, rbac_enabled: false, }) }) @@ -153,6 +155,7 @@ describe('systemFeaturesQueryOptions', () => { NEXT_PUBLIC_ENABLE_COLLABORATION_MODE: true, NEXT_PUBLIC_ALLOW_REGISTER: false, NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: false, + NEXT_PUBLIC_ENABLE_LEARN_APP: true, NEXT_PUBLIC_RBAC_ENABLED: true, }, }) @@ -166,6 +169,7 @@ describe('systemFeaturesQueryOptions', () => { enable_collaboration_mode: true, is_allow_register: false, enable_explore_banner: false, + enable_learn_app: true, rbac_enabled: true, branding: { enabled: false, @@ -219,6 +223,7 @@ describe('serverSystemFeaturesQueryOptions', () => { cloudEnv: { NEXT_PUBLIC_ENABLE_MARKETPLACE: false, NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: true, + NEXT_PUBLIC_ENABLE_LEARN_APP: true, }, }) @@ -231,6 +236,7 @@ describe('serverSystemFeaturesQueryOptions', () => { expect(data).toMatchObject({ enable_marketplace: false, enable_email_password_login: true, + enable_learn_app: true, }) }) diff --git a/web/features/system-features/config.ts b/web/features/system-features/config.ts index 0c5d91af5bf..014b40e2cfc 100644 --- a/web/features/system-features/config.ts +++ b/web/features/system-features/config.ts @@ -52,6 +52,7 @@ export const defaultSystemFeatures = { enable_creators_platform: false, enable_trial_app: false, enable_explore_banner: false, + enable_learn_app: true, } satisfies GetSystemFeaturesResponse export const cloudSystemFeatures = { @@ -101,5 +102,6 @@ export const cloudSystemFeatures = { enable_creators_platform: env.NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED, enable_trial_app: env.NEXT_PUBLIC_ENABLE_TRIAL_APP, enable_explore_banner: env.NEXT_PUBLIC_ENABLE_EXPLORE_BANNER, + enable_learn_app: env.NEXT_PUBLIC_ENABLE_LEARN_APP, rbac_enabled: env.NEXT_PUBLIC_RBAC_ENABLED, } satisfies GetSystemFeaturesResponse
{searchFilteredList.length > 1 ? t('newApp.foundResults', { ns: 'app', count: searchFilteredList.length }) : t('newApp.foundResult', { ns: 'app', count: searchFilteredList.length })}