diff --git a/web/app/components/header/app-nav/__tests__/index.spec.tsx b/web/app/components/header/app-nav/__tests__/index.spec.tsx
index 03f8edfacf..e7b546a589 100644
--- a/web/app/components/header/app-nav/__tests__/index.spec.tsx
+++ b/web/app/components/header/app-nav/__tests__/index.spec.tsx
@@ -2,13 +2,16 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
-import { useParams } from '@/next/navigation'
+import { useParams, usePathname, useRouter } from '@/next/navigation'
import { useInfiniteAppList } from '@/service/use-apps'
+import { useCreateSnippetMutation, useInfiniteSnippetList, useSnippetApiDetail } from '@/service/use-snippets'
import { AppModeEnum } from '@/types/app'
import AppNav from '../index'
vi.mock('@/next/navigation', () => ({
useParams: vi.fn(),
+ usePathname: vi.fn(),
+ useRouter: vi.fn(),
}))
vi.mock('react-i18next', () => ({
@@ -29,6 +32,19 @@ vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: vi.fn(),
}))
+vi.mock('@/service/use-snippets', () => ({
+ useCreateSnippetMutation: vi.fn(),
+ useInfiniteSnippetList: vi.fn(),
+ useSnippetApiDetail: vi.fn(),
+}))
+
+vi.mock('@langgenius/dify-ui/toast', () => ({
+ toast: {
+ error: vi.fn(),
+ success: vi.fn(),
+ },
+}))
+
vi.mock('@/app/components/app/create-app-dialog', () => ({
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) =>
show
@@ -83,17 +99,67 @@ vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
: null,
}))
+vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({
+ default: ({
+ isOpen,
+ onClose,
+ onConfirm,
+ }: {
+ isOpen: boolean
+ onClose: () => void
+ onConfirm: (payload: {
+ name: string
+ description: string
+ icon: { type: 'emoji', icon: string, background: string }
+ }) => void
+ }) =>
+ isOpen
+ ? (
+
+ )
+ : null,
+}))
+
vi.mock('../../nav', () => ({
default: ({
+ createText,
+ curNav,
+ isApp,
+ link,
onCreate,
onLoadMore,
navigationItems,
}: {
+ createText: string
+ curNav?: { id: string, name: string }
+ isApp?: boolean
+ link: string
onCreate: (state: string) => void
onLoadMore?: () => void
navigationItems?: Array<{ id: string, name: string, link: string }>
}) => (
+
{link}
+
{String(isApp)}
+
{createText}
+
{curNav ? `${curNav.id}:${curNav.name}` : ''}
{(navigationItems ?? []).map(item => (
- {`${item.name} -> ${item.link}`}
@@ -127,10 +193,52 @@ const mockAppData = [
},
]
+const mockSnippetData = [
+ {
+ id: 'snippet-1',
+ name: 'Snippet 1',
+ description: '',
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '🧩',
+ icon_background: '#fff',
+ icon_url: null,
+ },
+ is_published: true,
+ use_count: 0,
+ created_at: 1,
+ created_by: 'user-1',
+ updated_at: 1,
+ updated_by: 'user-1',
+ },
+ {
+ id: 'snippet-2',
+ name: 'Snippet 2',
+ description: '',
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '⚙️',
+ icon_background: '#000',
+ icon_url: null,
+ },
+ is_published: true,
+ use_count: 1,
+ created_at: 1,
+ created_by: 'user-1',
+ updated_at: 1,
+ updated_by: 'user-1',
+ },
+]
+
const mockUseParams = vi.mocked(useParams)
+const mockUsePathname = vi.mocked(usePathname)
+const mockUseRouter = vi.mocked(useRouter)
const mockUseAppContext = vi.mocked(useAppContext)
const mockUseAppStore = vi.mocked(useAppStore)
const mockUseInfiniteAppList = vi.mocked(useInfiniteAppList)
+const mockUseInfiniteSnippetList = vi.mocked(useInfiniteSnippetList)
+const mockUseSnippetApiDetail = vi.mocked(useSnippetApiDetail)
+const mockUseCreateSnippetMutation = vi.mocked(useCreateSnippetMutation)
let mockAppDetail: { id: string, name: string } | null = null
const setupDefaultMocks = (options?: {
@@ -144,6 +252,8 @@ const setupDefaultMocks = (options?: {
const fetchNextPage = options?.fetchNextPage ?? vi.fn()
mockUseParams.mockReturnValue({ appId: 'app-1' } as ReturnType)
+ mockUsePathname.mockReturnValue('/app/app-1/workflow')
+ mockUseRouter.mockReturnValue({ push: vi.fn() } as unknown as ReturnType)
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceEditor: options?.isEditor ?? false } as ReturnType)
mockUseAppStore.mockImplementation((selector: unknown) => (selector as (state: { appDetail: { id: string, name: string } | null }) => unknown)({ appDetail: mockAppDetail }))
mockUseInfiniteAppList.mockReturnValue({
@@ -153,10 +263,51 @@ const setupDefaultMocks = (options?: {
isFetchingNextPage: false,
refetch,
} as ReturnType)
+ mockUseInfiniteSnippetList.mockReturnValue({
+ data: undefined,
+ fetchNextPage: vi.fn(),
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+ mockUseSnippetApiDetail.mockReturnValue({
+ data: undefined,
+ } as ReturnType)
+ mockUseCreateSnippetMutation.mockReturnValue({
+ isPending: false,
+ mutate: vi.fn(),
+ } as unknown as ReturnType)
return { refetch, fetchNextPage }
}
+const setupSnippetMocks = (options?: {
+ fetchNextPage?: () => void
+ hasNextPage?: boolean
+ mutate?: ReturnType
+}) => {
+ const fetchNextPage = options?.fetchNextPage ?? vi.fn()
+ const mutate = options?.mutate ?? vi.fn()
+
+ setupDefaultMocks({ isEditor: true })
+ mockUseParams.mockReturnValue({ snippetId: 'snippet-1' } as ReturnType)
+ mockUsePathname.mockReturnValue('/snippets/snippet-1/orchestrate')
+ mockUseInfiniteSnippetList.mockReturnValue({
+ data: { pages: [{ data: mockSnippetData }] },
+ fetchNextPage,
+ hasNextPage: options?.hasNextPage ?? false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType)
+ mockUseSnippetApiDetail.mockReturnValue({
+ data: mockSnippetData[0],
+ } as ReturnType)
+ mockUseCreateSnippetMutation.mockReturnValue({
+ isPending: false,
+ mutate,
+ } as unknown as ReturnType)
+
+ return { fetchNextPage, mutate }
+}
+
describe('AppNav', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -338,4 +489,67 @@ describe('AppNav', () => {
expect(screen.getByText('App 1 -> /app/app-1/configuration')).toBeInTheDocument()
})
})
+
+ it('should switch the main nav to snippet list and render snippet items on snippet detail routes', () => {
+ setupSnippetMocks()
+
+ render()
+
+ expect(screen.getByTestId('nav-link')).toHaveTextContent('/snippets')
+ expect(screen.getByTestId('nav-is-app')).toHaveTextContent('false')
+ expect(screen.getByTestId('nav-current')).toHaveTextContent('snippet-1:Snippet 1')
+ expect(screen.getByTestId('nav-create-text')).toHaveTextContent('createFromBlank')
+ expect(screen.getByText('Snippet 1 -> /snippets/snippet-1/orchestrate')).toBeInTheDocument()
+ expect(screen.getByText('Snippet 2 -> /snippets/snippet-2/orchestrate')).toBeInTheDocument()
+ })
+
+ it('should not show stale snippet detail as the current nav while switching snippets', () => {
+ setupSnippetMocks()
+ mockUseParams.mockReturnValue({ snippetId: 'snippet-2' } as ReturnType)
+ mockUseSnippetApiDetail.mockReturnValue({
+ data: mockSnippetData[0],
+ } as ReturnType)
+
+ render()
+
+ expect(screen.getByTestId('nav-current')).toBeEmptyDOMElement()
+ })
+
+ it('should load more snippets from the snippet selector when more data is available', async () => {
+ const user = userEvent.setup()
+ const { fetchNextPage } = setupSnippetMocks({ hasNextPage: true })
+
+ render()
+
+ await user.click(screen.getByTestId('load-more'))
+ expect(fetchNextPage).toHaveBeenCalledTimes(1)
+ })
+
+ it('should open the create snippet dialog from snippet nav create action', async () => {
+ const user = userEvent.setup()
+ const mutate = vi.fn()
+ setupSnippetMocks({ mutate })
+
+ render()
+
+ await user.click(screen.getByTestId('create-blank'))
+ expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
+
+ await user.click(screen.getByTestId('create-snippet-dialog'))
+ expect(mutate).toHaveBeenCalledWith({
+ body: {
+ name: 'Created Snippet',
+ description: undefined,
+ icon_info: {
+ icon: '🤖',
+ icon_type: 'emoji',
+ icon_background: '#fff',
+ icon_url: undefined,
+ },
+ },
+ }, expect.objectContaining({
+ onSuccess: expect.any(Function),
+ onError: expect.any(Function),
+ }))
+ })
})
diff --git a/web/app/components/header/app-nav/index.tsx b/web/app/components/header/app-nav/index.tsx
index d98eaa1b3b..4f3a580ef8 100644
--- a/web/app/components/header/app-nav/index.tsx
+++ b/web/app/components/header/app-nav/index.tsx
@@ -1,19 +1,22 @@
'use client'
import type { NavItem } from '../nav/nav-selector'
-import {
- RiRobot2Fill,
- RiRobot2Line,
-} from '@remixicon/react'
+import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
+import { toast } from '@langgenius/dify-ui/toast'
import { flatten } from 'es-toolkit/compat'
-import { produce } from 'immer'
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
+import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
import { useAppContext } from '@/context/app-context'
import dynamic from '@/next/dynamic'
-import { useParams } from '@/next/navigation'
+import { useParams, usePathname, useRouter } from '@/next/navigation'
import { useInfiniteAppList } from '@/service/use-apps'
+import {
+ useCreateSnippetMutation,
+ useInfiniteSnippetList,
+ useSnippetApiDetail,
+} from '@/service/use-snippets'
import { AppModeEnum } from '@/types/app'
import Nav from '../nav'
@@ -23,13 +26,18 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
const AppNav = () => {
const { t } = useTranslation()
- const { appId } = useParams()
+ const { appId, snippetId } = useParams()
+ const { push } = useRouter()
+ const pathname = usePathname()
+ const isSnippetSegment = pathname === '/snippets' || pathname.startsWith('/snippets/')
+ const currentSnippetId = typeof snippetId === 'string' ? snippetId : ''
const { isCurrentWorkspaceEditor } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
const [showNewAppDialog, setShowNewAppDialog] = useState(false)
const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
- const [navItems, setNavItems] = useState([])
+ const [showCreateSnippetDialog, setShowCreateSnippetDialog] = useState(false)
+ const createSnippetMutation = useCreateSnippetMutation()
const {
data: appsData,
@@ -41,14 +49,36 @@ const AppNav = () => {
page: 1,
limit: 30,
name: '',
- }, { enabled: !!appId })
+ }, { enabled: !!appId && !isSnippetSegment })
+
+ const {
+ data: snippetsData,
+ fetchNextPage: fetchNextSnippetPage,
+ hasNextPage: hasNextSnippetPage,
+ isFetchingNextPage: isFetchingNextSnippetPage,
+ } = useInfiniteSnippetList({
+ page: 1,
+ limit: 30,
+ }, { enabled: !!currentSnippetId })
+
+ const { data: snippetDetail } = useSnippetApiDetail(currentSnippetId)
const handleLoadMore = useCallback(() => {
if (hasNextPage)
fetchNextPage()
}, [fetchNextPage, hasNextPage])
+ const handleLoadMoreSnippet = useCallback(() => {
+ if (hasNextSnippetPage)
+ fetchNextSnippetPage()
+ }, [fetchNextSnippetPage, hasNextSnippetPage])
+
const openModal = (state: string) => {
+ if (isSnippetSegment) {
+ setShowCreateSnippetDialog(true)
+ return
+ }
+
if (state === 'blank')
setShowNewAppDialog(true)
if (state === 'template')
@@ -57,64 +87,125 @@ const AppNav = () => {
setShowCreateFromDSLModal(true)
}
- useEffect(() => {
- if (appsData) {
- const appItems = flatten((appsData.pages ?? []).map(appData => appData.data))
- const navItems = appItems.map((app) => {
- const link = ((isCurrentWorkspaceEditor, app) => {
- if (!isCurrentWorkspaceEditor) {
- return `/app/${app.id}/overview`
- }
- else {
- if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
- return `/app/${app.id}/workflow`
- else
- return `/app/${app.id}/configuration`
- }
- })(isCurrentWorkspaceEditor, app)
- return {
- id: app.id,
- icon_type: app.icon_type,
- icon: app.icon,
- icon_background: app.icon_background,
- icon_url: app.icon_url,
- name: app.name,
- mode: app.mode,
- link,
- }
- })
- setNavItems(navItems as any)
- }
- }, [appsData, isCurrentWorkspaceEditor, setNavItems])
+ const appNavItems = useMemo(() => {
+ if (!appsData)
+ return []
- // update current app name
- useEffect(() => {
- if (appDetail) {
- const newNavItems = produce(navItems, (draft: NavItem[]) => {
- navItems.forEach((app, index) => {
- if (app.id === appDetail.id)
- draft[index]!.name = appDetail.name
- })
- })
- setNavItems(newNavItems)
+ const appItems = flatten((appsData.pages ?? []).map(appData => appData.data))
+
+ return appItems.map((app) => {
+ const link = (() => {
+ if (!isCurrentWorkspaceEditor)
+ return `/app/${app.id}/overview`
+
+ if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
+ return `/app/${app.id}/workflow`
+
+ return `/app/${app.id}/configuration`
+ })()
+
+ return {
+ id: app.id,
+ icon_type: app.icon_type,
+ icon: app.icon,
+ icon_background: app.icon_background,
+ icon_url: app.icon_url,
+ name: appDetail?.id === app.id ? appDetail.name : app.name,
+ mode: app.mode,
+ link,
+ }
+ })
+ }, [appDetail?.id, appDetail?.name, appsData, isCurrentWorkspaceEditor])
+
+ const snippetNavItems = useMemo(() => {
+ if (!snippetsData)
+ return []
+
+ const snippetItems = flatten((snippetsData.pages ?? []).map(snippetData => snippetData.data))
+
+ return snippetItems.map(snippet => ({
+ id: snippet.id,
+ icon_type: snippet.icon_info.icon_type,
+ icon: snippet.icon_info.icon,
+ icon_background: snippet.icon_info.icon_background ?? null,
+ icon_url: snippet.icon_info.icon_url ?? null,
+ name: snippet.name,
+ link: `/snippets/${snippet.id}/orchestrate`,
+ }))
+ }, [snippetsData])
+
+ const currentSnippetNav = useMemo(() => {
+ if (!snippetDetail)
+ return
+
+ if (snippetDetail.id !== currentSnippetId)
+ return
+
+ return {
+ id: snippetDetail.id,
+ icon_type: snippetDetail.icon_info.icon_type,
+ icon: snippetDetail.icon_info.icon,
+ icon_background: snippetDetail.icon_info.icon_background ?? null,
+ icon_url: snippetDetail.icon_info.icon_url ?? null,
+ name: snippetDetail.name,
}
- }, [appDetail, navItems])
+ }, [currentSnippetId, snippetDetail])
+
+ const handleCreateSnippet = useCallback(({
+ name,
+ description,
+ icon,
+ }: {
+ name: string
+ description: string
+ icon: AppIconSelection
+ }) => {
+ createSnippetMutation.mutate({
+ body: {
+ name,
+ description: description || undefined,
+ icon_info: {
+ icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
+ icon_type: icon.type,
+ icon_background: icon.type === 'emoji' ? icon.background : undefined,
+ icon_url: icon.type === 'image' ? icon.url : undefined,
+ },
+ },
+ }, {
+ onSuccess: (snippet) => {
+ toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
+ setShowCreateSnippetDialog(false)
+ push(`/snippets/${snippet.id}/orchestrate`)
+ },
+ onError: (error) => {
+ toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' }))
+ },
+ })
+ }, [createSnippetMutation, push, t])
+
+ const currentNav = isSnippetSegment ? currentSnippetNav : appDetail
+ const currentNavigationItems = isSnippetSegment ? snippetNavItems : appNavItems
+ const currentCreateText = isSnippetSegment
+ ? t('createFromBlank', { ns: 'snippet' })
+ : t('menus.newApp', { ns: 'common' })
+ const currentLoadMore = isSnippetSegment ? handleLoadMoreSnippet : handleLoadMore
+ const currentIsLoadingMore = isSnippetSegment ? isFetchingNextSnippetPage : isFetchingNextPage
return (
<>
}
- activeIcon={}
+ isApp={!isSnippetSegment}
+ icon={}
+ activeIcon={}
text={t('menus.apps', { ns: 'common' })}
activeSegment={['apps', 'app', 'snippets']}
- link="/apps"
- curNav={appDetail}
- navigationItems={navItems}
- createText={t('menus.newApp', { ns: 'common' })}
+ link={isSnippetSegment ? '/snippets' : '/apps'}
+ curNav={currentNav ?? undefined}
+ navigationItems={currentNavigationItems}
+ createText={currentCreateText}
onCreate={openModal}
- onLoadMore={handleLoadMore}
- isLoadingMore={isFetchingNextPage}
+ onLoadMore={currentLoadMore}
+ isLoadingMore={currentIsLoadingMore}
/>
{
onClose={() => setShowCreateFromDSLModal(false)}
onSuccess={() => refetch()}
/>
+ {showCreateSnippetDialog && (
+ setShowCreateSnippetDialog(false)}
+ onConfirm={handleCreateSnippet}
+ />
+ )}
>
)
}
diff --git a/web/app/components/header/nav/index.tsx b/web/app/components/header/nav/index.tsx
index b59aa14f61..1c702a5f29 100644
--- a/web/app/components/header/nav/index.tsx
+++ b/web/app/components/header/nav/index.tsx
@@ -5,7 +5,6 @@ import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useState } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
-import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import NavSelector from './nav-selector'
@@ -62,7 +61,7 @@ const Nav = ({
{
(hovered && curNav)
- ?
+ ?
: isActivated
? activeIcon
: icon
diff --git a/web/app/components/snippets/components/snippet-card.tsx b/web/app/components/snippets/components/snippet-card.tsx
index 3edb16a3e3..b9cd95da14 100644
--- a/web/app/components/snippets/components/snippet-card.tsx
+++ b/web/app/components/snippets/components/snippet-card.tsx
@@ -50,7 +50,7 @@ const SnippetCard = ({ snippet }: Props) => {
background={snippet.icon_info.icon_background}
imageUrl={snippet.icon_info.icon_url}
/>
-