feat(web): refine snippet siderbar

This commit is contained in:
JzoNg 2026-06-17 16:23:41 +08:00
parent 3e606ff0dc
commit 16a0cfc40c
8 changed files with 241 additions and 82 deletions

View File

@ -0,0 +1,107 @@
'use client'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import ToggleButton from '../../app-sidebar/toggle-button'
type SnippetDetailTopProps = {
expand?: boolean
onToggle?: () => void
}
const SEARCH_SHORTCUT = ['Mod', 'K']
const SnippetDetailTop = ({
expand = true,
onToggle,
}: SnippetDetailTopProps) => {
const { t } = useTranslation()
const router = useRouter()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) {
return (
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
{onToggle && (
<ToggleButton
expand={expand}
handleToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
)}
</div>
)
}
return (
<div className="flex items-center py-2 pr-2 pl-1">
<div className="flex min-w-0 flex-1 items-center gap-px">
<div className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 transition-colors hover:bg-background-default-hover">
<button
type="button"
aria-label={t('operation.back', { ns: 'common' })}
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
onClick={() => router.back()}
>
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
</button>
<Link
href="/"
aria-label={t('mainNav.home', { ns: 'common' })}
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
>
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
</Link>
</div>
<span className="shrink-0 system-md-regular text-text-quaternary">
/
</span>
<Link
href="/snippets"
className="shrink-0 truncate rounded-lg px-1.5 py-2 system-sm-semibold-uppercase text-text-secondary transition-colors hover:bg-background-default-hover hover:text-text-primary"
>
{t('tabs.snippets', { ns: 'workflow' })}
</Link>
</div>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={t('gotoAnything.searchTitle', { ns: 'app' })}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => setGotoAnythingOpen(true)}
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
)}
/>
<TooltipContent placement="bottom" className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]">
<span className="px-0.5">{t('gotoAnything.quickAction', { ns: 'app' })}</span>
<KbdGroup>
{SEARCH_SHORTCUT.map(key => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</TooltipContent>
</Tooltip>
{onToggle && (
<ToggleButton
expand={expand}
handleToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/>
)}
</div>
)
}
export default SnippetDetailTop

View File

