mirror of
https://github.com/langgenius/dify.git
synced 2026-09-21 13:20:52 +08:00
perf(web): streaming ssr for home page (#39922)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+28
-36
@@ -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' }))
|
||||
|
||||
+16
-15
@@ -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} />)
|
||||
+24
-23
@@ -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', () => {
|
||||
+2
-2
@@ -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
|
||||
+17
-20
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
+1
-1
@@ -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
-5
@@ -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)
|
||||
+1
-3
@@ -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)
|
||||
+117
-186
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
+108
-173
@@ -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)
|
||||
+6
-7
@@ -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}
|
||||
/>
|
||||
</>
|
||||
+3
-3
@@ -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));
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -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"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+11
-46
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+32
-28
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user