mirror of
https://github.com/langgenius/dify.git
synced 2026-05-28 21:03:22 +08:00
feat: new head
This commit is contained in:
@ -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)
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -120,6 +120,7 @@
|
||||
"iconPicker.ok": "确认",
|
||||
"importApp": "导入应用",
|
||||
"importDSL": "导入 DSL 文件",
|
||||
"importDSLDescription": "拖放 DSL 文件到 Studio 导入",
|
||||
"importFromDSL": "导入 DSL",
|
||||
"importFromDSLFile": "文件",
|
||||
"importFromDSLUrl": "URL",
|
||||
|
||||
Reference in New Issue
Block a user