feat: empty page

This commit is contained in:
Joel
2026-05-13 18:19:00 +08:00
committed by Jingyi-Dify
parent 55a3412f1d
commit 4c73293d3b
9 changed files with 253 additions and 58 deletions

View File

@ -0,0 +1,3 @@
# Mocks to Remove Before Release
- `emptyAppList=true`: frontend URL preview flag for forcing the `/apps` page into the first-empty state. Remove the parser and rendering override before release.

View File

@ -64,6 +64,7 @@ const mockQueryState = {
tagIDs: [] as string[],
keywords: '',
isCreatedByMe: false,
emptyAppList: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
@ -133,13 +134,14 @@ const defaultAppData = {
total: 2,
}],
}
let mockAppData = defaultAppData
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useInfiniteQuery: () => ({
data: defaultAppData,
data: mockAppData,
isLoading: mockServiceState.isLoading,
isFetching: mockServiceState.isFetching,
isFetchingNextPage: mockServiceState.isFetchingNextPage,
@ -281,6 +283,8 @@ describe('List', () => {
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockQueryState.emptyAppList = false
mockAppData = defaultAppData
mockUseWorkflowOnlineUsers.mockClear()
intersectionCallback = null
localStorage.clear()
@ -346,6 +350,66 @@ describe('List', () => {
expect(screen.getByText('app.newApp.dropDSLToCreateApp'))!.toBeInTheDocument()
})
it('should render first empty state when there are no apps and no active filters', () => {
mockAppData = { pages: [{ data: [], total: 0 }] }
renderList()
expect(screen.getByText('app.firstEmpty.title'))!.toBeInTheDocument()
expect(screen.getByText('app.firstEmpty.description'))!.toBeInTheDocument()
expect(screen.getByText('explore.learnDify.title'))!.toBeInTheDocument()
expect(screen.queryByText('app.types.label')).not.toBeInTheDocument()
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
expect(screen.queryByTestId('footer')).not.toBeInTheDocument()
})
it('should not render first empty state before the first app list page resolves', () => {
mockAppData = { pages: [] }
renderList()
expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument()
expect(screen.getByText('app.types.label'))!.toBeInTheDocument()
})
it('should render first empty state when emptyAppList URL preview is enabled', () => {
mockQueryState.emptyAppList = true
renderList()
expect(screen.getByText('app.firstEmpty.title'))!.toBeInTheDocument()
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
expect(screen.queryByText('app.types.label')).not.toBeInTheDocument()
expect(screen.queryByTestId('footer')).not.toBeInTheDocument()
})
it('should keep the regular empty state for empty filtered results', () => {
mockAppData = { pages: [{ data: [], total: 0 }] }
mockQueryState.keywords = 'missing app'
renderList()
expect(screen.getByTestId('empty-state'))!.toBeInTheDocument()
expect(screen.getByText('app.types.label'))!.toBeInTheDocument()
expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument()
})
it('should open create flows from first empty state actions', () => {
mockAppData = { pages: [{ data: [], total: 0 }] }
renderList()
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.startFromBlank/ }))
expect(screen.getByTestId('create-app-modal'))!.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.startFromTemplate/ }))
expect(screen.getByTestId('template-dialog'))!.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /app\.importDSL/ }))
expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument()
})
it('should pass workflow app ids to online users hook', () => {
renderList()

View File

@ -0,0 +1,93 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import LearnDify from '@/app/components/explore/learn-dify'
type EmptyCreateAction = {
id: string
icon: string
title: string
description: string
onClick: () => void
}
type Props = {
onCreateBlank: () => void
onCreateTemplate: () => void
onImportDSL: () => void
}
const FirstEmptyState: FC<Props> = ({
onCreateBlank,
onCreateTemplate,
onImportDSL,
}) => {
const { t } = useTranslation()
const actions: EmptyCreateAction[] = [
{
id: 'blank',
icon: '🖌️',
title: t('newApp.startFromBlank', { ns: 'app' }),
description: t('firstEmpty.blankDescription', { ns: 'app' }),
onClick: onCreateBlank,
},
{
id: 'template',
icon: '📑',
title: t('newApp.startFromTemplate', { ns: 'app' }),
description: t('firstEmpty.templateDescription', { ns: 'app' }),
onClick: onCreateTemplate,
},
{
id: 'dsl',
icon: '📁',
title: t('importDSL', { ns: 'app' }),
description: t('firstEmpty.importDescription', { ns: 'app' }),
onClick: onImportDSL,
},
]
return (
<div className="flex grow flex-col px-6">
<div className="flex flex-1 items-center justify-center py-10">
<section className="mx-auto flex w-full max-w-[968px] flex-col items-center text-center">
<h2 className="text-2xl/8 font-semibold text-text-primary">{t('firstEmpty.title', { ns: 'app' })}</h2>
<p className="mt-2 max-w-[560px] system-sm-regular text-text-tertiary">
{t('firstEmpty.description', { ns: 'app' })}
</p>
<div className="mt-6 grid w-full grid-cols-1 gap-4 md:grid-cols-3">
{actions.map(action => (
<button
key={action.id}
type="button"
className={cn(
'flex min-h-[204px] flex-col rounded-xl border border-components-panel-border bg-components-panel-on-panel-item-bg p-6 text-left shadow-xs',
'transition-colors hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
)}
onClick={action.onClick}
>
<span className="flex size-12 items-center justify-center rounded-xl bg-background-section text-2xl/8">
{action.icon}
</span>
<span className="mt-5 system-md-semibold text-text-primary">{action.title}</span>
<span className="mt-2 system-sm-regular text-text-tertiary">{action.description}</span>
</button>
))}
</div>
<div className="mt-6 flex items-center justify-center gap-2 text-text-quaternary">
<span className="i-ri-drag-drop-line size-4" />
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
</div>
</section>
</div>
<div className="mb-6 rounded-xl border border-components-panel-border bg-components-panel-bg p-4 shadow-xs">
<LearnDify className="px-0 pb-0" dismissible={false} itemLimit={3} />
</div>
</div>
)
}
export default FirstEmptyState

View File

@ -21,6 +21,7 @@ describe('useAppsQueryState', () => {
tagIDs: [],
keywords: '',
isCreatedByMe: false,
emptyAppList: false,
})
expect(typeof result.current.setCategory).toBe('function')
expect(typeof result.current.setKeywords).toBe('function')
@ -30,7 +31,7 @@ describe('useAppsQueryState', () => {
it('should parse app list filters from URL', () => {
const { result } = renderWithAdapter(
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true',
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true&emptyAppList=true',
)
expect(result.current.query).toEqual({
@ -38,6 +39,7 @@ describe('useAppsQueryState', () => {
tagIDs: ['tag1', 'tag2'],
keywords: 'search term',
isCreatedByMe: true,
emptyAppList: true,
})
})

View File

@ -25,6 +25,7 @@ const appListQueryParsers = {
isCreatedByMe: parseAsBoolean
.withDefault(false)
.withOptions({ history: 'push' }),
emptyAppList: parseAsBoolean.withDefault(false),
}
export function useAppsQueryState() {

View File

@ -20,6 +20,7 @@ import { AppCardSkeleton } from './app-card-skeleton'
import AppListHeaderFilters from './app-list-header-filters'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import Empty from './empty'
import FirstEmptyState from './first-empty-state'
import Footer from './footer'
import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
@ -52,7 +53,7 @@ const List: FC<Props> = ({
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
query: { category, tagIDs, keywords, isCreatedByMe },
query: { category, tagIDs, keywords, isCreatedByMe, emptyAppList },
setCategory,
setKeywords,
setTagIDs,
@ -173,7 +174,7 @@ const List: FC<Props> = ({
setCategory(nextValue)
}, [setCategory])
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const pages = useMemo(() => emptyAppList ? [{ data: [], total: 0 }] : data?.pages ?? [], [data?.pages, emptyAppList])
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
const workflowOnlineUserAppIds = useMemo(() => {
@ -192,9 +193,12 @@ const List: FC<Props> = ({
enabled: systemFeatures.enable_collaboration_mode,
})
const hasResolvedFirstPage = pages.length > 0
const hasAnyApp = (pages[0]?.total ?? 0) > 0
const hasActiveFilters = category !== 'all' || tagIDs.length > 0 || keywords.trim().length > 0 || debouncedKeywords.trim().length > 0 || isCreatedByMe
// Show skeleton during initial load or when refetching with no previous data
const showSkeleton = isLoading || (isFetching && pages.length === 0)
const showSkeleton = !emptyAppList && (isLoading || (isFetching && pages.length === 0))
const showFirstEmptyState = !showSkeleton && !hasAnyApp && isCurrentWorkspaceEditor && (emptyAppList || (hasResolvedFirstPage && !hasActiveFilters))
return (
<>
@ -211,55 +215,67 @@ const List: FC<Props> = ({
<p className="system-sm-regular text-text-tertiary">{t('studioDescription', { ns: 'app' })}</p>
</div>
</div>
<AppListHeaderFilters
category={category}
tagIDs={tagIDs}
keywords={keywords}
isCreatedByMe={isCreatedByMe}
onCategoryChange={handleCategoryChange}
onTagIDsChange={setTagIDs}
onKeywordsChange={setKeywords}
onCreatedByMeChange={handleCreatedByMeChange}
onCreateBlank={() => setShowNewAppModal(true)}
onCreateTemplate={() => setShowNewAppTemplateDialog(true)}
onImportDSL={() => setShowCreateFromDSLModal(true)}
onOpenTagManagement={() => setShowTagManagementModal(true)}
showCreateButton={isCurrentWorkspaceEditor}
/>
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-3 px-6 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
!hasAnyApp && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={category}
className={cn(!hasAnyApp && 'z-10')}
{!showFirstEmptyState && (
<AppListHeaderFilters
category={category}
tagIDs={tagIDs}
keywords={keywords}
isCreatedByMe={isCreatedByMe}
onCategoryChange={handleCategoryChange}
onTagIDsChange={setTagIDs}
onKeywordsChange={setKeywords}
onCreatedByMeChange={handleCreatedByMeChange}
onCreateBlank={() => setShowNewAppModal(true)}
onCreateTemplate={() => setShowNewAppTemplateDialog(true)}
onImportDSL={() => setShowCreateFromDSLModal(true)}
onOpenTagManagement={() => setShowTagManagementModal(true)}
showCreateButton={isCurrentWorkspaceEditor}
/>
)}
{showSkeleton
? <AppCardSkeleton count={6} />
: hasAnyApp
? apps.map(app => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
onRefresh={refetch}
onOpenTagManagement={() => setShowTagManagementModal(true)}
/>
))
: <Empty />}
{isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
{showFirstEmptyState
? (
<FirstEmptyState
onCreateBlank={() => setShowNewAppModal(true)}
onCreateTemplate={() => setShowNewAppTemplateDialog(true)}
onImportDSL={() => setShowCreateFromDSLModal(true)}
/>
)
: (
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-3 px-6 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
!hasAnyApp && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={category}
className={cn(!hasAnyApp && 'z-10')}
/>
)}
{showSkeleton
? <AppCardSkeleton count={6} />
: hasAnyApp
? apps.map(app => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
onRefresh={refetch}
onOpenTagManagement={() => setShowTagManagementModal(true)}
/>
))
: <Empty />}
{isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
)}
{isCurrentWorkspaceEditor && (
{isCurrentWorkspaceEditor && !showFirstEmptyState && (
<div
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
role="region"
@ -269,7 +285,7 @@ const List: FC<Props> = ({
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
</div>
)}
{!systemFeatures.branding.enabled && (
{!systemFeatures.branding.enabled && !showFirstEmptyState && (
<Footer />
)}
<CheckModal />

View File

@ -10,10 +10,14 @@ import { useLearnDifyHiddenState } from './storage'
type LearnDifyProps = {
className?: string
dismissible?: boolean
itemLimit?: number
}
const LearnDify = ({
className,
dismissible = true,
itemLimit,
}: LearnDifyProps) => {
const { t } = useTranslation()
const [hidden, setHidden] = useLearnDifyHiddenState()
@ -51,7 +55,9 @@ const LearnDify = ({
}, 800)
}
if (hidden)
const visibleItems = itemLimit ? learnDifyItems.slice(0, itemLimit) : learnDifyItems
if (dismissible && hidden)
return null
return (
@ -71,9 +77,11 @@ const LearnDify = ({
<h2 id="learn-dify-title" className="min-w-0 truncate system-xl-semibold text-text-primary">
{t('learnDify.title', { ns: 'explore' })}
</h2>
<button type="button" className="shrink-0 system-sm-medium text-text-primary" onClick={handleHide}>
{t('learnDify.hide', { ns: 'explore' })}
</button>
{dismissible && (
<button type="button" className="shrink-0 system-sm-medium text-text-primary" onClick={handleHide}>
{t('learnDify.hide', { ns: 'explore' })}
</button>
)}
</div>
<div className="mt-1 flex items-center justify-between gap-4">
<p className="min-w-0 truncate system-xs-regular text-text-tertiary">
@ -83,7 +91,7 @@ const LearnDify = ({
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{learnDifyItems.map(item => (
{visibleItems.map(item => (
<LearnDifyItem key={item.id} item={item} />
))}
</div>

View File

@ -49,6 +49,11 @@
"editFailed": "Failed to update app info",
"export": "Export DSL",
"exportFailed": "Export DSL failed.",
"firstEmpty.blankDescription": "Start with an empty canvas and build your app step by step.",
"firstEmpty.description": "Turn an idea into a working AI app - start from scratch, a template, or import an existing one.",
"firstEmpty.importDescription": "Already have a Dify app exported as DSL? Bring it in to continue where you left off.",
"firstEmpty.templateDescription": "Pick a ready-made app and customize it. The fastest way to see Dify in action.",
"firstEmpty.title": "Build your first App",
"gotoAnything.actions.accountDesc": "Navigate to account page",
"gotoAnything.actions.communityDesc": "Open Discord community",
"gotoAnything.actions.docDesc": "Open help documentation",
@ -120,7 +125,6 @@
"iconPicker.ok": "OK",
"importApp": "Import App",
"importDSL": "Import DSL file",
"importDSLDescription": "Drag DSL file to studio to import",
"importFromDSL": "Import from DSL",
"importFromDSLFile": "From DSL file",
"importFromDSLUrl": "From URL",

View File

@ -49,6 +49,11 @@
"editFailed": "更新应用信息失败",
"export": "导出 DSL",
"exportFailed": "导出 DSL 失败",
"firstEmpty.blankDescription": "从空白画布开始,一步步搭建你的应用。",
"firstEmpty.description": "把想法变成可运行的 AI 应用,可从空白、模板开始,或导入已有应用。",
"firstEmpty.importDescription": "已有导出的 Dify DSL 应用?导入后继续编辑。",
"firstEmpty.templateDescription": "选择现成应用并按需调整,这是体验 Dify 最快的方式。",
"firstEmpty.title": "创建你的第一个应用",
"gotoAnything.actions.accountDesc": "导航到账户页面",
"gotoAnything.actions.communityDesc": "打开 Discord 社区",
"gotoAnything.actions.docDesc": "打开帮助文档",
@ -120,7 +125,6 @@
"iconPicker.ok": "确认",
"importApp": "导入应用",
"importDSL": "导入 DSL 文件",
"importDSLDescription": "拖放 DSL 文件到 Studio 导入",
"importFromDSL": "导入 DSL",
"importFromDSLFile": "文件",
"importFromDSLUrl": "URL",