perf(web): streaming ssr for home page (#39922)

This commit is contained in:
yyh
2026-08-04 02:16:08 +00:00
committed by GitHub
parent 5456d4d56e
commit 9ea21e4fd1
33 changed files with 712 additions and 707 deletions
+3 -13
View File
@@ -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 <AppList />
export default function Page() {
return <HomePage />
}
export default React.memo(Home)
+2 -2
View File
@@ -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
@@ -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 }) => <div data-testid="app-type-icon">{type}</div>,
}))
@@ -17,7 +19,12 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
const createApp = (overrides?: Partial<App>): App => ({
type TemplateFixture = RecommendedAppResponse & { app: RecommendedAppInfoResponse }
type TemplateFixtureOverrides = Omit<Partial<RecommendedAppResponse>, 'app'> & {
app?: Partial<RecommendedAppInfoResponse>
}
const createApp = (overrides: TemplateFixtureOverrides = {}): TemplateFixture => ({
can_trial: true,
app_id: 'app-id',
description: 'App description',
@@ -27,10 +34,6 @@ const createApp = (overrides?: Partial<App>): 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>): 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<AppCardProps>) => {
const mergedProps: AppCardProps = {
const renderComponent = (props?: Partial<React.ComponentProps<typeof TemplateCard>>) => {
const mergedProps: React.ComponentProps<typeof TemplateCard> = {
app: createApp(),
canCreate: false,
onCreate,
onTry,
isExplore: false,
...props,
}
return renderWithConsoleQuery(<AppCard {...mergedProps} />, {
return renderWithConsoleQuery(<TemplateCard {...mergedProps} />, {
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' }))
@@ -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> = {}): 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> = {}): 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<ComponentProps<typeof BannerItem>> = {},
) =>
render(<BannerItem banner={banner} sort={1} language="en-US" titleId="banner-title" {...props} />)
@@ -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(<Banner banners={[createMockBanner('1', 'disabled')]} />)
it('renders nothing when there are no banners', () => {
render(<Banner banners={[]} />)
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(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'disabled', 'Hidden banner'),
createMockBanner('3', 'enabled', 'Second banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
@@ -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', () => {
@@ -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
@@ -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<ComponentProps<typeof Carousel>['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 (
<div className="relative flex w-full flex-col items-start gap-4 px-8 pt-6 pb-4">
@@ -217,20 +220,14 @@ export function Banner({ banners }: BannerProps) {
</p>
</div>
{enabledBanners.length > 0 ? (
<Carousel
opts={CAROUSEL_OPTIONS}
plugins={carouselPlugins}
aria-label={carouselLabel}
className="@container/banner w-full rounded-2xl"
>
<BannerCarouselContent
banners={enabledBanners}
accountId={userProfile.id}
language={locale}
/>
</Carousel>
) : null}
<Carousel
opts={CAROUSEL_OPTIONS}
plugins={carouselPlugins}
aria-label={carouselLabel}
className="@container/banner w-full rounded-2xl"
>
<BannerCarouselContent banners={banners} accountId={userProfile.id} language={locale} />
</Carousel>
</div>
)
}
+17
View File
@@ -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 <Banner banners={banners} />
}
@@ -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' },
@@ -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) => {
</section>
)
}
export default React.memo(ContinueWork)
@@ -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) => {
</Link>
)
}
export default React.memo(ContinueWorkItem)
@@ -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[] }) => (
<div data-testid="explore-banner" data-banner-count={banners.length}>
vi.mock('../../banner/home-banner', () => ({
HomeBanner: () => (
<div data-testid="explore-banner" data-banner-count={mockBanners.length}>
banner
</div>
),
@@ -460,7 +473,12 @@ vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
),
}))
const createApp = (overrides: Partial<App> = {}): App => ({
type AppFixture = RecommendedAppResponse & { app: RecommendedAppInfoResponse }
type AppFixtureOverrides = Omit<Partial<RecommendedAppResponse>, 'app'> & {
app?: Partial<RecommendedAppInfoResponse>
}
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> = {}): 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> = {}): 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> = {}): RecentAppResponse => ({
@@ -501,7 +513,7 @@ const createWorkspaceApp = (overrides: Partial<RecentAppResponse> = {}): RecentA
permission_keys: overrides.permission_keys,
})
const createBanner = (overrides: Partial<BannerType> = {}): BannerType => ({
const createBanner = (overrides: Partial<BannerResponse> = {}): 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<string, string>
}
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<string, string>,
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 }) => (
<JotaiProvider store={jotaiStore}>
<ConsoleQueryWrapper>
@@ -585,7 +582,7 @@ const renderAppList = (
)
const rendered = renderWithNuqs(
<Wrapped>
<AppList onSuccess={onSuccess} />
<HomeContent />
</Wrapped>,
{ 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: <SkipHomeGuideProbe />,
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)
})
})
})
@@ -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<ExploreAppListData>({
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<BannerType[]>({
queryKey: [...consoleQuery.explore.banners.get.queryKey({ input }), language],
queryFn: () => fetchBanners(language),
})
}
function getDisabledBannersQueryOptions() {
return queryOptions<BannerType[]>({
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<string>()
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<App | null>(null)
const [currApp, setCurrApp] = useState<RecommendedAppResponse | null>(null)
const [isShowCreateModal, setIsShowCreateModal] = useState(false)
const { handleImportDSL, handleImportDSLConfirm, versions, isFetching } = useImportDSL()
const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false)
const [currentTryApp, setCurrentTryApp] = useState<TryAppSelection | undefined>(undefined)
const currentCreateAppModeRef = useRef<App['app']['mode'] | null>(null)
const [currentTryApp, setCurrentTryApp] = useState<RecommendedAppResponse | undefined>(undefined)
const currentCreateAppModeRef = useRef<string | null>(null)
const currentCreateAppTrackingRef = useRef<Pick<
TrackCreateAppParams,
'source' | 'templateId'
@@ -331,14 +265,14 @@ const Apps = ({ onSuccess }: { onSuccess?: () => 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 (
<div
className={cn(
'flex h-full min-h-0 flex-col overflow-hidden border-l-[0.5px] border-divider-regular',
)}
>
<HomeShell>
<div className="flex flex-1 flex-col overflow-y-auto">
{homeQueries.isPending ? (
<ExploreHomeSkeleton showBanner={systemFeatures.enable_explore_banner} />
) : (
<>
{systemFeatures.enable_explore_banner && <Banner banners={homeQueries.banners} />}
<ExploreRecommendations
canCreate={canCreateApp}
continueWorkApps={homeQueries.continueWorkApps}
forceShowLearnDify={shouldForceShowLearnDifyForTour}
onCreate={handleCreateFromLearnDify}
onTry={handleTryAppFromLearnDify}
/>
{systemFeatures.enable_explore_banner && <HomeBanner />}
<HomeRecommendations
canCreate={canCreateApp}
continueWorkApps={continueWorkApps}
forceShowLearnDify={shouldForceShowLearnDifyForTour}
onCreate={handleCreateFromLearnDify}
onTry={handleTryAppFromLearnDify}
/>
<ExploreAppListHeader
allCategoriesEn={allCategoriesEn}
categories={visibleCategories}
currCategory={activeCategory}
keywords={keywords}
onCategoryChange={setCurrCategory}
onKeywordsChange={handleKeywordsChange}
/>
<HomeTemplatesHeader
allCategoriesEn={allCategoriesEn}
categories={visibleCategories}
currCategory={activeCategory}
keywords={keywords}
onCategoryChange={setCurrCategory}
onKeywordsChange={handleKeywordsChange}
/>
<div className={cn('relative flex flex-1 shrink-0 grow flex-col pb-6')}>
<nav className={cn(s.appList, 'grid shrink-0 content-start gap-3 px-8')}>
{searchFilteredList.map((app) => (
<AppCard
key={app.app_id}
app={app}
canCreate={canCreateApp}
onCreate={() => handleCreateFromAppList(app)}
onTry={handleTryApp}
/>
))}
</nav>
</div>
</>
)}
<div className={cn('relative flex flex-1 shrink-0 grow flex-col pb-6')}>
<nav
aria-labelledby="home-templates-title"
className={cn(s.templateGrid, 'grid shrink-0 content-start gap-3 px-8')}
>
{searchFilteredList.map((app) => (
<TemplateCard
key={app.app_id}
app={app}
canCreate={canCreateApp}
onCreate={() => handleCreateFromTemplate(app)}
onTry={handleTryApp}
/>
))}
</nav>
</div>
</div>
{isShowCreateModal && (
<CreateAppModal
appIconType={currApp?.app.icon_type || 'emoji'}
appIcon={currApp?.app.icon || ''}
appIconBackground={currApp?.app.icon_background || ''}
appIconUrl={currApp?.app.icon_url}
appName={currApp?.app.name || ''}
appDescription={currApp?.app.description || ''}
appIconType={
currApp?.app?.icon_type === 'image' ||
currApp?.app?.icon_type === 'emoji' ||
currApp?.app?.icon_type === 'link'
? currApp.app.icon_type
: 'emoji'
}
appIcon={currApp?.app?.icon || ''}
appIconBackground={currApp?.app?.icon_background || ''}
appIconUrl={currApp?.app?.icon_url}
appName={currApp?.app?.name || ''}
appDescription={currApp?.description || ''}
show={isShowCreateModal}
onConfirm={onCreate}
confirmDisabled={isFetching}
@@ -551,10 +488,10 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
{currentTryApp && (
<TryApp
appId={currentTryApp.appId}
app={currentTryApp.app}
appId={currentTryApp.app_id}
app={currentTryApp}
canCreate={canCreateApp}
categories={currentTryApp.app.categories}
categories={currentTryApp.categories ?? []}
createButtonStepByStepTourTarget={
canCreateApp && isCurrentTryAppFromLearnDifyRef.current && !isShowCreateModal
? STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate
@@ -564,8 +501,6 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
onCreate={handleShowFromTryApp}
/>
)}
</div>
</HomeShell>
)
}
export default React.memo(Apps)
@@ -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}
/>
</>
@@ -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));
}
}
@@ -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 (
<div className="sticky top-0 z-10 bg-background-body">
<div className="flex items-center gap-2 px-8 pt-6">
<div className="min-w-0 flex-1 truncate system-xl-medium text-text-primary">
<h2
id="home-templates-title"
className="min-w-0 flex-1 truncate system-xl-medium text-text-primary"
>
{t(($) => $['apps.title'], { ns: 'explore' })}
</div>
</h2>
<a
href="https://marketplace.dify.ai/templates"
target="_blank"
+9
View File
@@ -0,0 +1,9 @@
import type { ReactNode } from 'react'
export function HomeShell({ children }: { children: ReactNode }) {
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden border-l-[0.5px] border-divider-regular">
{children}
</div>
)
}
@@ -3,7 +3,7 @@
import { useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
function ExploreAppCardSkeleton() {
function HomeTemplateCardSkeleton() {
return (
<div className="col-span-1 flex h-35.5 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs shadow-shadow-shadow-3">
<div className="flex shrink-0 items-center gap-3 px-4 pt-4 pb-2">
@@ -30,42 +30,7 @@ function ExploreAppCardSkeleton() {
)
}
function RecommendationSectionSkeletonBody({
hasDescription = false,
}: {
hasDescription?: boolean
}) {
if (hasDescription) {
return (
<SkeletonContainer className="-mx-4 rounded-2xl bg-background-section p-4">
<div className="flex items-start justify-between gap-4 pb-2.5">
<div className="min-w-0">
<SkeletonRectangle className="h-5 w-48 animate-pulse" />
<SkeletonRectangle className="mt-2 h-3 w-80 animate-pulse" />
</div>
<SkeletonRectangle className="size-8 shrink-0 animate-pulse rounded-lg" />
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5">
{Array.from({ length: 4 }, (_, index) => (
<div
key={index}
className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg px-4 pt-4 pb-4 shadow-xs"
>
<div className="flex flex-col items-start gap-2 pb-1">
<SkeletonRectangle className="size-10 shrink-0 animate-pulse rounded-[10px]" />
<SkeletonRectangle className="h-4 w-3/4 animate-pulse" />
</div>
<div className="flex flex-col gap-1">
<SkeletonRectangle className="h-3 w-full animate-pulse" />
<SkeletonRectangle className="h-3 w-4/5 animate-pulse" />
</div>
</div>
))}
</div>
</SkeletonContainer>
)
}
function HomeRecommendationsSkeleton() {
return (
<SkeletonContainer>
<div className="flex min-h-12 items-end justify-between gap-4 pb-2">
@@ -93,7 +58,7 @@ function RecommendationSectionSkeletonBody({
)
}
function ExploreHeaderSkeletonBody() {
function HomeTemplatesHeaderSkeletonBody() {
return (
<div className="sticky top-0 z-10 bg-background-body">
<div className="flex items-center gap-2 px-8 pt-6">
@@ -114,17 +79,17 @@ function ExploreHeaderSkeletonBody() {
)
}
function ExploreAppListSkeletonBody() {
function HomeTemplatesSkeletonBody() {
return (
<div className="grid shrink-0 grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3 px-8">
{Array.from({ length: 8 }, (_, index) => (
<ExploreAppCardSkeleton key={index} />
<HomeTemplateCardSkeleton key={index} />
))}
</div>
)
}
function BannerSkeletonBody() {
function HomeBannerSkeleton() {
return (
<div className="relative flex w-full flex-col items-start gap-4 px-8 pt-6 pb-4">
<div className="flex w-full flex-col gap-1">
@@ -136,18 +101,18 @@ function BannerSkeletonBody() {
)
}
export function ExploreHomeSkeleton({ showBanner }: { showBanner: boolean }) {
export function HomeSkeleton({ showBanner }: { showBanner: boolean }) {
const { t } = useTranslation()
return (
<div role="status" aria-label={t(($) => $.loading, { ns: 'common' })} className="contents">
{showBanner && <BannerSkeletonBody />}
{showBanner && <HomeBannerSkeleton />}
<section className="px-8 pb-5">
<RecommendationSectionSkeletonBody />
<HomeRecommendationsSkeleton />
</section>
<ExploreHeaderSkeletonBody />
<HomeTemplatesHeaderSkeletonBody />
<div className="relative flex flex-1 shrink-0 grow flex-col pb-6">
<ExploreAppListSkeletonBody />
<HomeTemplatesSkeletonBody />
</div>
</div>
)
+65
View File
@@ -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 (
<HydrationBoundary state={dehydratedState}>
<Suspense
fallback={
<HomeShell>
<div className="flex flex-1 flex-col overflow-y-auto">
<HomeSkeleton showBanner={enableExploreBanner} />
</div>
</HomeShell>
}
>
<HomeContent />
</Suspense>
</HydrationBoundary>
)
}
@@ -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
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={appBasicInfo.icon_type}
icon={appBasicInfo.icon}
background={appBasicInfo.icon_background}
imageUrl={appBasicInfo.icon_url}
iconType={appIconType}
icon={appBasicInfo?.icon ?? undefined}
background={appBasicInfo?.icon_background ?? undefined}
imageUrl={appBasicInfo?.icon_url ?? undefined}
/>
<AppTypeIcon
wrapperClassName="absolute -right-0.5 -bottom-0.5 size-4 rounded-sm border-components-panel-on-panel-item-bg shadow-sm"
className="size-3"
type={appBasicInfo.mode}
type={appMode}
/>
</div>
<div className="flex w-0 grow flex-col gap-1 py-px">
<div className="flex items-center system-md-semibold text-text-secondary">
<div id={nameId} className="truncate" title={appBasicInfo.name}>
{appBasicInfo.name}
<div id={nameId} className="truncate" title={appName}>
{appName}
</div>
</div>
<div className="flex items-center system-2xs-medium-uppercase text-text-tertiary">
{appBasicInfo.mode === AppModeEnum.ADVANCED_CHAT && (
{appMode === AppModeEnum.ADVANCED_CHAT && (
<div className="truncate">
{t(($) => $['types.advanced'], { ns: 'app' }).toUpperCase()}
</div>
)}
{appBasicInfo.mode === AppModeEnum.CHAT && (
{appMode === AppModeEnum.CHAT && (
<div className="truncate">
{t(($) => $['types.chatbot'], { ns: 'app' }).toUpperCase()}
</div>
)}
{appBasicInfo.mode === AppModeEnum.AGENT_CHAT && (
{appMode === AppModeEnum.AGENT_CHAT && (
<div className="truncate">
{t(($) => $['types.agent'], { ns: 'app' }).toUpperCase()}
</div>
)}
{appBasicInfo.mode === AppModeEnum.WORKFLOW && (
{appMode === AppModeEnum.WORKFLOW && (
<div className="truncate">
{t(($) => $['types.workflow'], { ns: 'app' }).toUpperCase()}
</div>
)}
{appBasicInfo.mode === AppModeEnum.COMPLETION && (
{appMode === AppModeEnum.COMPLETION && (
<div className="truncate">
{t(($) => $['types.completion'], { ns: 'app' }).toUpperCase()}
</div>
@@ -126,5 +132,3 @@ const AppCard = ({ app, canCreate, onCreate, onTry, isExplore = true }: AppCardP
</div>
)
}
export default AppCard
-14
View File
@@ -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
}
-52
View File
@@ -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<string, unknown> => {
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)
}