feat: new head

This commit is contained in:
Joel
2026-05-13 16:14:24 +08:00
committed by Jingyi-Dify
parent e989546a40
commit 39d88aa1db
4 changed files with 156 additions and 60 deletions

View File

@ -48,6 +48,13 @@ vi.mock('@/context/app-context', () => ({
}),
}))
const mockOnPlanInfoChanged = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged,
}),
}))
const mockSetKeywords = vi.fn()
const mockSetTagIDs = vi.fn()
const mockSetIsCreatedByMe = vi.fn()
@ -179,6 +186,20 @@ vi.mock('@/next/dynamic', () => ({
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
}
}
if (fnString.includes('create-app-modal')) {
return function MockCreateAppModal({ show, onClose, onSuccess, onCreateFromTemplate }: { show: boolean, onClose: () => void, onSuccess: () => void, onCreateFromTemplate: () => void }) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-app-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-create-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-create-modal' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromTemplate, 'data-testid': 'to-template-modal' }, 'To Template'))
}
}
if (fnString.includes('create-app-dialog')) {
return function MockCreateAppTemplateDialog({ show, onClose, onSuccess, onCreateFromBlank }: { show: boolean, onClose: () => void, onSuccess: () => void, onCreateFromBlank: () => void }) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'template-dialog' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-template-dialog' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-template-dialog' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromBlank, 'data-testid': 'to-blank-modal' }, 'To Blank'))
}
}
return () => null
},
}))
@ -298,6 +319,11 @@ describe('List', () => {
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
})
it('should render create button for editors', () => {
renderList()
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
renderList()
@ -429,6 +455,78 @@ describe('List', () => {
})
})
describe('Create Menu', () => {
it('should render all create menu options', async () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
expect(await screen.findByText('app.newApp.startFromBlank'))!.toBeInTheDocument()
expect(await screen.findByText('app.newApp.startFromTemplate'))!.toBeInTheDocument()
expect(await screen.findByText('app.importDSL'))!.toBeInTheDocument()
expect(await screen.findAllByText('app.newApp.dropDSLToCreateApp')).toHaveLength(2)
})
it('should open blank app modal from create menu', async () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(await screen.findByText('app.newApp.startFromBlank'))
expect(screen.getByTestId('create-app-modal'))!.toBeInTheDocument()
})
it('should open template dialog from create menu', async () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(await screen.findByText('app.newApp.startFromTemplate'))
expect(screen.getByTestId('template-dialog'))!.toBeInTheDocument()
})
it('should open DSL import modal from create menu', async () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(await screen.findByText('app.importDSL'))
expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument()
})
it('should open blank app modal with create shortcut', () => {
renderList()
fireEvent.keyDown(window, { key: 'n', metaKey: true })
expect(screen.getByTestId('create-app-modal'))!.toBeInTheDocument()
})
it('should open template dialog with create template shortcut', () => {
renderList()
fireEvent.keyDown(window, { key: 'n', metaKey: true, shiftKey: true })
expect(screen.getByTestId('template-dialog'))!.toBeInTheDocument()
})
it('should not trigger create shortcut while typing in search', () => {
renderList()
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'n', metaKey: true })
expect(screen.queryByTestId('create-app-modal'))!.not.toBeInTheDocument()
})
it('should not render create button for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument()
})
})
describe('Non-Editor User', () => {
it('should not render new app card for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)

View File

@ -3,16 +3,13 @@
import type { FC } from 'react'
import type { AppListQuery } from '@/contract/console/apps'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import { IS_DEV, NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import { useProviderContext } from '@/context/provider-context'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import { consoleQuery } from '@/service/client'
@ -20,6 +17,7 @@ import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import AppListHeaderFilters from './app-list-header-filters'
import { APP_LIST_SEARCH_DEBOUNCE_MS, MOCK_APP_LIST } from './constants'
import Empty from './empty'
import Footer from './footer'
@ -34,6 +32,12 @@ const TagManagementModal = dynamic(() => import('@/features/tag-management/compo
const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), {
ssr: false,
})
const CreateAppModal = dynamic(() => import('@/app/components/app/create-app-modal'), {
ssr: false,
})
const CreateAppTemplateDialog = dynamic(() => import('@/app/components/app/create-app-dialog'), {
ssr: false,
})
type Props = {
controlRefreshList?: number
@ -44,6 +48,7 @@ const List: FC<Props> = ({
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const { onPlanInfoChanged } = useProviderContext()
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
@ -57,6 +62,8 @@ const List: FC<Props> = ({
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false)
const [showNewAppModal, setShowNewAppModal] = useState(false)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
@ -112,14 +119,6 @@ const List: FC<Props> = ({
}, [controlRefreshList, refetch])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line h-[14px] w-[14px]" /> },
]
useEffect(() => {
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
@ -234,54 +233,21 @@ const List: FC<Props> = ({
<p className="system-sm-regular text-text-tertiary">{t('studioDescription', { ns: 'app' })}</p>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 py-2">
<div className="flex min-w-0 items-center gap-2">
<Select
value={category as string}
onValueChange={handleCategoryChange}
>
<SelectTrigger
aria-label={t('types.label', { ns: 'app' })}
className="w-auto shrink-0 gap-0 rounded-lg bg-components-input-bg-normal px-2 py-1 system-sm-regular text-text-tertiary hover:bg-components-input-bg-normal data-open:bg-components-input-bg-normal"
>
<span className="flex items-center gap-0">
<span aria-hidden className="i-ri-apps-2-line size-4 shrink-0 p-0.5" />
<span className="px-1">{t('types.label', { ns: 'app' })}</span>
</span>
</SelectTrigger>
<SelectContent popupClassName="min-w-40" listClassName="p-1">
{options.map(option => (
<SelectItem
key={option.value}
value={option.value}
onClick={() => handleCategoryChange(option.value)}
>
{option.icon}
<SelectItemText>{option.text}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
/>
</div>
<div className="flex items-center gap-2">
<label className="flex h-8 items-center gap-2 rounded-lg bg-components-input-bg-normal px-2 text-text-secondary">
<Checkbox checked={isCreatedByMe} onCheck={handleCreatedByMeChange} />
<span className="system-sm-regular whitespace-nowrap">
{t('showMyCreatedAppsOnly', { ns: 'app' })}
</span>
</label>
</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',
@ -348,11 +314,41 @@ const List: FC<Props> = ({
onSuccess={() => {
setShowCreateFromDSLModal(false)
setDroppedDSLFile(undefined)
onPlanInfoChanged()
refetch()
}}
droppedFile={droppedDSLFile}
/>
)}
{showNewAppModal && (
<CreateAppModal
show={showNewAppModal}
onClose={() => setShowNewAppModal(false)}
onSuccess={() => {
onPlanInfoChanged()
refetch()
}}
onCreateFromTemplate={() => {
setShowNewAppTemplateDialog(true)
setShowNewAppModal(false)
}}
defaultAppMode={category !== 'all' ? category : undefined}
/>
)}
{showNewAppTemplateDialog && (
<CreateAppTemplateDialog
show={showNewAppTemplateDialog}
onClose={() => setShowNewAppTemplateDialog(false)}
onSuccess={() => {
onPlanInfoChanged()
refetch()
}}
onCreateFromBlank={() => {
setShowNewAppModal(true)
setShowNewAppTemplateDialog(false)
}}
/>
)}
</>
)
}

View File

@ -120,6 +120,7 @@
"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

@ -120,6 +120,7 @@
"iconPicker.ok": "确认",
"importApp": "导入应用",
"importDSL": "导入 DSL 文件",
"importDSLDescription": "拖放 DSL 文件到 Studio 导入",
"importFromDSL": "导入 DSL",
"importFromDSLFile": "文件",
"importFromDSLUrl": "URL",