diff --git a/api/controllers/console/explore/banner.py b/api/controllers/console/explore/banner.py index a6321bb797c..52eadec7f34 100644 --- a/api/controllers/console/explore/banner.py +++ b/api/controllers/console/explore/banner.py @@ -1,37 +1,62 @@ -from typing import Any, cast +from datetime import datetime +from typing import cast from flask import request from flask_restx import Namespace, Resource -from pydantic import BaseModel, Field, RootModel +from pydantic import BaseModel, Field, RootModel, field_validator from sqlalchemy import select from controllers.common.schema import query_params_from_model, register_response_schema_models from controllers.console import api -from controllers.console.explore.wraps import explore_banner_enabled from extensions.ext_database import db from fields.base import ResponseModel +from libs.helper import dump_response from models.enums import BannerStatus from models.model import ExporleBanner +from services.feature_service import FeatureService class BannerListQuery(BaseModel): language: str = Field(default="en-US", description="Banner language") +class BannerContentResponse(ResponseModel): + category: str + title: str = Field(min_length=1) + description: str + image_source: str = Field( + min_length=1, + validation_alias="img-src", + serialization_alias="img-src", + ) + + class BannerResponse(ResponseModel): id: str - content: Any - link: str | None = None + content: BannerContentResponse + link: str sort: int - status: str - created_at: str | None = None + status: BannerStatus + created_at: str + + @field_validator("created_at", mode="before") + @classmethod + def serialize_created_at(cls, value: datetime | str) -> str: + if isinstance(value, datetime): + return value.isoformat() + return value class BannerListResponse(RootModel[list[BannerResponse]]): root: list[BannerResponse] -register_response_schema_models(cast(Namespace, api), BannerListResponse) +register_response_schema_models( + cast(Namespace, api), + BannerContentResponse, + BannerResponse, + BannerListResponse, +) class BannerApi(Resource): @@ -39,38 +64,28 @@ class BannerApi(Resource): @api.doc(params=query_params_from_model(BannerListQuery)) @api.response(200, "Success", api.models[BannerListResponse.__name__]) - @explore_banner_enabled def get(self): """Get banner list.""" - language = request.args.get("language", "en-US") + if not FeatureService.is_explore_banner_enabled(): + return dump_response(BannerListResponse, []) + + query = BannerListQuery.model_validate(request.args.to_dict(flat=True)) # Build base query for enabled banners base_query = select(ExporleBanner).where(ExporleBanner.status == BannerStatus.ENABLED) # Try to get banners in the requested language banners = db.session.scalars( - base_query.where(ExporleBanner.language == language).order_by(ExporleBanner.sort) + base_query.where(ExporleBanner.language == query.language).order_by(ExporleBanner.sort) ).all() # Fallback to en-US if no banners found and language is not en-US - if not banners and language != "en-US": + if not banners and query.language != "en-US": banners = db.session.scalars( base_query.where(ExporleBanner.language == "en-US").order_by(ExporleBanner.sort) ).all() - # Convert banners to serializable format - result = [] - for banner in banners: - banner_data = { - "id": banner.id, - "content": banner.content, # Already parsed as JSON by SQLAlchemy - "link": banner.link, - "sort": banner.sort, - "status": banner.status, - "created_at": banner.created_at.isoformat() if banner.created_at else None, - } - result.append(banner_data) - return result + return dump_response(BannerListResponse, banners) api.add_resource(BannerApi, "/explore/banners") diff --git a/api/controllers/console/explore/wraps.py b/api/controllers/console/explore/wraps.py index 01234172849..1f4da57f9aa 100644 --- a/api/controllers/console/explore/wraps.py +++ b/api/controllers/console/explore/wraps.py @@ -114,17 +114,6 @@ def trial_feature_enable[**P, R](view: Callable[P, R]): return decorated -def explore_banner_enabled[**P, R](view: Callable[P, R]): - @wraps(view) - def decorated(*args: P.args, **kwargs: P.kwargs): - features = FeatureService.get_system_features() - if not features.enable_explore_banner: - abort(403, "Explore banner feature is not enabled.") - return view(*args, **kwargs) - - return decorated - - class InstalledAppResource(Resource): # must be reversed if there are multiple decorators diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index f736da7881b..21feae8b0a1 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -15800,6 +15800,15 @@ AppMCPServer Status Enum | ---- | ---- | ----------- | -------- | | data | [ [AverageSessionInteractionStatisticItem](#averagesessioninteractionstatisticitem) ] | | Yes | +#### BannerContentResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| category | string | | Yes | +| description | string | | Yes | +| img-src | string | | Yes | +| title | string | | Yes | + #### BannerListResponse | Name | Type | Description | Required | @@ -15810,12 +15819,20 @@ AppMCPServer Status Enum | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | | | Yes | -| created_at | string | | No | +| content | [BannerContentResponse](#bannercontentresponse) | | Yes | +| created_at | string | | Yes | | id | string | | Yes | -| link | string | | No | +| link | string | | Yes | | sort | integer | | Yes | -| status | string | | Yes | +| status | [BannerStatus](#bannerstatus) | | Yes | + +#### BannerStatus + +ExporleBanner status + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| BannerStatus | string | ExporleBanner status | | #### BatchImportPayload diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 954d182d74e..7b3451596a6 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -319,6 +319,10 @@ class FeatureService: def get_app_dsl_version(cls) -> str: return CURRENT_APP_DSL_VERSION + @staticmethod + def is_explore_banner_enabled() -> bool: + return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_EXPLORE_BANNER + @classmethod def _fulfill_system_params_from_env(cls, system_features: SystemFeatureModel): system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN @@ -328,7 +332,7 @@ class FeatureService: system_features.is_allow_register = dify_config.ALLOW_REGISTER system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != "" system_features.enable_change_email = dify_config.ENABLE_CHANGE_EMAIL - system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER + system_features.enable_explore_banner = cls.is_explore_banner_enabled() system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR diff --git a/api/tests/unit_tests/controllers/console/explore/test_banner.py b/api/tests/unit_tests/controllers/console/explore/test_banner.py index 36b83151b72..25432a3f944 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_banner.py +++ b/api/tests/unit_tests/controllers/console/explore/test_banner.py @@ -1,10 +1,11 @@ from collections.abc import Iterator from datetime import datetime -from inspect import unwrap +from unittest.mock import MagicMock from uuid import uuid4 import pytest from flask import Flask +from pydantic import ValidationError from sqlalchemy.engine import Engine from sqlalchemy.orm import Session @@ -32,9 +33,14 @@ def banner_session(sqlite_engine: Engine) -> Iterator[Session]: yield session -def _banner(*, text: str, language: str, link: str, created_at: datetime) -> ExporleBanner: +def _banner(*, title: str, language: str, link: str, created_at: datetime) -> ExporleBanner: banner = ExporleBanner( - content={"text": text}, + content={ + "category": "Featured", + "title": title, + "description": "Banner description", + "img-src": "https://example.com/banner.png", + }, link=link, sort=1, status=BannerStatus.ENABLED, @@ -53,10 +59,9 @@ class TestBannerApi: banner_session: Session, ): api = banner_module.BannerApi() - method = unwrap(api.get) banner = _banner( - text="hello", + title="hello", language="fr-FR", link="https://example.com", created_at=datetime(2024, 1, 1), @@ -64,14 +69,20 @@ class TestBannerApi: banner_session.add(banner) banner_session.commit() monkeypatch.setattr(banner_module.db, "session", banner_session) + monkeypatch.setattr(banner_module.FeatureService, "is_explore_banner_enabled", lambda: True) with app.test_request_context("/?language=fr-FR"): - result = method(api) + result = api.get() assert result == [ { "id": banner.id, - "content": {"text": "hello"}, + "content": { + "category": "Featured", + "title": "hello", + "description": "Banner description", + "img-src": "https://example.com/banner.png", + }, "link": "https://example.com", "sort": 1, "status": "enabled", @@ -86,10 +97,9 @@ class TestBannerApi: banner_session: Session, ): api = banner_module.BannerApi() - method = unwrap(api.get) banner = _banner( - text="fallback", + title="fallback", language="en-US", link="https://example.com/fallback", created_at=datetime(2024, 1, 2), @@ -97,14 +107,20 @@ class TestBannerApi: banner_session.add(banner) banner_session.commit() monkeypatch.setattr(banner_module.db, "session", banner_session) + monkeypatch.setattr(banner_module.FeatureService, "is_explore_banner_enabled", lambda: True) with app.test_request_context("/?language=es-ES"): - result = method(api) + result = api.get() assert result == [ { "id": banner.id, - "content": {"text": "fallback"}, + "content": { + "category": "Featured", + "title": "fallback", + "description": "Banner description", + "img-src": "https://example.com/banner.png", + }, "link": "https://example.com/fallback", "sort": 1, "status": "enabled", @@ -119,10 +135,79 @@ class TestBannerApi: banner_session: Session, ): api = banner_module.BannerApi() - method = unwrap(api.get) monkeypatch.setattr(banner_module.db, "session", banner_session) + monkeypatch.setattr(banner_module.FeatureService, "is_explore_banner_enabled", lambda: True) with app.test_request_context("/"): - result = method(api) + result = api.get() assert result == [] + + def test_get_banners_allows_empty_supporting_copy( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + banner_session: Session, + ): + api = banner_module.BannerApi() + banner = _banner( + title="title only", + language="en-US", + link="https://example.com", + created_at=datetime(2024, 1, 3), + ) + banner.content["category"] = "" + banner.content["description"] = "" + banner_session.add(banner) + banner_session.commit() + monkeypatch.setattr(banner_module.db, "session", banner_session) + monkeypatch.setattr(banner_module.FeatureService, "is_explore_banner_enabled", lambda: True) + + with app.test_request_context("/?language=en-US"): + result = api.get() + + assert result[0]["content"] == { + "category": "", + "title": "title only", + "description": "", + "img-src": "https://example.com/banner.png", + } + + def test_get_banners_returns_empty_without_querying_when_disabled( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + ): + api = banner_module.BannerApi() + session = MagicMock() + monkeypatch.setattr(banner_module.db, "session", session) + monkeypatch.setattr(banner_module.FeatureService, "is_explore_banner_enabled", lambda: False) + + with app.test_request_context("/"): + result = api.get() + + assert result == [] + session.scalars.assert_not_called() + + def test_get_banners_rejects_invalid_content( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + banner_session: Session, + ): + api = banner_module.BannerApi() + banner = _banner( + title="invalid", + language="en-US", + link="https://example.com", + created_at=datetime(2024, 1, 4), + ) + banner.content = {"title": "invalid"} + banner_session.add(banner) + banner_session.commit() + monkeypatch.setattr(banner_module.db, "session", banner_session) + monkeypatch.setattr(banner_module.FeatureService, "is_explore_banner_enabled", lambda: True) + + with app.test_request_context("/"): + with pytest.raises(ValidationError): + api.get() diff --git a/api/tests/unit_tests/services/test_feature_service_explore_banner.py b/api/tests/unit_tests/services/test_feature_service_explore_banner.py new file mode 100644 index 00000000000..35f74a96490 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_explore_banner.py @@ -0,0 +1,31 @@ +import pytest + +from services import feature_service as feature_service_module +from services.feature_service import FeatureService + + +@pytest.mark.parametrize( + ("edition", "enterprise_enabled", "configured", "expected"), + [ + ("CLOUD", False, True, True), + ("CLOUD", False, False, False), + ("SELF_HOSTED", False, True, False), + ("SELF_HOSTED", True, True, False), + ], +) +def test_get_system_features_enables_explore_banner_only_for_cloud( + monkeypatch: pytest.MonkeyPatch, + edition: str, + enterprise_enabled: bool, + configured: bool, + expected: bool, +) -> None: + monkeypatch.setattr(feature_service_module.dify_config, "EDITION", edition) + monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", enterprise_enabled) + monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_EXPLORE_BANNER", configured) + monkeypatch.setattr(FeatureService, "_fulfill_params_from_enterprise", lambda *_: None) + + result = FeatureService.get_system_features() + + assert FeatureService.is_explore_banner_enabled() is expected + assert result.enable_explore_banner is expected diff --git a/packages/contracts/generated/api/console/explore/types.gen.ts b/packages/contracts/generated/api/console/explore/types.gen.ts index 7528792fca1..55181dbc577 100644 --- a/packages/contracts/generated/api/console/explore/types.gen.ts +++ b/packages/contracts/generated/api/console/explore/types.gen.ts @@ -41,12 +41,12 @@ export type RecommendedAppDetailResponse = { } export type BannerResponse = { - content: unknown - created_at?: string | null + content: BannerContentResponse + created_at: string id: string - link?: string | null + link: string sort: number - status: string + status: BannerStatus } export type RecommendedAppInfoResponse = { @@ -59,6 +59,15 @@ export type RecommendedAppInfoResponse = { name?: string | null } +export type BannerContentResponse = { + category: string + description: string + 'img-src': string + title: string +} + +export type BannerStatus = 'disabled' | 'enabled' + export type RecommendedAppListResponseWritable = { categories: Array recommended_apps: Array diff --git a/packages/contracts/generated/api/console/explore/zod.gen.ts b/packages/contracts/generated/api/console/explore/zod.gen.ts index 895e7f9f230..efd87b24f91 100644 --- a/packages/contracts/generated/api/console/explore/zod.gen.ts +++ b/packages/contracts/generated/api/console/explore/zod.gen.ts @@ -20,23 +20,6 @@ export const zRecommendedAppDetailResponse = z.object({ */ export const zRecommendedAppDetailNullableResponse = zRecommendedAppDetailResponse.nullable() -/** - * BannerResponse - */ -export const zBannerResponse = z.object({ - content: z.unknown(), - created_at: z.string().nullish(), - id: z.string(), - link: z.string().nullish(), - sort: z.int(), - status: z.string(), -}) - -/** - * BannerListResponse - */ -export const zBannerListResponse = z.array(zBannerResponse) - /** * RecommendedAppInfoResponse */ @@ -81,6 +64,40 @@ export const zLearnDifyAppListResponse = z.object({ recommended_apps: z.array(zRecommendedAppResponse), }) +/** + * BannerContentResponse + */ +export const zBannerContentResponse = z.object({ + category: z.string(), + description: z.string(), + 'img-src': z.string().min(1), + title: z.string().min(1), +}) + +/** + * BannerStatus + * + * ExporleBanner status + */ +export const zBannerStatus = z.enum(['disabled', 'enabled']) + +/** + * BannerResponse + */ +export const zBannerResponse = z.object({ + content: zBannerContentResponse, + created_at: z.string(), + id: z.string(), + link: z.string(), + sort: z.int(), + status: zBannerStatus, +}) + +/** + * BannerListResponse + */ +export const zBannerListResponse = z.array(zBannerResponse) + /** * RecommendedAppInfoResponse */ diff --git a/web/app/(commonLayout)/page.tsx b/web/app/(commonLayout)/page.tsx index 0af351494d4..5933845b8b4 100644 --- a/web/app/(commonLayout)/page.tsx +++ b/web/app/(commonLayout)/page.tsx @@ -1,15 +1,5 @@ -'use client' +import { HomePage } from '@/features/home/page' -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import AppList from '@/app/components/explore/app-list' -import useDocumentTitle from '@/hooks/use-document-title' - -const Home = () => { - const { t } = useTranslation() - useDocumentTitle(t(($) => $['mainNav.home'], { ns: 'common' })) - - return +export default function Page() { + return } - -export default React.memo(Home) diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx index 50d0088cc92..aa98251cb0a 100644 --- a/web/app/components/explore/try-app/index.tsx +++ b/web/app/components/explore/try-app/index.tsx @@ -1,6 +1,6 @@ /* eslint-disable style/multiline-ternary */ 'use client' -import type { App as AppType } from '@/models/explore' +import type { RecommendedAppResponse } from '@dify/contracts/api/console/explore/types.gen' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs' @@ -17,7 +17,7 @@ import { TypeEnum } from './types' type Props = Readonly<{ appId: string - app: AppType + app: RecommendedAppResponse canCreate?: boolean categories?: string[] createButtonStepByStepTourTarget?: string diff --git a/web/app/components/explore/app-card/__tests__/index.spec.tsx b/web/features/home/__tests__/template-card.spec.tsx similarity index 77% rename from web/app/components/explore/app-card/__tests__/index.spec.tsx rename to web/features/home/__tests__/template-card.spec.tsx index 96a782c40ff..9b72f33152f 100644 --- a/web/app/components/explore/app-card/__tests__/index.spec.tsx +++ b/web/features/home/__tests__/template-card.spec.tsx @@ -1,15 +1,17 @@ +import type { + RecommendedAppInfoResponse, + RecommendedAppResponse, +} from '@dify/contracts/api/console/explore/types.gen' import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen' -import type { AppCardProps } from '../index' -import type { App } from '@/models/explore' import { fireEvent, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { trackEvent } from '@/app/components/base/amplitude' import { renderWithConsoleQuery } from '@/test/console/query-data' import { AppModeEnum } from '@/types/app' -import AppCard from '../index' +import { TemplateCard } from '../template-card' -vi.mock('../../../app/type-selector', () => ({ +vi.mock('@/app/components/app/type-selector', () => ({ AppTypeIcon: ({ type }: { type: string }) =>
{type}
, })) @@ -17,7 +19,12 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) -const createApp = (overrides?: Partial): App => ({ +type TemplateFixture = RecommendedAppResponse & { app: RecommendedAppInfoResponse } +type TemplateFixtureOverrides = Omit, 'app'> & { + app?: Partial +} + +const createApp = (overrides: TemplateFixtureOverrides = {}): TemplateFixture => ({ can_trial: true, app_id: 'app-id', description: 'App description', @@ -27,10 +34,6 @@ const createApp = (overrides?: Partial): App => ({ categories: ['Assistant'], position: 1, is_listed: true, - install_count: 0, - installed: false, - editable: true, - is_agent: false, ...overrides, app: { id: 'id-1', @@ -40,28 +43,25 @@ const createApp = (overrides?: Partial): App => ({ icon_background: '#fff', icon_url: '', name: 'Sample App', - description: 'App description', - use_icon_as_answer_icon: false, ...overrides?.app, }, }) -describe('AppCard', () => { +describe('TemplateCard', () => { const onCreate = vi.fn() const onTry = vi.fn() const mockTrackEvent = vi.mocked(trackEvent) let deploymentEdition: DeploymentEdition = 'CLOUD' - const renderComponent = (props?: Partial) => { - const mergedProps: AppCardProps = { + const renderComponent = (props?: Partial>) => { + const mergedProps: React.ComponentProps = { app: createApp(), canCreate: false, onCreate, onTry, - isExplore: false, ...props, } - return renderWithConsoleQuery(, { + return renderWithConsoleQuery(, { systemFeatures: { deployment_edition: deploymentEdition }, }) } @@ -108,11 +108,10 @@ describe('AppCard', () => { }) describe('User Interactions', () => { - it('should make the app card clickable in explore mode on cloud edition', () => { + it('should make the app card clickable on cloud edition', () => { renderComponent({ app: createApp({ app: { ...createApp().app, mode: AppModeEnum.WORKFLOW } }), canCreate: true, - isExplore: true, }) const cardButton = screen.getByRole('button', { name: 'Sample App' }) @@ -120,8 +119,8 @@ describe('AppCard', () => { expect(cardButton).toHaveAttribute('type', 'button') }) - it('should not render hover action buttons in explore mode', () => { - renderComponent({ canCreate: true, isExplore: true }) + it('should not render hover action buttons', () => { + renderComponent({ canCreate: true }) expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() expect(screen.queryByText('explore.appCard.try')).not.toBeInTheDocument() @@ -129,29 +128,22 @@ describe('AppCard', () => { it('should make the app card clickable outside cloud edition when create is allowed', () => { deploymentEdition = 'COMMUNITY' - renderComponent({ canCreate: true, isExplore: true }) + renderComponent({ canCreate: true }) expect(screen.getByRole('button', { name: 'Sample App' })).toHaveClass('cursor-pointer') }) it('should not make the app card clickable outside cloud edition when create is not allowed', () => { deploymentEdition = 'COMMUNITY' - renderComponent({ canCreate: false, isExplore: true }) + renderComponent({ canCreate: false }) expect(screen.queryByRole('button', { name: 'Sample App' })).not.toBeInTheDocument() }) }) describe('Props', () => { - it('should hide action buttons when not in explore mode', () => { - renderComponent({ canCreate: true, isExplore: false }) - - expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() - expect(screen.queryByText('explore.appCard.try')).not.toBeInTheDocument() - }) - it('should hide create button when canCreate is false', () => { - renderComponent({ canCreate: false, isExplore: true }) + renderComponent({ canCreate: false }) expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() }) @@ -176,18 +168,18 @@ describe('AppCard', () => { it('should call onTry when app card is clicked on cloud edition', () => { const app = createApp() - renderComponent({ app, canCreate: true, isExplore: true }) + renderComponent({ app, canCreate: true }) fireEvent.click(screen.getByRole('button', { name: 'Sample App' })) - expect(onTry).toHaveBeenCalledWith({ appId: 'app-id', app }) + expect(onTry).toHaveBeenCalledWith(app) expect(onCreate).not.toHaveBeenCalled() }) it('should call onCreate when app card is clicked outside cloud edition', () => { deploymentEdition = 'COMMUNITY' - renderComponent({ canCreate: true, isExplore: true }) + renderComponent({ canCreate: true }) fireEvent.click(screen.getByRole('button', { name: 'Sample App' })) @@ -200,18 +192,18 @@ describe('AppCard', () => { const user = userEvent.setup() const app = createApp() - renderComponent({ app, canCreate: true, isExplore: true }) + renderComponent({ app, canCreate: true }) screen.getByRole('button', { name: 'Sample App' }).focus() await user.keyboard('{Enter}') - expect(onTry).toHaveBeenCalledWith({ appId: 'app-id', app }) + expect(onTry).toHaveBeenCalledWith(app) }) it('should track preview event when app card is clicked', () => { const app = createApp() - renderComponent({ app, canCreate: true, isExplore: true }) + renderComponent({ app, canCreate: true }) fireEvent.click(screen.getByRole('button', { name: 'Sample App' })) diff --git a/web/app/components/explore/banner/__tests__/banner-item.spec.tsx b/web/features/home/banner/__tests__/banner-item.spec.tsx similarity index 83% rename from web/app/components/explore/banner/__tests__/banner-item.spec.tsx rename to web/features/home/banner/__tests__/banner-item.spec.tsx index 7849619689e..3c718098c9a 100644 --- a/web/app/components/explore/banner/__tests__/banner-item.spec.tsx +++ b/web/features/home/banner/__tests__/banner-item.spec.tsx @@ -1,5 +1,5 @@ +import type { BannerResponse } from '@dify/contracts/api/console/explore/types.gen' import type { ComponentProps } from 'react' -import type { Banner } from '@/models/app' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { BannerItem } from '../banner-item' @@ -10,22 +10,23 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: (...args: unknown[]) => mockTrackEvent(...args), })) -const createMockBanner = (overrides: Partial = {}): Banner => - ({ - id: 'banner-1', - status: 'enabled', - link: 'https://example.com', - content: { - category: 'Featured', - title: 'Test Banner Title', - description: 'Test banner description text', - 'img-src': 'https://example.com/image.png', - }, - ...overrides, - }) as Banner +const createMockBanner = (overrides: Partial = {}): BannerResponse => ({ + id: 'banner-1', + status: 'enabled', + link: 'https://example.com', + created_at: '2024-01-01T00:00:00Z', + content: { + category: 'Featured', + title: 'Test Banner Title', + description: 'Test banner description text', + 'img-src': 'https://example.com/image.png', + }, + sort: 1, + ...overrides, +}) const renderBannerItem = ( - banner: Banner = createMockBanner(), + banner: BannerResponse = createMockBanner(), props: Partial> = {}, ) => render() diff --git a/web/app/components/explore/banner/__tests__/banner.spec.tsx b/web/features/home/banner/__tests__/banner.spec.tsx similarity index 92% rename from web/app/components/explore/banner/__tests__/banner.spec.tsx rename to web/features/home/banner/__tests__/banner.spec.tsx index 72d4409edc3..8539d8c06d3 100644 --- a/web/app/components/explore/banner/__tests__/banner.spec.tsx +++ b/web/features/home/banner/__tests__/banner.spec.tsx @@ -1,4 +1,4 @@ -import type { Banner as BannerType } from '@/models/app' +import type { BannerResponse } from '@dify/contracts/api/console/explore/types.gen' import { cleanup, fireEvent, screen } from '@testing-library/react' import * as React from 'react' import { act } from 'react' @@ -132,7 +132,7 @@ vi.mock('../banner-item', () => ({ accountId, titleId, }: { - banner: BannerType + banner: BannerResponse sort: number language: string accountId?: string @@ -152,20 +152,21 @@ vi.mock('../banner-item', () => ({ const createMockBanner = ( id: string, - status: string = 'enabled', + status: BannerResponse['status'] = 'enabled', title: string = 'Test Banner', -): BannerType => - ({ - id, - status, - link: 'https://example.com', - content: { - category: 'Featured', - title, - description: 'Test description', - 'img-src': `https://example.com/image-${id}.png`, - }, - }) as BannerType +): BannerResponse => ({ + id, + status, + link: 'https://example.com', + created_at: '2024-01-01T00:00:00Z', + content: { + category: 'Featured', + title, + description: 'Test description', + 'img-src': `https://example.com/image-${id}.png`, + }, + sort: 1, +}) describe('Banner', () => { beforeEach(() => { @@ -180,21 +181,22 @@ describe('Banner', () => { afterEach(cleanup) - it('renders the greeting shell without a carousel when no enabled banner exists', () => { - render() + it('renders nothing when there are no banners', () => { + render() - expect(screen.getByText('Welcome back, Evan👋')).toBeInTheDocument() - expect(screen.getByText('What if… this is where your next idea begins.')).toBeInTheDocument() + expect(screen.queryByText('Welcome back, Evan👋')).not.toBeInTheDocument() + expect( + screen.queryByText('What if… this is where your next idea begins.'), + ).not.toBeInTheDocument() expect(screen.queryByRole('region')).not.toBeInTheDocument() }) - it('labels the carousel and renders only enabled banners', () => { + it('labels the carousel and renders its banners', () => { render( , ) @@ -202,7 +204,6 @@ describe('Banner', () => { expect(screen.getByRole('region', { name: 'Featured' })).toBeInTheDocument() expect(screen.getByRole('group', { name: 'pagination.pageNumber' })).toBeInTheDocument() expect(screen.getAllByTestId('banner-item')).toHaveLength(2) - expect(screen.queryByText('Hidden banner')).not.toBeInTheDocument() }) it('keeps only the active slide exposed to assistive technology and keyboard focus', () => { diff --git a/web/app/components/explore/banner/__tests__/indicator-button.spec.tsx b/web/features/home/banner/__tests__/indicator-button.spec.tsx similarity index 100% rename from web/app/components/explore/banner/__tests__/indicator-button.spec.tsx rename to web/features/home/banner/__tests__/indicator-button.spec.tsx diff --git a/web/app/components/explore/banner/banner-item.tsx b/web/features/home/banner/banner-item.tsx similarity index 96% rename from web/app/components/explore/banner/banner-item.tsx rename to web/features/home/banner/banner-item.tsx index 61fe71b29b7..739e519c019 100644 --- a/web/app/components/explore/banner/banner-item.tsx +++ b/web/features/home/banner/banner-item.tsx @@ -1,8 +1,8 @@ -import type { Banner } from '@/models/app' +import type { BannerResponse } from '@dify/contracts/api/console/explore/types.gen' import { trackEvent } from '@/app/components/base/amplitude' type BannerItemProps = { - banner: Banner + banner: BannerResponse sort: number language: string accountId?: string diff --git a/web/app/components/explore/banner/banner.tsx b/web/features/home/banner/banner.tsx similarity index 91% rename from web/app/components/explore/banner/banner.tsx rename to web/features/home/banner/banner.tsx index 5cd13f548be..f0a15213566 100644 --- a/web/app/components/explore/banner/banner.tsx +++ b/web/features/home/banner/banner.tsx @@ -1,5 +1,5 @@ +import type { BannerResponse } from '@dify/contracts/api/console/explore/types.gen' import type { ComponentProps, FocusEvent } from 'react' -import type { Banner as BannerType } from '@/models/app' import { useAtomValue } from 'jotai' import { useEffect, useId, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -18,13 +18,13 @@ const CAROUSEL_OPTIONS = { } satisfies NonNullable['opts']> type BannerCarouselContentProps = { - banners: BannerType[] + banners: BannerResponse[] accountId?: string language: string } type BannerSlideProps = { - banner: BannerType + banner: BannerResponse index: number isActive: boolean accountId?: string @@ -184,15 +184,13 @@ function BannerCarouselContent({ banners, accountId, language }: BannerCarouselC } type BannerProps = { - banners: BannerType[] + banners: BannerResponse[] } export function Banner({ banners }: BannerProps) { const { t } = useTranslation() const locale = useLocale() const userProfile = useAtomValue(userProfileAtom) - const enabledBanners = banners.filter((banner) => banner.status === 'enabled') - const carouselLabel = enabledBanners[0]?.content.category || enabledBanners[0]?.content.title const [carouselPlugins] = useState(() => [ Carousel.Plugin.Fade(), Carousel.Plugin.Autoplay({ @@ -205,6 +203,11 @@ export function Banner({ banners }: BannerProps) { }, }), ]) + const firstBanner = banners[0] + + if (!firstBanner) return null + + const carouselLabel = firstBanner.content.category || firstBanner.content.title return (
@@ -217,20 +220,14 @@ export function Banner({ banners }: BannerProps) {

- {enabledBanners.length > 0 ? ( - - - - ) : null} + + + ) } diff --git a/web/features/home/banner/home-banner.tsx b/web/features/home/banner/home-banner.tsx new file mode 100644 index 00000000000..fbdabb63b03 --- /dev/null +++ b/web/features/home/banner/home-banner.tsx @@ -0,0 +1,17 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { useLocale } from '@/context/i18n' +import { consoleQuery } from '@/service/client' +import { Banner } from './banner' + +export function HomeBanner() { + const locale = useLocale() + const { data: banners } = useSuspenseQuery( + consoleQuery.explore.banners.get.queryOptions({ + input: { query: { language: locale } }, + }), + ) + + return +} diff --git a/web/app/components/explore/banner/indicator-button.module.css b/web/features/home/banner/indicator-button.module.css similarity index 100% rename from web/app/components/explore/banner/indicator-button.module.css rename to web/features/home/banner/indicator-button.module.css diff --git a/web/app/components/explore/banner/indicator-button.tsx b/web/features/home/banner/indicator-button.tsx similarity index 100% rename from web/app/components/explore/banner/indicator-button.tsx rename to web/features/home/banner/indicator-button.tsx diff --git a/web/app/components/explore/continue-work/__tests__/item.spec.tsx b/web/features/home/continue-work/__tests__/item.spec.tsx similarity index 99% rename from web/app/components/explore/continue-work/__tests__/item.spec.tsx rename to web/features/home/continue-work/__tests__/item.spec.tsx index 0c849e79755..be3aaf7fc17 100644 --- a/web/app/components/explore/continue-work/__tests__/item.spec.tsx +++ b/web/features/home/continue-work/__tests__/item.spec.tsx @@ -4,7 +4,7 @@ import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithConsoleQuery } from '@/test/console/query-data' import { AppACLPermission } from '@/utils/permission' -import ContinueWorkItem from '../item' +import { ContinueWorkItem } from '../item' const mockConsoleState = vi.hoisted(() => ({ userProfile: { id: 'user-1' }, diff --git a/web/app/components/explore/continue-work/index.tsx b/web/features/home/continue-work/continue-work.tsx similarity index 89% rename from web/app/components/explore/continue-work/index.tsx rename to web/features/home/continue-work/continue-work.tsx index 08458aae429..632028fe7f2 100644 --- a/web/app/components/explore/continue-work/index.tsx +++ b/web/features/home/continue-work/continue-work.tsx @@ -2,17 +2,16 @@ import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen' import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' import { useTranslation } from 'react-i18next' import Link from '@/next/link' -import ContinueWorkItem from './item' +import { ContinueWorkItem } from './item' type ContinueWorkProps = { apps: RecentAppResponse[] className?: string } -const ContinueWork = ({ apps, className }: ContinueWorkProps) => { +export function ContinueWork({ apps, className }: ContinueWorkProps) { const { t } = useTranslation() if (apps.length === 0) return null @@ -42,5 +41,3 @@ const ContinueWork = ({ apps, className }: ContinueWorkProps) => { ) } - -export default React.memo(ContinueWork) diff --git a/web/app/components/explore/continue-work/item.tsx b/web/features/home/continue-work/item.tsx similarity index 97% rename from web/app/components/explore/continue-work/item.tsx rename to web/features/home/continue-work/item.tsx index 45ecd74031a..a1e1f4944bc 100644 --- a/web/app/components/explore/continue-work/item.tsx +++ b/web/features/home/continue-work/item.tsx @@ -29,7 +29,7 @@ type ContinueWorkItemProps = { app: RecentAppResponse } -const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => { +export function ContinueWorkItem({ app }: ContinueWorkItemProps) { const { t } = useTranslation() const { formatTimeFromNow } = useFormatTimeFromNow() const currentUserId = useAtomValue(userProfileIdAtom) @@ -133,5 +133,3 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => { ) } - -export default React.memo(ContinueWorkItem) diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/features/home/home-content/__tests__/home-content.spec.tsx similarity index 86% rename from web/app/components/explore/app-list/__tests__/index.spec.tsx rename to web/features/home/home-content/__tests__/home-content.spec.tsx index f33cb348056..339e530e0a0 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/features/home/home-content/__tests__/home-content.spec.tsx @@ -1,33 +1,34 @@ import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { + BannerResponse, + RecommendedAppInfoResponse, + RecommendedAppResponse, +} from '@dify/contracts/api/console/explore/types.gen' import type { StepByStepTourStatePatchPayload, StepByStepTourStateResponse, } from '@dify/contracts/api/console/onboarding/types.gen' import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen' import type { ReactNode } from 'react' -import type { Mock } from 'vitest' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types' -import type { Banner as BannerType } from '@/models/app' -import type { App } from '@/models/explore' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createStore, Provider as JotaiProvider, useSetAtom } from 'jotai' import { queryClientAtom } from 'jotai-tanstack-query' import { useHydrateAtoms } from 'jotai/utils' +import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage' import { resetStepByStepTourSessionAtom, stepByStepTourSessionAtom, } from '@/app/components/step-by-step-tour/state' import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' -import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture' import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' -import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '../../learn-dify/storage' -import AppList from '../index' +import { HomeContent } from '../home-content' type StepByStepTourTestUiState = StepByStepTourSessionState & { minimized: boolean } @@ -51,22 +52,19 @@ const mockConsoleState = vi.hoisted(() => ({ workspacePermissionKeys: [] as string[], })) -let mockExploreData: { categories: string[]; allList: App[] } | undefined = { +let mockExploreData: { categories: string[]; allList: RecommendedAppResponse[] } | undefined = { categories: [], allList: [], } -let mockLearnDifyApps: App[] = [] +let mockLearnDifyApps: RecommendedAppResponse[] = [] let mockLearnDifyLoading = false let mockWorkspaceApps: RecentAppResponse[] = [] -let mockWorkspaceAppsLoading = false -let mockBanners: BannerType[] = [] -let mockBannersLoading = false -let mockIsLoading = false -let mockIsError = false +let mockBanners: BannerResponse[] = [] const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() const mockTrackCreateApp = vi.fn() const mockTrackEvent = vi.hoisted(() => vi.fn()) +const mockGetRecommendedApp = vi.hoisted(() => vi.fn()) const mockAppQueries = vi.hoisted(() => ({ listQueryOptions: vi.fn(), recentQueryOptions: vi.fn(), @@ -217,12 +215,6 @@ vi.mock('@/service/use-explore', () => ({ }), })) -vi.mock('@/service/explore', () => ({ - fetchAppDetail: vi.fn(), - fetchAppList: vi.fn(), - fetchBanners: vi.fn(), -})) - vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: mockTrackEvent, })) @@ -255,13 +247,6 @@ vi.mock('@/service/client', () => ({ }) => { mockAppQueries.listQueryOptions(options) const limit = options.input?.query?.limit ?? mockWorkspaceApps.length - if (mockWorkspaceAppsLoading) { - return { - queryKey: ['console', 'apps', 'get', options], - queryFn: () => new Promise(() => {}), - select: options.select, - } - } const response = { data: mockWorkspaceApps.slice(0, limit), has_more: false, @@ -285,13 +270,6 @@ vi.mock('@/service/client', () => ({ }) => { mockAppQueries.recentQueryOptions(options) const limit = options.input?.query?.limit ?? mockWorkspaceApps.length - if (mockWorkspaceAppsLoading) { - return { - queryKey: ['console', 'apps', 'recent', 'get', options], - queryFn: () => new Promise(() => {}), - select: options.select, - } - } const response = { data: mockWorkspaceApps.slice(0, limit), } @@ -326,6 +304,14 @@ vi.mock('@/service/client', () => ({ }, explore: { apps: { + byAppId: { + get: { + queryOptions: (options: { input: { params: { app_id: string } } }) => ({ + queryKey: ['console', 'explore', 'apps', 'byAppId', 'get', options.input], + queryFn: () => mockGetRecommendedApp(options.input), + }), + }, + }, get: { queryKey: ({ input }: { input?: unknown } = {}) => [ 'console', @@ -334,6 +320,24 @@ vi.mock('@/service/client', () => ({ 'get', input, ], + queryOptions: (options: { + input?: { query?: { language?: string } } + select?: (response: { + categories: string[] + recommended_apps: RecommendedAppResponse[] + }) => unknown + }) => { + const response = { + categories: mockExploreData?.categories ?? [], + recommended_apps: mockExploreData?.allList ?? [], + } + return { + queryKey: ['console', 'explore', 'apps', 'get', options.input], + queryFn: () => Promise.resolve(response), + initialData: response, + select: options.select, + } + }, }, }, banners: { @@ -345,6 +349,15 @@ vi.mock('@/service/client', () => ({ 'get', input, ], + queryOptions: (options: { + input?: { query?: { language?: string } } + select?: (response: BannerResponse[]) => unknown + }) => ({ + queryKey: ['console', 'explore', 'banners', 'get', options.input], + queryFn: () => Promise.resolve(mockBanners), + initialData: mockBanners, + select: options.select, + }), }, }, }, @@ -410,7 +423,7 @@ vi.mock('@/app/components/explore/create-app-modal', () => ({ }, })) -vi.mock('../../try-app', () => ({ +vi.mock('@/app/components/explore/try-app', () => ({ default: ({ canCreate = true, createButtonStepByStepTourTarget, @@ -439,9 +452,9 @@ vi.mock('../../try-app', () => ({ ), })) -vi.mock('../../banner/banner', () => ({ - Banner: ({ banners }: { banners: BannerType[] }) => ( -
+vi.mock('../../banner/home-banner', () => ({ + HomeBanner: () => ( +
banner
), @@ -460,7 +473,12 @@ vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({ ), })) -const createApp = (overrides: Partial = {}): App => ({ +type AppFixture = RecommendedAppResponse & { app: RecommendedAppInfoResponse } +type AppFixtureOverrides = Omit, 'app'> & { + app?: Partial +} + +const createApp = (overrides: AppFixtureOverrides = {}): AppFixture => ({ app: { id: overrides.app?.id ?? 'app-basic-id', mode: overrides.app?.mode ?? AppModeEnum.CHAT, @@ -469,8 +487,6 @@ const createApp = (overrides: Partial = {}): App => ({ icon_background: overrides.app?.icon_background ?? '#fff', icon_url: overrides.app?.icon_url ?? '', name: overrides.app?.name ?? 'Alpha', - description: overrides.app?.description ?? 'Alpha description', - use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, }, can_trial: true, app_id: overrides.app_id ?? 'app-1', @@ -481,10 +497,6 @@ const createApp = (overrides: Partial = {}): App => ({ categories: overrides.categories ?? ['Writing'], position: overrides.position ?? 1, is_listed: overrides.is_listed ?? true, - install_count: overrides.install_count ?? 0, - installed: overrides.installed ?? false, - editable: overrides.editable ?? false, - is_agent: overrides.is_agent ?? false, }) const createWorkspaceApp = (overrides: Partial = {}): RecentAppResponse => ({ @@ -501,7 +513,7 @@ const createWorkspaceApp = (overrides: Partial = {}): RecentA permission_keys: overrides.permission_keys, }) -const createBanner = (overrides: Partial = {}): BannerType => ({ +const createBanner = (overrides: Partial = {}): BannerResponse => ({ id: overrides.id ?? 'banner-1', status: overrides.status ?? 'enabled', link: overrides.link ?? 'https://example.com', @@ -520,22 +532,23 @@ const mockAppCreatePermission = (hasEditPermission: boolean) => { } type RenderOptions = { + hasEditPermission?: boolean enableExploreBanner?: boolean enableLearnApp?: boolean extra?: ReactNode deploymentEdition?: DeploymentEdition + searchParams?: Record } const localeInput = { query: { language: 'en-US' } } -const exploreAppListQueryKey = ['console', 'explore', 'apps', 'get', localeInput, 'en-US'] -const exploreBannersQueryKey = ['console', 'explore', 'banners', 'get', localeInput, 'en-US'] +const homeTemplatesQueryKey = ['console', 'explore', 'apps', 'get', localeInput] +const exploreBannersQueryKey = ['console', 'explore', 'banners', 'get', localeInput] -const renderAppList = ( +const renderHomeContent = ({ hasEditPermission = false, - onSuccess?: () => void, - searchParams?: Record, - options: RenderOptions = {}, -) => { + searchParams, + ...options +}: RenderOptions = {}) => { mockAppCreatePermission(hasEditPermission) const { wrapper: ConsoleQueryWrapper, queryClient } = createConsoleQueryWrapper({ systemFeatures: { @@ -544,35 +557,19 @@ const renderAppList = ( enable_learn_app: options.enableLearnApp ?? true, }, }) - if (!mockIsLoading && !mockIsError && mockExploreData) - queryClient.setQueryData(exploreAppListQueryKey, mockExploreData) - if (options.enableExploreBanner && !mockBannersLoading) - queryClient.setQueryData(exploreBannersQueryKey, mockBanners) + if (mockExploreData) { + queryClient.setQueryData(homeTemplatesQueryKey, { + categories: mockExploreData.categories, + recommended_apps: mockExploreData.allList, + }) + } + if (options.enableExploreBanner) queryClient.setQueryData(exploreBannersQueryKey, mockBanners) queryClient.setQueryData(mockStepByStepTour.stateQueryKey, mockStepByStepTour.state) - const mockFetchAppList = fetchAppList as unknown as Mock - const mockFetchBanners = fetchBanners as unknown as Mock const jotaiStore = createStore() seedRegisteredConsoleStateFixture(jotaiStore) jotaiStore.set(queryClientAtom, queryClient) - if (mockIsLoading) { - mockFetchAppList.mockImplementation(() => new Promise(() => {})) - } else if (mockIsError) { - mockFetchAppList.mockRejectedValue(new Error('Failed to load explore apps')) - } else { - mockFetchAppList.mockResolvedValue({ - categories: mockExploreData?.categories ?? [], - recommended_apps: mockExploreData?.allList ?? [], - }) - } - - if (mockBannersLoading) { - mockFetchBanners.mockImplementation(() => new Promise(() => {})) - } else { - mockFetchBanners.mockResolvedValue(mockBanners) - } - const Wrapped = ({ children }: { children: ReactNode }) => ( @@ -585,7 +582,7 @@ const renderAppList = ( ) const rendered = renderWithNuqs( - + , { searchParams }, ) @@ -602,7 +599,7 @@ function SkipHomeGuideProbe() { ) } -describe('AppList', () => { +describe('HomeContent', () => { beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() @@ -624,11 +621,7 @@ describe('AppList', () => { ] mockLearnDifyLoading = false mockWorkspaceApps = [] - mockWorkspaceAppsLoading = false mockBanners = [] - mockBannersLoading = false - mockIsLoading = false - mockIsError = false mockStepByStepTour.reset() }) @@ -637,33 +630,6 @@ describe('AppList', () => { }) describe('Rendering', () => { - it('should render the home shell skeleton when the explore query is loading', () => { - mockExploreData = undefined - mockIsLoading = true - mockWorkspaceAppsLoading = true - mockLearnDifyLoading = true - - renderAppList() - - expect(screen.queryByText('explore.apps.description')).not.toBeInTheDocument() - expect(screen.getAllByRole('status', { name: 'common.loading' })).toHaveLength(1) - }) - - it('should keep the whole home page in the initial skeleton while continue work apps are loading', () => { - mockExploreData = { - categories: ['Writing'], - allList: [createApp()], - } - mockWorkspaceAppsLoading = true - - renderAppList() - - expect( - screen.queryByRole('heading', { name: 'explore.continueWork.title' }), - ).not.toBeInTheDocument() - expect(screen.getAllByRole('status', { name: 'common.loading' })).toHaveLength(1) - }) - it('should not render learn dify content while learn dify items are loading', () => { mockExploreData = { categories: ['Writing'], @@ -672,7 +638,7 @@ describe('AppList', () => { mockLearnDifyApps = [] mockLearnDifyLoading = true - renderAppList() + renderHomeContent() expect( screen.queryByRole('heading', { name: 'explore.learnDify.title' }), @@ -689,7 +655,7 @@ describe('AppList', () => { mockLearnDifyLoading = true localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true') - renderAppList() + renderHomeContent() expect( screen.queryByRole('heading', { name: 'explore.learnDify.title' }), @@ -710,7 +676,7 @@ describe('AppList', () => { ], } - renderAppList() + renderHomeContent() expect(screen.getByText('Alpha')).toBeInTheDocument() expect(screen.getByText('Beta')).toBeInTheDocument() @@ -739,7 +705,7 @@ describe('AppList', () => { createWorkspaceApp({ id: 'app-9', name: 'Hidden Ninth App', author_name: 'Riley' }), ] - renderAppList() + renderHomeContent() expect( screen.getByRole('heading', { name: 'explore.continueWork.title' }), @@ -773,7 +739,7 @@ describe('AppList', () => { } mockWorkspaceApps = [createWorkspaceApp()] - renderAppList() + renderHomeContent() expect(mockAppQueries.recentQueryOptions).toHaveBeenCalledWith( expect.objectContaining({ @@ -801,7 +767,7 @@ describe('AppList', () => { }), ] - renderAppList() + renderHomeContent() const card = screen.getByRole('button', { name: /Preview Only App.*app\.types\.chatbot/ }) expect(card).toHaveAttribute('aria-disabled', 'true') @@ -823,7 +789,7 @@ describe('AppList', () => { } mockWorkspaceApps = [] - renderAppList() + renderHomeContent() expect( screen.queryByRole('heading', { name: 'explore.continueWork.title' }), @@ -836,7 +802,7 @@ describe('AppList', () => { allList: [createApp()], } - renderAppList() + renderHomeContent() const learnDifyHeading = screen.getByRole('heading', { name: 'explore.learnDify.title' }) expect(learnDifyHeading).toBeInTheDocument() @@ -861,7 +827,7 @@ describe('AppList', () => { allList: [createApp()], } - renderAppList(false, undefined, undefined, { enableLearnApp: false }) + renderHomeContent({ enableLearnApp: false }) expect( screen.queryByRole('heading', { name: 'explore.learnDify.title' }), @@ -875,7 +841,7 @@ describe('AppList', () => { allList: [createApp()], } - renderAppList() + renderHomeContent() fireEvent.click(screen.getByRole('button', { name: 'explore.learnDify.hide' })) @@ -910,7 +876,7 @@ describe('AppList', () => { ], } - renderAppList(false, undefined, { category: 'Writing' }) + renderHomeContent({ searchParams: { category: 'Writing' } }) expect(screen.getByText('Alpha')).toBeInTheDocument() expect(screen.queryByText('Beta')).not.toBeInTheDocument() @@ -922,7 +888,7 @@ describe('AppList', () => { allList: [createApp()], } - renderAppList(false, undefined, { category: 'c' }) + renderHomeContent({ searchParams: { category: 'c' } }) expect(screen.queryByRole('radio', { name: 'c' })).not.toBeInTheDocument() expect(screen.getByText('Alpha')).toBeInTheDocument() @@ -941,7 +907,7 @@ describe('AppList', () => { ], } - renderAppList(false, undefined, { category: 'Writing' }) + renderHomeContent({ searchParams: { category: 'Writing' } }) const input = screen.getByPlaceholderText('common.operation.search') fireEvent.change(input, { target: { value: 'alp' } }) @@ -967,7 +933,7 @@ describe('AppList', () => { createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } }), ], } - renderAppList() + renderHomeContent() const input = screen.getByPlaceholderText('common.operation.search') fireEvent.change(input, { target: { value: 'gam' } }) @@ -982,12 +948,11 @@ describe('AppList', () => { it('should handle create flow from app card when outside cloud edition and confirm DSL when pending', async () => { vi.useRealTimers() - const onSuccess = vi.fn() mockExploreData = { categories: ['Writing'], allList: [createApp()], } - ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + mockGetRecommendedApp.mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT, }) @@ -1002,12 +967,12 @@ describe('AppList', () => { }, ) - renderAppList(true, onSuccess) + renderHomeContent({ hasEditPermission: true }) fireEvent.click(screen.getByRole('button', { name: 'Alpha' })) fireEvent.click(await screen.findByTestId('confirm-create')) await waitFor(() => { - expect(fetchAppDetail).toHaveBeenCalledWith('app-basic-id') + expect(mockGetRecommendedApp).toHaveBeenCalledWith({ params: { app_id: 'app-1' } }) }) expect(mockHandleImportDSL).toHaveBeenCalledTimes(1) expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument() @@ -1020,7 +985,6 @@ describe('AppList', () => { appMode: AppModeEnum.CHAT, templateId: 'app-1', }) - expect(onSuccess).toHaveBeenCalledTimes(1) }) }) @@ -1031,7 +995,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], } - ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + mockGetRecommendedApp.mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT, }) @@ -1044,12 +1008,12 @@ describe('AppList', () => { }, ) - renderAppList(true) + renderHomeContent({ hasEditPermission: true }) await user.click(await screen.findByRole('button', { name: 'Learn Workflow Basics' })) await user.click(await screen.findByTestId('confirm-create')) await waitFor(() => { - expect(fetchAppDetail).toHaveBeenCalledWith('learn-basic-1') + expect(mockGetRecommendedApp).toHaveBeenCalledWith({ params: { app_id: 'learn-1' } }) }) expect(mockHandleImportDSL).toHaveBeenCalledWith( expect.any(Object), @@ -1073,7 +1037,7 @@ describe('AppList', () => { minimized: true, }) - renderAppList(true, undefined, undefined, { deploymentEdition: 'CLOUD' }) + renderHomeContent({ hasEditPermission: true, deploymentEdition: 'CLOUD' }) await user.click(await screen.findByRole('button', { name: 'Learn Workflow Basics' })) @@ -1098,7 +1062,8 @@ describe('AppList', () => { minimized: true, }) - renderAppList(true, undefined, undefined, { + renderHomeContent({ + hasEditPermission: true, extra: , deploymentEdition: 'CLOUD', }) @@ -1131,7 +1096,7 @@ describe('AppList', () => { minimized: true, }) - renderAppList(false, undefined, undefined, { deploymentEdition: 'CLOUD' }) + renderHomeContent({ deploymentEdition: 'CLOUD' }) await user.click(await screen.findByRole('button', { name: 'Learn Workflow Basics' })) @@ -1168,7 +1133,7 @@ describe('AppList', () => { activeGuideIndexes: [0, 1], minimized: true, }) - ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + mockGetRecommendedApp.mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT, }) @@ -1181,7 +1146,7 @@ describe('AppList', () => { }, ) - renderAppList(true, undefined, undefined, { deploymentEdition: 'CLOUD' }) + renderHomeContent({ hasEditPermission: true, deploymentEdition: 'CLOUD' }) await user.click(await screen.findByRole('button', { name: 'Learn Workflow Basics' })) await user.click(await screen.findByTestId('try-app-create')) @@ -1225,7 +1190,7 @@ describe('AppList', () => { minimized: true, }) mockStepByStepTour.patchState.mockRejectedValueOnce(new Error('patch failed')) - ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + mockGetRecommendedApp.mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT, }) @@ -1238,7 +1203,7 @@ describe('AppList', () => { }, ) - renderAppList(true, undefined, undefined, { deploymentEdition: 'CLOUD' }) + renderHomeContent({ hasEditPermission: true, deploymentEdition: 'CLOUD' }) await user.click(await screen.findByRole('button', { name: 'Learn Workflow Basics' })) await user.click(await screen.findByTestId('try-app-create')) @@ -1290,7 +1255,7 @@ describe('AppList', () => { activeGuideIndex: 0, minimized: true, }) - ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + mockGetRecommendedApp.mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT, }) @@ -1305,7 +1270,7 @@ describe('AppList', () => { }, ) - renderAppList(true, undefined, undefined, { deploymentEdition: 'CLOUD' }) + renderHomeContent({ hasEditPermission: true, deploymentEdition: 'CLOUD' }) await user.click(await screen.findByRole('button', { name: 'Learn Workflow Basics' })) await user.click(await screen.findByTestId('try-app-create')) @@ -1335,7 +1300,7 @@ describe('AppList', () => { minimized: true, }) - renderAppList(true, undefined, undefined, { deploymentEdition: 'CLOUD' }) + renderHomeContent({ hasEditPermission: true, deploymentEdition: 'CLOUD' }) await user.click(await screen.findByRole('button', { name: 'Learn Workflow Basics' })) const createFromDetailsButton = await screen.findByTestId('try-app-create') @@ -1365,7 +1330,7 @@ describe('AppList', () => { createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } }), ], } - renderAppList() + renderHomeContent() const input = screen.getByPlaceholderText('common.operation.search') fireEvent.change(input, { target: { value: 'gam' } }) @@ -1383,39 +1348,18 @@ describe('AppList', () => { expect(screen.getByText('Gamma')).toBeInTheDocument() }) - it('should render nothing when isError is true', async () => { - vi.useRealTimers() - mockIsError = true - mockExploreData = undefined - - const { container } = renderAppList() - - await waitFor(() => { - expect(container.innerHTML).toBe('') - }) - }) - - it('should render the initial skeleton while app list data is pending', () => { - mockExploreData = undefined - mockIsLoading = true - - renderAppList() - - expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument() - }) - it('should close create modal via hide button', async () => { vi.useRealTimers() mockExploreData = { categories: ['Writing'], allList: [createApp()], } - ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + mockGetRecommendedApp.mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT, }) - renderAppList(true) + renderHomeContent({ hasEditPermission: true }) fireEvent.click(screen.getByRole('button', { name: 'Alpha' })) expect(await screen.findByTestId('create-app-modal')).toBeInTheDocument() @@ -1432,7 +1376,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], } - ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + mockGetRecommendedApp.mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT, }) @@ -1445,7 +1389,7 @@ describe('AppList', () => { }, ) - renderAppList(true) + renderHomeContent({ hasEditPermission: true }) fireEvent.click(screen.getByRole('button', { name: 'Alpha' })) fireEvent.click(await screen.findByTestId('confirm-create')) @@ -1460,7 +1404,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], } - ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + mockGetRecommendedApp.mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT, }) @@ -1470,7 +1414,7 @@ describe('AppList', () => { }, ) - renderAppList(true) + renderHomeContent({ hasEditPermission: true }) fireEvent.click(screen.getByRole('button', { name: 'Alpha' })) fireEvent.click(await screen.findByTestId('confirm-create')) @@ -1493,7 +1437,7 @@ describe('AppList', () => { allList: [createApp()], } - renderAppList(true, undefined, undefined, { deploymentEdition: 'CLOUD' }) + renderHomeContent({ hasEditPermission: true, deploymentEdition: 'CLOUD' }) fireEvent.click(screen.getByRole('button', { name: 'Alpha' })) expect(await screen.findByTestId('try-app-panel')).toBeInTheDocument() @@ -1511,7 +1455,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], } - ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ + mockGetRecommendedApp.mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT, }) @@ -1524,7 +1468,7 @@ describe('AppList', () => { }, ) - renderAppList(true, undefined, undefined, { deploymentEdition: 'CLOUD' }) + renderHomeContent({ hasEditPermission: true, deploymentEdition: 'CLOUD' }) fireEvent.click(screen.getByRole('button', { name: 'Alpha' })) await screen.findByTestId('try-app-panel') @@ -1547,7 +1491,7 @@ describe('AppList', () => { allList: [createApp()], } - renderAppList(true, undefined, undefined, { deploymentEdition: 'CLOUD' }) + renderHomeContent({ hasEditPermission: true, deploymentEdition: 'CLOUD' }) fireEvent.click(screen.getByRole('button', { name: 'Alpha' })) expect(await screen.findByTestId('try-app-panel')).toBeInTheDocument() @@ -1565,23 +1509,10 @@ describe('AppList', () => { } mockBanners = [createBanner()] - renderAppList(false, undefined, undefined, { enableExploreBanner: true }) + renderHomeContent({ enableExploreBanner: true }) expect(screen.getByTestId('explore-banner')).toBeInTheDocument() expect(screen.getByTestId('explore-banner')).toHaveAttribute('data-banner-count', '1') }) - - it('should keep the whole home page in the initial skeleton while banners are loading', () => { - mockExploreData = { - categories: ['Writing'], - allList: [createApp()], - } - mockBannersLoading = true - - renderAppList(false, undefined, undefined, { enableExploreBanner: true }) - - expect(screen.queryByTestId('explore-banner')).not.toBeInTheDocument() - expect(screen.getAllByRole('status', { name: 'common.loading' })).toHaveLength(1) - }) }) }) diff --git a/web/app/components/explore/app-list/index.tsx b/web/features/home/home-content/home-content.tsx similarity index 66% rename from web/app/components/explore/app-list/index.tsx rename to web/features/home/home-content/home-content.tsx index 62259226f30..58271e49f59 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/features/home/home-content/home-content.tsx @@ -1,22 +1,16 @@ 'use client' -import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { RecommendedAppResponse } from '@dify/contracts/api/console/explore/types.gen' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { StepByStepTourTaskId } from '@/app/components/step-by-step-tour/types' -import type { Banner as BannerType } from '@/models/app' -import type { App } from '@/models/explore' -import type { TryAppSelection } from '@/types/try-app' import type { TrackCreateAppParams } from '@/utils/create-app-tracking' import { cn } from '@langgenius/dify-ui/cn' -import { queryOptions, useQueries, useSuspenseQuery } from '@tanstack/react-query' +import { useQueryClient, useSuspenseQueries, useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { useAtomValue, useSetAtom } from 'jotai' import { useQueryState } from 'nuqs' -import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import AppCard from '@/app/components/explore/app-card' -import { Banner } from '@/app/components/explore/banner/banner' import { getStepByStepTourPermissionVariant, trackStepByStepTourEvent, @@ -34,109 +28,50 @@ import { STEP_BY_STEP_TOUR_TASKS } from '@/app/components/step-by-step-tour/task import { useLocale } from '@/context/i18n' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import useDocumentTitle from '@/hooks/use-document-title' import { useImportDSL } from '@/hooks/use-import-dsl' import { DSLImportMode } from '@/models/app' import dynamic from '@/next/dynamic' import { consoleQuery } from '@/service/client' -import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore' import { trackCreateApp } from '@/utils/create-app-tracking' import { hasPermission } from '@/utils/permission' -import { ExploreAppListHeader } from './explore-app-list-header' -import { ExploreRecommendations } from './explore-recommendations' -import { ExploreHomeSkeleton } from './loading-skeletons' +import { HomeBanner } from '../banner/home-banner' +import { HomeShell } from '../home-shell' +import { TemplateCard } from '../template-card' +import { HomeRecommendations } from './recommendations' import s from './style.module.css' +import { HomeTemplatesHeader } from './templates-header' -const TryApp = dynamic(() => import('../try-app'), { ssr: false }) -const CreateAppModal = dynamic(() => import('../create-app-modal'), { ssr: false }) +const TryApp = dynamic(() => import('@/app/components/explore/try-app'), { ssr: false }) +const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { + ssr: false, +}) const DSLConfirmModal = dynamic( () => import('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false }, ) -type ExploreAppListData = { - categories: string[] - allList: App[] -} - -const homeContinueWorkAppsInput = { - query: { - limit: 8, - }, -} - -const disabledBannersQueryKey = ['explore', 'home', 'banners', 'disabled'] as const const HOME_STEP_BY_STEP_TOUR_TASK_ID = 'home' satisfies StepByStepTourTaskId -function getLocaleQueryInput(locale?: string) { - return locale ? { query: { language: locale } } : {} -} - -function getExploreAppListQueryOptions(locale?: string) { - const input = getLocaleQueryInput(locale) - const language = input.query?.language - - return queryOptions({ - queryKey: [...consoleQuery.explore.apps.get.queryKey({ input }), language], - queryFn: async () => { - const { categories, recommended_apps } = await fetchAppList(language) - return { - categories, - allList: [...recommended_apps].sort((a, b) => a.position - b.position), - } - }, - }) -} - -function getContinueWorkAppsQueryOptions() { - return consoleQuery.apps.recent.get.queryOptions({ - input: homeContinueWorkAppsInput, - select: (response): RecentAppResponse[] => response.data, - }) -} - -function getBannersQueryOptions(locale?: string) { - const input = getLocaleQueryInput(locale) - const language = input.query?.language - - return queryOptions({ - queryKey: [...consoleQuery.explore.banners.get.queryKey({ input }), language], - queryFn: () => fetchBanners(language), - }) -} - -function getDisabledBannersQueryOptions() { - return queryOptions({ - queryKey: disabledBannersQueryKey, - queryFn: async () => [], - initialData: [], - staleTime: 'static', - }) -} - -const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { +export function HomeContent() { const { t } = useTranslation() + useDocumentTitle(t(($) => $['mainNav.home'], { ns: 'common' })) const locale = useLocale() + const queryClient = useQueryClient() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const homeQueries = useQueries({ + const [templatesQuery, recentAppsQuery] = useSuspenseQueries({ queries: [ - getExploreAppListQueryOptions(locale), - getContinueWorkAppsQueryOptions(), - systemFeatures.enable_explore_banner - ? getBannersQueryOptions(locale) - : getDisabledBannersQueryOptions(), + consoleQuery.explore.apps.get.queryOptions({ + input: { query: { language: locale } }, + }), + consoleQuery.apps.recent.get.queryOptions({ + input: { query: { limit: 8 } }, + }), ], - combine: ([exploreAppListQuery, continueWorkAppsQuery, bannersQuery]) => ({ - appListData: exploreAppListQuery.data, - continueWorkApps: continueWorkAppsQuery.data ?? [], - banners: bannersQuery.data ?? [], - isPending: - exploreAppListQuery.isPending || continueWorkAppsQuery.isPending || bannersQuery.isPending, - isAppListError: - exploreAppListQuery.isError || - (!exploreAppListQuery.isPending && !exploreAppListQuery.data), - }), }) + const templatesData = templatesQuery.data + const continueWorkApps = recentAppsQuery.data.data const allCategoriesEn = t(($) => $['apps.allCategories'], { ns: 'explore', lng: 'en' }) const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') const activeStepByStepTourTaskId = useAtomValue(activeStepByStepTourTaskIdAtom) @@ -195,24 +130,23 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { }) const visibleCategories = useMemo(() => { - if (!homeQueries.appListData) return [] - const categoriesWithApps = new Set() - homeQueries.appListData.allList.forEach((app) => { - app.categories.forEach((category) => categoriesWithApps.add(category)) + templatesData.recommended_apps.forEach((app) => { + app.categories?.forEach((category) => categoriesWithApps.add(category)) }) - return homeQueries.appListData.categories.filter((category) => categoriesWithApps.has(category)) - }, [homeQueries.appListData]) + return templatesData.categories.filter((category) => categoriesWithApps.has(category)) + }, [templatesData]) const activeCategory = visibleCategories.includes(currCategory) ? currCategory : allCategoriesEn const filteredList = useMemo(() => { - if (!homeQueries.appListData) return [] - return homeQueries.appListData.allList.filter( - (item) => activeCategory === allCategoriesEn || item.categories?.includes(activeCategory), - ) - }, [homeQueries.appListData, activeCategory, allCategoriesEn]) + return [...templatesData.recommended_apps] + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) + .filter( + (item) => activeCategory === allCategoriesEn || item.categories?.includes(activeCategory), + ) + }, [templatesData, activeCategory, allCategoriesEn]) const searchFilteredList = useMemo(() => { if (!searchKeywords || !filteredList || filteredList.length === 0) return filteredList @@ -225,14 +159,14 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { ) }, [searchKeywords, filteredList]) - const [currApp, setCurrApp] = useState(null) + const [currApp, setCurrApp] = useState(null) const [isShowCreateModal, setIsShowCreateModal] = useState(false) const { handleImportDSL, handleImportDSLConfirm, versions, isFetching } = useImportDSL() const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) - const [currentTryApp, setCurrentTryApp] = useState(undefined) - const currentCreateAppModeRef = useRef(null) + const [currentTryApp, setCurrentTryApp] = useState(undefined) + const currentCreateAppModeRef = useRef(null) const currentCreateAppTrackingRef = useRef void }) => { hideTryAppPanel() } }, [currentTryApp, hideTryAppPanel, homeTryAppCreateGuideActive, isShowCreateModal]) - const handleTryApp = useCallback((params: TryAppSelection) => { + const handleTryApp = useCallback((app: RecommendedAppResponse) => { isCurrentTryAppFromLearnDifyRef.current = false - setCurrentTryApp(params) + setCurrentTryApp(app) }, []) const handleTryAppFromLearnDify = useCallback( - (params: TryAppSelection) => { + (app: RecommendedAppResponse) => { isCurrentTryAppFromLearnDifyRef.current = true - setCurrentTryApp(params) + setCurrentTryApp(app) if ( activeStepByStepTourTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID && @@ -366,10 +300,10 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { ], ) const handleShowFromTryApp = useCallback(() => { - setCurrApp(currentTryApp?.app || null) + setCurrApp(currentTryApp || null) currentCreateAppTrackingRef.current = { source: 'explore_template_preview', - templateId: currentTryApp?.appId || currentTryApp?.app.app_id, + templateId: currentTryApp?.app_id, } shouldCompleteHomeTourOnCreateRef.current = isCurrentTryAppFromLearnDifyRef.current && @@ -381,14 +315,13 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { activeStepByStepTourGuideIndex, activeStepByStepTourTaskId, completedStepByStepTourTaskIds, - currentTryApp?.app, - currentTryApp?.appId, + currentTryApp, ]) - const handleCreateFromLearnDify = useCallback((app: App) => { + const handleCreateFromLearnDify = useCallback((app: RecommendedAppResponse) => { setCurrApp(app) setIsShowCreateModal(true) }, []) - const handleCreateFromAppList = useCallback((app: App) => { + const handleCreateFromTemplate = useCallback((app: RecommendedAppResponse) => { currentCreateAppTrackingRef.current = { source: 'explore_template_list', templateId: app.app_id, @@ -396,7 +329,7 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { setCurrApp(app) setIsShowCreateModal(true) }, []) - const trackCurrentCreateApp = useCallback((appMode?: App['app']['mode'] | null) => { + const trackCurrentCreateApp = useCallback((appMode?: string | null) => { const currentCreateAppTracking = currentCreateAppTrackingRef.current const resolvedAppMode = appMode ?? currentCreateAppModeRef.current if (!resolvedAppMode || !currentCreateAppTracking) return @@ -419,10 +352,17 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { isSubmittingHomeTourCreateRef.current = shouldCompleteHomeTourOnCreateRef.current hideTryAppPanel() - const appId = currApp?.app.id + const appId = currApp?.app_id if (!appId) return - const { export_data, mode } = await fetchAppDetail(appId) + const appDetail = await queryClient.ensureQueryData( + consoleQuery.explore.apps.byAppId.get.queryOptions({ + input: { params: { app_id: appId } }, + }), + ) + if (!appDetail) throw new Error('Recommended app not found') + + const { export_data, mode } = appDetail currentCreateAppModeRef.current = mode const payload = { mode: DSLImportMode.YAML_CONTENT, @@ -455,9 +395,10 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { [ abandonHomeTourCreate, completeHomeTourAfterCreate, - currApp?.app.id, + currApp?.app_id, handleImportDSL, hideTryAppPanel, + queryClient, trackCurrentCreateApp, ], ) @@ -467,11 +408,10 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { onSuccess: (response) => { trackCurrentCreateApp(response.app_mode) completeHomeTourAfterCreate() - onSuccess?.() }, skipRedirectOnSuccess: shouldCompleteHomeTourOnCreateRef.current, }) - }, [completeHomeTourAfterCreate, handleImportDSLConfirm, onSuccess, trackCurrentCreateApp]) + }, [completeHomeTourAfterCreate, handleImportDSLConfirm, trackCurrentCreateApp]) const handleCancelDSLConfirm = useCallback(() => { setShowDSLConfirmModal(false) @@ -479,61 +419,58 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { abandonHomeTourCreate() }, [abandonHomeTourCreate]) - if (homeQueries.isAppListError) return null - return ( -
+
- {homeQueries.isPending ? ( - - ) : ( - <> - {systemFeatures.enable_explore_banner && } - + {systemFeatures.enable_explore_banner && } + - + -
- -
- - )} +
+ +
{isShowCreateModal && ( void }) => { {currentTryApp && ( void }) => { onCreate={handleShowFromTryApp} /> )} -
+ ) } - -export default React.memo(Apps) diff --git a/web/app/components/explore/app-list/explore-recommendations.tsx b/web/features/home/home-content/recommendations.tsx similarity index 70% rename from web/app/components/explore/app-list/explore-recommendations.tsx rename to web/features/home/home-content/recommendations.tsx index 2b653007e3b..10fb32f3ba0 100644 --- a/web/app/components/explore/app-list/explore-recommendations.tsx +++ b/web/features/home/home-content/recommendations.tsx @@ -1,15 +1,14 @@ 'use client' import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen' -import type { App } from '@/models/explore' -import type { TryAppSelection } from '@/types/try-app' -import ContinueWork from '@/app/components/explore/continue-work' +import type { RecommendedAppResponse } from '@dify/contracts/api/console/explore/types.gen' import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' import dynamic from '@/next/dynamic' +import { ContinueWork } from '../continue-work/continue-work' const LearnDify = dynamic(() => import('@/app/components/explore/learn-dify'), { ssr: false }) -export function ExploreRecommendations({ +export function HomeRecommendations({ canCreate, continueWorkApps, forceShowLearnDify, @@ -19,8 +18,8 @@ export function ExploreRecommendations({ canCreate: boolean continueWorkApps: RecentAppResponse[] forceShowLearnDify?: boolean - onCreate: (app: App) => void - onTry: (params: TryAppSelection) => void + onCreate: (app: RecommendedAppResponse) => void + onTry: (app: RecommendedAppResponse) => void }) { return ( <> @@ -30,7 +29,7 @@ export function ExploreRecommendations({ className="pb-0" forceVisible={forceShowLearnDify} onCreate={onCreate} - onTry={onTry} + onTry={({ app }) => onTry(app)} stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.home} /> diff --git a/web/app/components/explore/app-list/style.module.css b/web/features/home/home-content/style.module.css similarity index 90% rename from web/app/components/explore/app-list/style.module.css rename to web/features/home/home-content/style.module.css index 76427ae00e1..cf0adac17c5 100644 --- a/web/app/components/explore/app-list/style.module.css +++ b/web/features/home/home-content/style.module.css @@ -6,18 +6,18 @@ text-fill-color: transparent; } -.appList { +.templateGrid { grid-template-columns: repeat(1, minmax(0, 1fr)); } @media (min-width: 1280px) { - .appList { + .templateGrid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } @media (min-width: 640px) and (max-width: 1279px) { - .appList { + .templateGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } diff --git a/web/app/components/explore/app-list/explore-app-list-header.tsx b/web/features/home/home-content/templates-header.tsx similarity index 90% rename from web/app/components/explore/app-list/explore-app-list-header.tsx rename to web/features/home/home-content/templates-header.tsx index 4f86c1bdad5..5e8b65fb228 100644 --- a/web/app/components/explore/app-list/explore-app-list-header.tsx +++ b/web/features/home/home-content/templates-header.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' import Category from '@/app/components/explore/category' -export function ExploreAppListHeader({ +export function HomeTemplatesHeader({ allCategoriesEn, categories, currCategory, @@ -24,9 +24,12 @@ export function ExploreAppListHeader({ return (
-
+

{t(($) => $['apps.title'], { ns: 'explore' })} -

+ + {children} +
+ ) +} diff --git a/web/app/components/explore/app-list/loading-skeletons.tsx b/web/features/home/home-skeleton.tsx similarity index 67% rename from web/app/components/explore/app-list/loading-skeletons.tsx rename to web/features/home/home-skeleton.tsx index a9e6c1801d9..db9441fc51f 100644 --- a/web/app/components/explore/app-list/loading-skeletons.tsx +++ b/web/features/home/home-skeleton.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' -function ExploreAppCardSkeleton() { +function HomeTemplateCardSkeleton() { return (
@@ -30,42 +30,7 @@ function ExploreAppCardSkeleton() { ) } -function RecommendationSectionSkeletonBody({ - hasDescription = false, -}: { - hasDescription?: boolean -}) { - if (hasDescription) { - return ( - -
-
- - -
- -
-
- {Array.from({ length: 4 }, (_, index) => ( -
-
- - -
-
- - -
-
- ))} -
-
- ) - } - +function HomeRecommendationsSkeleton() { return (
@@ -93,7 +58,7 @@ function RecommendationSectionSkeletonBody({ ) } -function ExploreHeaderSkeletonBody() { +function HomeTemplatesHeaderSkeletonBody() { return (
@@ -114,17 +79,17 @@ function ExploreHeaderSkeletonBody() { ) } -function ExploreAppListSkeletonBody() { +function HomeTemplatesSkeletonBody() { return (
{Array.from({ length: 8 }, (_, index) => ( - + ))}
) } -function BannerSkeletonBody() { +function HomeBannerSkeleton() { return (
@@ -136,18 +101,18 @@ function BannerSkeletonBody() { ) } -export function ExploreHomeSkeleton({ showBanner }: { showBanner: boolean }) { +export function HomeSkeleton({ showBanner }: { showBanner: boolean }) { const { t } = useTranslation() return (
$.loading, { ns: 'common' })} className="contents"> - {showBanner && } + {showBanner && }
- +
- +
- +
) diff --git a/web/features/home/page.tsx b/web/features/home/page.tsx new file mode 100644 index 00000000000..d50ceb36671 --- /dev/null +++ b/web/features/home/page.tsx @@ -0,0 +1,65 @@ +import { defaultShouldDehydrateQuery, dehydrate, HydrationBoundary } from '@tanstack/react-query' +import { Suspense } from 'react' +import { getQueryClientServer, makeQueryClient } from '@/context/query-client-server' +import { getLocaleOnServer } from '@/i18n-config/server' +import { getServerConsoleClientContext, serverConsoleQuery } from '@/service/server' +import { HomeContent } from './home-content/home-content' +import { HomeShell } from './home-shell' +import { HomeSkeleton } from './home-skeleton' + +export async function HomePage() { + const homeQueryClient = makeQueryClient() + const [locale, context] = await Promise.all([ + getLocaleOnServer(), + getServerConsoleClientContext(), + ]) + + void homeQueryClient.prefetchQuery( + serverConsoleQuery.explore.apps.get.queryOptions({ + context, + input: { query: { language: locale } }, + }), + ) + void homeQueryClient.prefetchQuery( + serverConsoleQuery.apps.recent.get.queryOptions({ + context, + input: { query: { limit: 8 } }, + }), + ) + + const enableExploreBanner = ( + await getQueryClientServer().ensureQueryData( + serverConsoleQuery.systemFeatures.get.queryOptions(), + ) + ).enable_explore_banner + if (enableExploreBanner) { + void homeQueryClient.prefetchQuery( + serverConsoleQuery.explore.banners.get.queryOptions({ + context, + input: { query: { language: locale } }, + }), + ) + } + + const dehydratedState = dehydrate(homeQueryClient, { + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || query.state.status === 'pending', + shouldRedactErrors: () => false, + }) + + return ( + + +
+ +
+ + } + > + +
+
+ ) +} diff --git a/web/app/components/explore/app-card/index.tsx b/web/features/home/template-card.tsx similarity index 71% rename from web/app/components/explore/app-card/index.tsx rename to web/features/home/template-card.tsx index 639c9a7ee7f..182572b7a1e 100644 --- a/web/app/components/explore/app-card/index.tsx +++ b/web/features/home/template-card.tsx @@ -1,25 +1,23 @@ 'use client' -import type { App } from '@/models/explore' -import type { TryAppSelection } from '@/types/try-app' +import type { RecommendedAppResponse } from '@dify/contracts/api/console/explore/types.gen' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' import { useId } from 'react' import { useTranslation } from 'react-i18next' +import { AppTypeIcon } from '@/app/components/app/type-selector' import { trackEvent } from '@/app/components/base/amplitude' import AppIcon from '@/app/components/base/app-icon' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { AppModeEnum } from '@/types/app' -import { AppTypeIcon } from '../../app/type-selector' -export type AppCardProps = { - app: App +type TemplateCardProps = { + app: RecommendedAppResponse canCreate: boolean onCreate: () => void - onTry: (params: TryAppSelection) => void - isExplore?: boolean + onTry: (app: RecommendedAppResponse) => void } -const AppCard = ({ app, canCreate, onCreate, onTry, isExplore = true }: AppCardProps) => { +export function TemplateCard({ app, canCreate, onCreate, onTry }: TemplateCardProps) { const { t } = useTranslation() const { data: deploymentEdition } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), @@ -27,18 +25,26 @@ const AppCard = ({ app, canCreate, onCreate, onTry, isExplore = true }: AppCardP }) const nameId = useId() const descriptionId = useId() - const { app: appBasicInfo } = app + const appBasicInfo = app.app + const appName = appBasicInfo?.name ?? '' + const appMode = appBasicInfo?.mode ?? '' + const appIconType = + appBasicInfo?.icon_type === 'image' || + appBasicInfo?.icon_type === 'emoji' || + appBasicInfo?.icon_type === 'link' + ? appBasicInfo.icon_type + : null const canViewApp = deploymentEdition === 'CLOUD' - const isClickable = isExplore && (canViewApp || canCreate) + const isClickable = canViewApp || canCreate const handleTryApp = () => { trackEvent('preview_template', { template_id: app.app_id, - template_name: appBasicInfo.name, - template_mode: appBasicInfo.mode, - template_categories: app.categories, + template_name: appName, + template_mode: appMode, + template_categories: app.categories ?? [], page: 'explore', }) - onTry({ appId: app.app_id, app }) + onTry(app) } const handleCardClick = () => { if (canViewApp) { @@ -69,45 +75,45 @@ const AppCard = ({ app, canCreate, onCreate, onTry, isExplore = true }: AppCardP
-
- {appBasicInfo.name} +
+ {appName}
- {appBasicInfo.mode === AppModeEnum.ADVANCED_CHAT && ( + {appMode === AppModeEnum.ADVANCED_CHAT && (
{t(($) => $['types.advanced'], { ns: 'app' }).toUpperCase()}
)} - {appBasicInfo.mode === AppModeEnum.CHAT && ( + {appMode === AppModeEnum.CHAT && (
{t(($) => $['types.chatbot'], { ns: 'app' }).toUpperCase()}
)} - {appBasicInfo.mode === AppModeEnum.AGENT_CHAT && ( + {appMode === AppModeEnum.AGENT_CHAT && (
{t(($) => $['types.agent'], { ns: 'app' }).toUpperCase()}
)} - {appBasicInfo.mode === AppModeEnum.WORKFLOW && ( + {appMode === AppModeEnum.WORKFLOW && (
{t(($) => $['types.workflow'], { ns: 'app' }).toUpperCase()}
)} - {appBasicInfo.mode === AppModeEnum.COMPLETION && ( + {appMode === AppModeEnum.COMPLETION && (
{t(($) => $['types.completion'], { ns: 'app' }).toUpperCase()}
@@ -126,5 +132,3 @@ const AppCard = ({ app, canCreate, onCreate, onTry, isExplore = true }: AppCardP
) } - -export default AppCard diff --git a/web/models/app.ts b/web/models/app.ts index 84fea16e1e0..1946cd245bb 100644 --- a/web/models/app.ts +++ b/web/models/app.ts @@ -152,17 +152,3 @@ export type WebhookTriggerResponse = { node_id: string created_at: string } - -export type Banner = { - id: string - content: { - category: string - title: string - description: string - 'img-src': string - } - link: string - sort: number - status: string - created_at: string -} diff --git a/web/service/explore.ts b/web/service/explore.ts index 2b86479567b..92dcc93ca3d 100644 --- a/web/service/explore.ts +++ b/web/service/explore.ts @@ -1,13 +1,10 @@ import type { - BannerListResponse, - BannerResponse, GetExploreAppsLearnDifyResponse, GetExploreAppsResponse, RecommendedAppDetailResponse, RecommendedAppInfoResponse, RecommendedAppResponse, } from '@dify/contracts/api/console/explore/types.gen' -import type { Banner } from '@/models/app' import type { App, AppCategory } from '@/models/explore' import type { AppIconType } from '@/types/app' import { consoleClient } from './client' @@ -31,19 +28,6 @@ type ExploreAppDetailResponse = { can_trial: boolean } -const isRecord = (value: unknown): value is Record => { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -const getValue = (source: object, key: string): unknown => { - return Reflect.get(source, key) -} - -const getStringProperty = (source: object, key: string, fallback = '') => { - const value = getValue(source, key) - return typeof value === 'string' ? value : fallback -} - const normalizeAppMode = (value: unknown) => { return typeof value === 'string' ? value : '' } @@ -119,32 +103,6 @@ const normalizeAppDetail = (response: RecommendedAppDetailResponse): ExploreAppD } } -const normalizeBannerContent = (content: unknown): Banner['content'] => { - const record = isRecord(content) ? content : {} - - return { - category: getStringProperty(record, 'category'), - title: getStringProperty(record, 'title'), - description: getStringProperty(record, 'description'), - 'img-src': getStringProperty(record, 'img-src'), - } -} - -const normalizeBanner = (banner: BannerResponse): Banner => { - return { - id: banner.id, - content: normalizeBannerContent(banner.content), - link: banner.link ?? '', - sort: banner.sort, - status: banner.status, - created_at: banner.created_at ?? '', - } -} - -const normalizeBannersResponse = (response: BannerListResponse): Banner[] => { - return response.map(normalizeBanner) -} - export const fetchAppList = (language?: string) => { if (!language) return consoleClient.explore.apps.get({}).then(normalizeExploreAppsResponse) @@ -181,13 +139,3 @@ export const fetchInstalledAppList = (appId?: string | null) => { query: { app_id: appId }, }) } - -export const fetchBanners = (language?: string) => { - if (!language) return consoleClient.explore.banners.get({}).then(normalizeBannersResponse) - - return consoleClient.explore.banners - .get({ - query: { language }, - }) - .then(normalizeBannersResponse) -}