@ -14,6 +14,9 @@ import DatasetDetailTop from '@/app/components/app-sidebar/dataset-detail-top'
import { useStore as useAppStore } from '@/app/components/app/store'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import EnvNav from '@/app/components/header/env-nav'
// import { buildIntegrationPath } from '@/app/components/integrations/routes'
// import { SnippetSidebarContent } from '@/app/components/snippets/components/snippet-sidebar'
// import { useSnippetDetailStore } from '@/app/components/snippets/store'
import { useAppContext } from '@/context/app-context'
import { AgentDetailSection, AgentDetailTop } from '@/features/agent-v2/agent-detail/navigation'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
@ -25,6 +28,7 @@ import AccountSection from './components/account-section'
import HelpMenu from './components/help-menu'
import MainNavLink from './components/nav-link'
import { MainNavSearchButton } from './components/search-button'
// import SnippetDetailTop from './components/snippet-detail-top'
import WebAppsSection from './components/web-apps-section'
import { WorkspaceCard } from './components/workspace-card'
import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes'
@ -111,9 +115,7 @@ const MainNav = ({
const detailNavigationTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isDetailNavigationHoverPreviewOpen = isCollapsedDetailNavigation && detailNavigationHoverPreviewOpen
const detailNavigationVisibleExpanded = detailNavigationExpanded || isDetailNavigationHoverPreviewOpen
const bottomNavigationExpanded = showSnippetDetailBottomNavigation
? false
: !showDetailNavigation || detailNavigationVisibleExpanded
const bottomNavigationExpanded = !showDetailNavigation || detailNavigationVisibleExpanded
const handleToggleDetailNavigation = useCallback(() => {
if (isDetailNavigationHoverPreviewOpen) {
if (detailNavigationTransitionTimerRef.current)
@ -222,9 +224,7 @@ const MainNav = ({
? detailNavigationExpanded
? 'w-[248px] bg-background-body p-1'
: 'w-16 bg-background-body p-1'
: showSnippetDetailBottomNavigation
? 'w-16 bg-background-body p-1'
: 'w-60 flex-col',
: 'w-60 flex-col',
'bg-background-body',
className,
)}

View File

@ -1,8 +1,8 @@
import type { ReactNode } from 'react'
import type { WorkflowProps } from '@/app/components/workflow'
import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
import type { SnippetDetail, SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
@ -13,6 +13,7 @@ const mockDoSyncWorkflowDraft = vi.fn()
const mockSyncWorkflowDraftWhenPageClose = vi.fn()
const mockReset = vi.fn()
const mockSetFields = vi.fn()
const mockSetNavigationState = vi.fn()
const mockPublishSnippetMutateAsync = vi.fn()
const mockUseSnippetPublishedWorkflow = vi.fn()
const mockFetchInspectVars = vi.fn()
@ -62,8 +63,13 @@ let capturedHooksStore: Record<string, unknown> | undefined
let capturedWorkflowNodes: WorkflowProps['nodes'] | undefined
let snippetDetailStoreState: {
fields: SnippetInputField[]
onFieldsChange?: (fields: SnippetInputField[]) => void
readonly: boolean
reset: typeof mockReset
setFields: typeof mockSetFields
setNavigationState: typeof mockSetNavigationState
snippet?: SnippetDetail
snippetId?: string
}
vi.mock('@/app/components/snippets/store', () => ({
@ -190,34 +196,6 @@ vi.mock('@/app/components/snippets/components/snippet-children', () => ({
),
}))
vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({
default: ({
fields,
onFieldsChange,
}: {
fields: SnippetInputField[]
onFieldsChange: (fields: SnippetInputField[]) => void
}) => (
<div>
<button type="button" onClick={() => onFieldsChange([])}>remove</button>
<button
type="button"
onClick={() => onFieldsChange([
...fields,
{
type: PipelineInputVarType.textInput,
label: 'New Field',
variable: 'new_field',
required: true,
},
])}
>
submit
</button>
</div>
),
}))
const payload: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
@ -328,12 +306,20 @@ describe('SnippetMain', () => {
},
})
mockHandleCheckBeforePublish.mockResolvedValue(true)
mockSetNavigationState.mockImplementation((state) => {
snippetDetailStoreState = {
...snippetDetailStoreState,
...state,
}
})
capturedHooksStore = undefined
capturedWorkflowNodes = undefined
snippetDetailStoreState = {
fields: [...payload.inputFields],
readonly: true,
reset: mockReset,
setFields: mockSetFields,
setNavigationState: mockSetNavigationState,
}
mockWorkspacePermissionKeys.value = ['snippets.create_and_modify']
})
@ -414,7 +400,12 @@ describe('SnippetMain', () => {
it('should sync draft input_fields when removing a field from the panel', async () => {
renderSnippetMain({ currentNodes: [createDraftNode()] })
fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => {
expect(snippetDetailStoreState.onFieldsChange).toEqual(expect.any(Function))
})
act(() => {
snippetDetailStoreState.onFieldsChange?.([])
})
await waitFor(() => {
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
@ -426,7 +417,20 @@ describe('SnippetMain', () => {
it('should sync draft input_fields when adding a field from the sidebar', async () => {
renderSnippetMain({ currentNodes: [createDraftNode()] })
fireEvent.click(screen.getByRole('button', { name: 'submit' }))
await waitFor(() => {
expect(snippetDetailStoreState.onFieldsChange).toEqual(expect.any(Function))
})
act(() => {
snippetDetailStoreState.onFieldsChange?.([
...payload.inputFields,
{
type: PipelineInputVarType.textInput,
label: 'New Field',
variable: 'new_field',
required: true,
},
])
})
await waitFor(() => {
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([

View File

@ -53,20 +53,6 @@ vi.mock('@/app/components/app/configuration/config-var/config-modal', () => ({
},
}))
vi.mock('@/next/link', () => ({
default: ({
children,
href,
className,
}: {
children: React.ReactNode
href: string
className?: string
}) => (
<a href={href} className={className}>{children}</a>
),
}))
vi.mock('@/app/components/workflow/nodes/start/components/var-list', () => ({
default: (props: {
list: InputVar[]
@ -155,7 +141,11 @@ describe('SnippetSidebar', () => {
/>,
)
expect(screen.getByRole('link', { name: /snippet\.management/i })).toHaveAttribute('href', '/snippets')
expect(screen.queryByRole('link', { name: /snippet\.management/i })).not.toBeInTheDocument()
expect(screen.getByText(snippet.name)).toHaveAttribute('title', snippet.name)
expect(screen.getByText(snippet.name)).toHaveClass('truncate')
expect(screen.getByText(snippet.description)).toHaveAttribute('title', snippet.description)
expect(screen.getByText(snippet.description)).toHaveClass('truncate')
expect(screen.queryByRole('button', { name: /common\.operation\.add/i })).not.toBeInTheDocument()
expect(capturedVarListProps?.readonly).toBe(true)
})

View File

@ -39,7 +39,6 @@ import { useSnippetInputFieldActions } from './hooks/use-snippet-input-field-act
import { useSnippetPublish } from './hooks/use-snippet-publish'
import SaveBeforeLeavingDialog from './save-before-leaving-dialog'
import SnippetChildren from './snippet-children'
import SnippetSidebar from './snippet-sidebar'
type SnippetMainProps = {
payload: SnippetDetailPayload
@ -365,9 +364,11 @@ const SnippetMain = ({
const {
reset,
setFields,
setNavigationState,
} = useSnippetDetailStore(useShallow(state => ({
reset: state.reset,
setFields: state.setFields,
setNavigationState: state.setNavigationState,
})))
const {
fields,
@ -385,6 +386,8 @@ const SnippetMain = ({
const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl(snippetId)
useEffect(() => {
reset()
return () => reset()
}, [reset, snippetId])
useEffect(() => {
@ -447,6 +450,15 @@ const SnippetMain = ({
setHasDraftChanges(true)
}, [canCreateAndModifySnippet, handleFieldsChange, isEditing, setHasDraftChanges])
useEffect(() => {
setNavigationState({
snippetId,
snippet,
readonly: !isEditing,
onFieldsChange: handleFieldsChangeInEditing,
})
}, [handleFieldsChangeInEditing, isEditing, setNavigationState, snippet, snippetId])
const updateLocalDraftFromSyncPayload = useCallback((
syncedDraftPayload?: Omit<SnippetDraftSyncPayload, 'hash'> | void,
) => {
@ -600,12 +612,6 @@ const SnippetMain = ({
return (
<div className="relative flex h-full min-h-0 min-w-0">
<SnippetSidebar
snippet={snippet}
fields={fields}
readonly={!isEditing}
onFieldsChange={handleFieldsChangeInEditing}
/>
<div className="relative min-h-0 min-w-0 grow">
<WorkflowWithInnerContext
key={`${snippetId}-${isEditing ? 'draft' : 'published'}`}

View File

@ -11,7 +11,6 @@ import SnippetInfoDropdown from '@/app/components/app-sidebar/snippet-info/dropd
import ConfigVarModal from '@/app/components/app/configuration/config-var/config-modal'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import VarList from '@/app/components/workflow/nodes/start/components/var-list'
import Link from '@/next/link'
import { hasDuplicateStr } from '@/utils/var'
type SnippetSidebarProps = {
@ -21,6 +20,10 @@ type SnippetSidebarProps = {
onFieldsChange: (fields: SnippetInputField[]) => void
}
type SnippetSidebarContentProps = SnippetSidebarProps & {
className?: string
}
const toWorkflowInputVar = (field: SnippetInputField): InputVar => ({
...field,
type: field.type as unknown as InputVar['type'],
@ -32,12 +35,13 @@ const toSnippetInputField = (field: InputVar): SnippetInputField => ({
type: field.type as unknown as SnippetInputField['type'],
})
const SnippetSidebar = ({
export const SnippetSidebarContent = ({
snippet,
fields,
readonly,
onFieldsChange,
}: SnippetSidebarProps) => {
className,
}: SnippetSidebarContentProps) => {
const { t } = useTranslation()
const [isShowAddVarModal, setIsShowAddVarModal] = useState(false)
const workflowInputVars = useMemo(() => fields.map(toWorkflowInputVar), [fields])
@ -88,21 +92,13 @@ const SnippetSidebar = ({
}, [fields, onFieldsChange])
return (
<aside className="flex h-full w-90 shrink-0 flex-col overflow-hidden rounded-tl-2xl border-r border-divider-subtle bg-background-default">
<div className="shrink-0 px-6 pt-7">
<Link
href="/snippets"
className="inline-flex items-center gap-2 system-sm-semibold-uppercase text-text-primary hover:text-text-accent"
>
<span aria-hidden className="i-ri-arrow-left-line h-4 w-4" />
{t('management', { ns: 'snippet' })}
</Link>
<div className="mt-12 flex items-start gap-3">
<div className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-background-default', className)}>
<div className="shrink-0 px-3 py-2">
<div className="flex items-start gap-3">
<div className="min-w-0 grow">
<div className="system-xl-semibold text-text-primary">{snippet.name}</div>
<div className="truncate system-xl-semibold text-text-primary" title={snippet.name}>{snippet.name}</div>
{!!snippet.description && (
<div className="mt-3 system-sm-regular text-text-tertiary">
<div className="mt-3 truncate system-sm-regular text-text-tertiary" title={snippet.description}>
{snippet.description}
</div>
)}
@ -111,9 +107,7 @@ const SnippetSidebar = ({
</div>
</div>
<div className="mx-6 mt-7 h-px shrink-0 bg-divider-subtle" />
<div className="flex min-h-0 grow flex-col px-6 pt-7">
<div className="flex min-h-0 grow flex-col px-3 pt-6">
<Field
title={t('inputVariables', { ns: 'snippet' })}
operations={!readonly
@ -151,6 +145,14 @@ const SnippetSidebar = ({
varKeys={fields.map(v => v.variable)}
/>
)}
</div>
)
}
const SnippetSidebar = (props: SnippetSidebarProps) => {
return (
<aside className="flex h-full w-90 shrink-0 flex-col overflow-hidden rounded-tl-2xl border-r border-divider-subtle bg-background-default">
<SnippetSidebarContent {...props} />
</aside>
)
}

View File

@ -1,4 +1,4 @@
import type { SnippetInputField } from '@/models/snippet'
import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetDetailStore } from '..'
@ -9,6 +9,15 @@ const createField = (variable: string): SnippetInputField => ({
required: true,
})
const snippet: SnippetDetail = {
id: 'snippet-1',
name: 'Snippet',
description: 'Description',
updatedAt: '2026-03-29 10:00',
usage: '0',
tags: [],
}
describe('useSnippetDetailStore', () => {
beforeEach(() => {
useSnippetDetailStore.getState().reset()
@ -28,4 +37,32 @@ describe('useSnippetDetailStore', () => {
expect(useSnippetDetailStore.getState().fields).toEqual([])
})
it('should store and reset snippet navigation state', () => {
const onFieldsChange = vi.fn()
useSnippetDetailStore.getState().setNavigationState({
snippetId: 'snippet-1',
snippet,
readonly: false,
onFieldsChange,
})
expect(useSnippetDetailStore.getState()).toMatchObject({
snippetId: 'snippet-1',
snippet,
readonly: false,
onFieldsChange,
})
useSnippetDetailStore.getState().reset()
expect(useSnippetDetailStore.getState()).toMatchObject({
fields: [],
readonly: true,
snippet: undefined,
snippetId: undefined,
onFieldsChange: undefined,
})
})
})

View File

@ -1,20 +1,33 @@
'use client'
import type { SnippetInputField } from '@/models/snippet'
import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
import { create } from 'zustand'
type SnippetNavigationState = {
snippet?: SnippetDetail
snippetId?: string
readonly: boolean
onFieldsChange?: (fields: SnippetInputField[]) => void
}
type SnippetDetailUIState = {
fields: SnippetInputField[]
setFields: (fields: SnippetInputField[]) => void
setNavigationState: (state: SnippetNavigationState) => void
reset: () => void
}
} & SnippetNavigationState
const initialState = {
fields: [] as SnippetInputField[],
readonly: true,
snippet: undefined,
snippetId: undefined,
onFieldsChange: undefined,
}
export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({
...initialState,
setFields: fields => set({ fields }),
setNavigationState: state => set(state),
reset: () => set(initialState),
}))