feat(web): support snippet nav

This commit is contained in:
JzoNg 2026-04-28 16:26:39 +08:00
parent 42889d23e5
commit 70fd4a5c88
4 changed files with 375 additions and 63 deletions

View File

@ -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
? (
<button
type="button"
data-testid="create-snippet-dialog"
onClick={() => {
onConfirm({
name: 'Created Snippet',
description: '',
icon: {
type: 'emoji',
icon: '🤖',
background: '#fff',
},
})
onClose()
}}
>
Create Snippet
</button>
)
: 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 }>
}) => (
<div data-testid="nav">
<div data-testid="nav-link">{link}</div>
<div data-testid="nav-is-app">{String(isApp)}</div>
<div data-testid="nav-create-text">{createText}</div>
<div data-testid="nav-current">{curNav ? `${curNav.id}:${curNav.name}` : ''}</div>
<ul data-testid="nav-items">
{(navigationItems ?? []).map(item => (
<li key={item.id}>{`${item.name} -> ${item.link}`}</li>
@ -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<typeof useParams>)
mockUsePathname.mockReturnValue('/app/app-1/workflow')
mockUseRouter.mockReturnValue({ push: vi.fn() } as unknown as ReturnType<typeof useRouter>)
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceEditor: options?.isEditor ?? false } as ReturnType<typeof useAppContext>)
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<typeof useInfiniteAppList>)
mockUseInfiniteSnippetList.mockReturnValue({
data: undefined,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useInfiniteSnippetList>)
mockUseSnippetApiDetail.mockReturnValue({
data: undefined,
} as ReturnType<typeof useSnippetApiDetail>)
mockUseCreateSnippetMutation.mockReturnValue({
isPending: false,
mutate: vi.fn(),
} as unknown as ReturnType<typeof useCreateSnippetMutation>)
return { refetch, fetchNextPage }
}
const setupSnippetMocks = (options?: {
fetchNextPage?: () => void
hasNextPage?: boolean
mutate?: ReturnType<typeof vi.fn>
}) => {
const fetchNextPage = options?.fetchNextPage ?? vi.fn()
const mutate = options?.mutate ?? vi.fn()
setupDefaultMocks({ isEditor: true })
mockUseParams.mockReturnValue({ snippetId: 'snippet-1' } as ReturnType<typeof useParams>)
mockUsePathname.mockReturnValue('/snippets/snippet-1/orchestrate')
mockUseInfiniteSnippetList.mockReturnValue({
data: { pages: [{ data: mockSnippetData }] },
fetchNextPage,
hasNextPage: options?.hasNextPage ?? false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useInfiniteSnippetList>)
mockUseSnippetApiDetail.mockReturnValue({
data: mockSnippetData[0],
} as ReturnType<typeof useSnippetApiDetail>)
mockUseCreateSnippetMutation.mockReturnValue({
isPending: false,
mutate,
} as unknown as ReturnType<typeof useCreateSnippetMutation>)
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(<AppNav />)
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<typeof useParams>)
mockUseSnippetApiDetail.mockReturnValue({
data: mockSnippetData[0],
} as ReturnType<typeof useSnippetApiDetail>)
render(<AppNav />)
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(<AppNav />)
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(<AppNav />)
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),
}))
})
})

View File

@ -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<NavItem[]>([])
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<NavItem[]>(() => {
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<NavItem[]>(() => {
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 (
<>
<Nav
isApp
icon={<RiRobot2Line className="h-4 w-4" />}
activeIcon={<RiRobot2Fill className="h-4 w-4" />}
isApp={!isSnippetSegment}
icon={<span className="i-ri-robot-2-line h-4 w-4" />}
activeIcon={<span className="i-ri-robot-2-fill h-4 w-4" />}
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}
/>
<CreateAppModal
show={showNewAppDialog}
@ -131,6 +222,14 @@ const AppNav = () => {
onClose={() => setShowCreateFromDSLModal(false)}
onSuccess={() => refetch()}
/>
{showCreateSnippetDialog && (
<CreateSnippetDialog
isOpen={showCreateSnippetDialog}
isSubmitting={createSnippetMutation.isPending}
onClose={() => setShowCreateSnippetDialog(false)}
onConfirm={handleCreateSnippet}
/>
)}
</>
)
}

View File

@ -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 = ({
<div>
{
(hovered && curNav)
? <ArrowNarrowLeft className="h-4 w-4" />
? <span className="i-custom-vender-line-arrows-arrow-narrow-left h-4 w-4" />
: isActivated
? activeIcon
: icon

View File

@ -50,7 +50,7 @@ const SnippetCard = ({ snippet }: Props) => {
background={snippet.icon_info.icon_background}
imageUrl={snippet.icon_info.icon_url}
/>
<div className="w-0 grow py-[1px]">
<div className="w-0 grow py-px">
<div className="truncate text-sm leading-5 font-semibold text-text-secondary" title={snippet.name}>
{snippet.name}
</div>