diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index e5a618f60e..fb39d81f71 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -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) diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 6211b40720..8df36aafde 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -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 = ({ 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 = ({ const newAppCardRef = useRef(null) const containerRef = useRef(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() @@ -112,14 +119,6 @@ const List: FC = ({ }, [controlRefreshList, refetch]) const anchorRef = useRef(null) - const options = [ - { value: 'all', text: t('types.all', { ns: 'app' }), icon: }, - { value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: }, - { value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: }, - { value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: }, - { value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: }, - { value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: }, - ] useEffect(() => { if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') { @@ -234,54 +233,21 @@ const List: FC = ({

{t('studioDescription', { ns: 'app' })}

-
-
- - setShowTagManagementModal(true)} /> - setKeywords(e.target.value)} - onClear={() => setKeywords('')} - /> -
-
- -
-
+ setShowNewAppModal(true)} + onCreateTemplate={() => setShowNewAppTemplateDialog(true)} + onImportDSL={() => setShowCreateFromDSLModal(true)} + onOpenTagManagement={() => setShowTagManagementModal(true)} + showCreateButton={isCurrentWorkspaceEditor} + />
= ({ onSuccess={() => { setShowCreateFromDSLModal(false) setDroppedDSLFile(undefined) + onPlanInfoChanged() refetch() }} droppedFile={droppedDSLFile} /> )} + {showNewAppModal && ( + setShowNewAppModal(false)} + onSuccess={() => { + onPlanInfoChanged() + refetch() + }} + onCreateFromTemplate={() => { + setShowNewAppTemplateDialog(true) + setShowNewAppModal(false) + }} + defaultAppMode={category !== 'all' ? category : undefined} + /> + )} + {showNewAppTemplateDialog && ( + setShowNewAppTemplateDialog(false)} + onSuccess={() => { + onPlanInfoChanged() + refetch() + }} + onCreateFromBlank={() => { + setShowNewAppModal(true) + setShowNewAppTemplateDialog(false) + }} + /> + )} ) } diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index 1a4e83e8eb..ab27f400bb 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -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", diff --git a/web/i18n/zh-Hans/app.json b/web/i18n/zh-Hans/app.json index 37623c95fa..b972a68aa7 100644 --- a/web/i18n/zh-Hans/app.json +++ b/web/i18n/zh-Hans/app.json @@ -120,6 +120,7 @@ "iconPicker.ok": "确认", "importApp": "导入应用", "importDSL": "导入 DSL 文件", + "importDSLDescription": "拖放 DSL 文件到 Studio 导入", "importFromDSL": "导入 DSL", "importFromDSLFile": "文件", "importFromDSLUrl": "URL",