mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
feat(web): snippet header in graph
This commit is contained in:
parent
a6e9316de3
commit
c41ba7d627
@ -1,3 +1,4 @@
|
||||
import type { HeaderProps } from '@/app/components/workflow/header'
|
||||
import type { SnippetDetailPayload } from '@/models/snippet'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
@ -81,6 +82,20 @@ vi.mock('@/app/components/workflow', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/header', () => ({
|
||||
default: (props: HeaderProps) => {
|
||||
const CustomRunMode = props.normal?.runAndHistoryProps?.components?.RunMode
|
||||
|
||||
return (
|
||||
<div data-testid="workflow-header">
|
||||
{props.normal?.components?.left}
|
||||
{CustomRunMode && <CustomRunMode text={props.normal?.runAndHistoryProps?.runButtonText} />}
|
||||
{props.normal?.components?.middle}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/context', () => ({
|
||||
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-context-provider">{children}</div>
|
||||
|
||||
@ -8,6 +8,7 @@ import SnippetHeader from './snippet-header'
|
||||
import SnippetWorkflowPanel from './workflow-panel'
|
||||
|
||||
type SnippetChildrenProps = {
|
||||
snippetId: string
|
||||
fields: SnippetInputField[]
|
||||
uiMeta: SnippetDetailUIModel
|
||||
editingField: SnippetInputField | null
|
||||
@ -26,6 +27,7 @@ type SnippetChildrenProps = {
|
||||
}
|
||||
|
||||
const SnippetChildren = ({
|
||||
snippetId,
|
||||
fields,
|
||||
uiMeta,
|
||||
editingField,
|
||||
@ -47,6 +49,7 @@ const SnippetChildren = ({
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background-body to-transparent" />
|
||||
|
||||
<SnippetHeader
|
||||
snippetId={snippetId}
|
||||
inputFieldCount={fields.length}
|
||||
onToggleInputPanel={onToggleInputPanel}
|
||||
onTogglePublishMenu={onTogglePublishMenu}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
import type { HeaderProps } from '@/app/components/workflow/header'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SnippetHeader from '..'
|
||||
|
||||
vi.mock('@/app/components/workflow/header', () => ({
|
||||
default: (props: HeaderProps) => {
|
||||
const CustomRunMode = props.normal?.runAndHistoryProps?.components?.RunMode
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="workflow-header"
|
||||
data-show-env={String(props.normal?.controls?.showEnvButton ?? true)}
|
||||
data-show-global-variable={String(props.normal?.controls?.showGlobalVariableButton ?? true)}
|
||||
data-history-url={props.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl ?? ''}
|
||||
>
|
||||
{props.normal?.components?.left}
|
||||
{CustomRunMode && <CustomRunMode text={props.normal?.runAndHistoryProps?.runButtonText} />}
|
||||
{props.normal?.components?.middle}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('SnippetHeader', () => {
|
||||
const mockToggleInputPanel = vi.fn()
|
||||
const mockTogglePublishMenu = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verifies the wrapper passes the expected workflow header configuration.
|
||||
describe('Rendering', () => {
|
||||
it('should configure workflow header slots and hide workflow-only controls', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
inputFieldCount={3}
|
||||
onToggleInputPanel={mockToggleInputPanel}
|
||||
onTogglePublishMenu={mockTogglePublishMenu}
|
||||
/>,
|
||||
)
|
||||
|
||||
const header = screen.getByTestId('workflow-header')
|
||||
expect(header).toHaveAttribute('data-show-env', 'false')
|
||||
expect(header).toHaveAttribute('data-show-global-variable', 'false')
|
||||
expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs')
|
||||
expect(screen.getByRole('button', { name: /snippet\.inputFieldButton/i })).toHaveTextContent('3')
|
||||
expect(screen.getByRole('button', { name: /snippet\.publishButton/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /snippet\.testRunButton/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies forwarded callbacks still drive the snippet-specific controls.
|
||||
describe('User Interactions', () => {
|
||||
it('should invoke the snippet callbacks when input and publish buttons are clicked', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
inputFieldCount={1}
|
||||
onToggleInputPanel={mockToggleInputPanel}
|
||||
onTogglePublishMenu={mockTogglePublishMenu}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /snippet\.inputFieldButton/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
|
||||
|
||||
expect(mockToggleInputPanel).toHaveBeenCalledTimes(1)
|
||||
expect(mockTogglePublishMenu).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,61 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { HeaderProps } from '@/app/components/workflow/header'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import Header from '@/app/components/workflow/header'
|
||||
import InputFieldButton from './input-field-button'
|
||||
import Publisher from './publisher'
|
||||
import RunMode from './run-mode'
|
||||
|
||||
type SnippetHeaderProps = {
|
||||
snippetId: string
|
||||
inputFieldCount: number
|
||||
onToggleInputPanel: () => void
|
||||
onTogglePublishMenu: () => void
|
||||
}
|
||||
|
||||
const SnippetHeader = ({
|
||||
snippetId,
|
||||
inputFieldCount,
|
||||
onToggleInputPanel,
|
||||
onTogglePublishMenu,
|
||||
}: SnippetHeaderProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const viewHistoryProps = useMemo(() => {
|
||||
return {
|
||||
historyUrl: `/snippets/${snippetId}/workflow-runs`,
|
||||
}
|
||||
}, [snippetId])
|
||||
|
||||
return (
|
||||
<div className="absolute right-3 top-3 z-20 flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-secondary shadow-xs backdrop-blur"
|
||||
onClick={onToggleInputPanel}
|
||||
>
|
||||
<span className="text-[13px] font-medium leading-4">{t('inputFieldButton')}</span>
|
||||
<span className="rounded-md border border-divider-deep px-1.5 py-0.5 text-[10px] font-medium leading-3 text-text-tertiary">
|
||||
{inputFieldCount}
|
||||
</span>
|
||||
</button>
|
||||
const headerProps: HeaderProps = useMemo(() => {
|
||||
return {
|
||||
normal: {
|
||||
components: {
|
||||
left: <InputFieldButton count={inputFieldCount} onClick={onToggleInputPanel} />,
|
||||
middle: <Publisher onClick={onTogglePublishMenu} />,
|
||||
},
|
||||
controls: {
|
||||
showEnvButton: false,
|
||||
showGlobalVariableButton: false,
|
||||
},
|
||||
runAndHistoryProps: {
|
||||
showRunButton: true,
|
||||
viewHistoryProps,
|
||||
components: {
|
||||
RunMode,
|
||||
},
|
||||
},
|
||||
},
|
||||
viewHistory: {
|
||||
viewHistoryProps,
|
||||
},
|
||||
}
|
||||
}, [inputFieldCount, onToggleInputPanel, onTogglePublishMenu, viewHistoryProps])
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-accent shadow-xs backdrop-blur"
|
||||
>
|
||||
<span aria-hidden className="i-ri-play-mini-fill h-4 w-4" />
|
||||
<span className="text-[13px] font-medium leading-4">{t('testRunButton')}</span>
|
||||
<span className="rounded-md bg-state-accent-active px-1.5 py-0.5 text-[10px] font-semibold leading-3 text-text-accent">R</span>
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-lg bg-components-button-primary-bg px-3 py-2 text-white shadow-[0px_2px_2px_-1px_rgba(0,0,0,0.12),0px_1px_1px_-1px_rgba(0,0,0,0.12),0px_0px_0px_0.5px_rgba(9,9,11,0.05)]"
|
||||
onClick={onTogglePublishMenu}
|
||||
>
|
||||
<span className="text-[13px] font-medium leading-4">{t('publishButton')}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg text-text-tertiary shadow-xs"
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-2-line h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
return <Header {...headerProps} />
|
||||
}
|
||||
|
||||
export default SnippetHeader
|
||||
export default memo(SnippetHeader)
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type InputFieldButtonProps = {
|
||||
count: number
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const InputFieldButton = ({
|
||||
count,
|
||||
onClick,
|
||||
}: InputFieldButtonProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-secondary shadow-xs backdrop-blur"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text-[13px] font-medium leading-4">{t('inputFieldButton')}</span>
|
||||
<span className="rounded-md border border-divider-deep px-1.5 py-0.5 text-[10px] font-medium leading-3 text-text-tertiary">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(InputFieldButton)
|
||||
@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type PublisherProps = {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const Publisher = ({
|
||||
onClick,
|
||||
}: PublisherProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-lg bg-components-button-primary-bg px-3 py-2 text-white shadow-[0px_2px_2px_-1px_rgba(0,0,0,0.12),0px_1px_1px_-1px_rgba(0,0,0,0.12),0px_0px_0px_0.5px_rgba(9,9,11,0.05)]"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text-[13px] font-medium leading-4">{t('publishButton')}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Publisher)
|
||||
@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { RiPlayLargeLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type RunModeProps = {
|
||||
text?: string
|
||||
}
|
||||
|
||||
const RunMode = ({
|
||||
text,
|
||||
}: RunModeProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 items-center gap-1 rounded-md px-1.5 text-[13px] font-medium text-text-accent hover:bg-state-accent-hover"
|
||||
>
|
||||
<RiPlayLargeLine className="h-4 w-4" />
|
||||
<span>{text ?? t('testRunButton')}</span>
|
||||
<span className="rounded-md bg-state-accent-active px-1.5 py-0.5 text-[10px] font-semibold leading-3 text-text-accent">R</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(RunMode)
|
||||
@ -191,6 +191,7 @@ const SnippetMain = ({
|
||||
hooksStore={hooksStore}
|
||||
>
|
||||
<SnippetChildren
|
||||
snippetId={snippetId}
|
||||
fields={fields}
|
||||
uiMeta={uiMeta}
|
||||
editingField={editingField}
|
||||
|
||||
@ -211,6 +211,20 @@ describe('Header layout components', () => {
|
||||
expect(store.getState().showChatVariablePanel).toBe(false)
|
||||
expect(store.getState().showGlobalVariablePanel).toBe(false)
|
||||
})
|
||||
|
||||
it('should hide env and global variable buttons when the controls are disabled', () => {
|
||||
renderWorkflowComponent(
|
||||
<HeaderInNormal
|
||||
controls={{
|
||||
showEnvButton: false,
|
||||
showGlobalVariableButton: false,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('env-button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('global-variable-button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeaderInRestoring', () => {
|
||||
|
||||
@ -28,10 +28,15 @@ export type HeaderInNormalProps = {
|
||||
middle?: React.ReactNode
|
||||
chatVariableTrigger?: React.ReactNode
|
||||
}
|
||||
controls?: {
|
||||
showEnvButton?: boolean
|
||||
showGlobalVariableButton?: boolean
|
||||
}
|
||||
runAndHistoryProps?: RunAndHistoryProps
|
||||
}
|
||||
const HeaderInNormal = ({
|
||||
components,
|
||||
controls,
|
||||
runAndHistoryProps,
|
||||
}: HeaderInNormalProps) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
@ -47,6 +52,9 @@ const HeaderInNormal = ({
|
||||
const selectedNode = nodes.find(node => node.data.selected)
|
||||
const { handleBackupDraft } = useWorkflowRun()
|
||||
const { closeAllInputFieldPanels } = useInputFieldPanel()
|
||||
const showEnvButton = controls?.showEnvButton !== false
|
||||
const showGlobalVariableButton = controls?.showGlobalVariableButton !== false
|
||||
const showContextButtons = !!components?.chatVariableTrigger || showEnvButton || showGlobalVariableButton
|
||||
|
||||
const onStartRestoring = useCallback(() => {
|
||||
workflowStore.setState({ isRestoring: true })
|
||||
@ -75,11 +83,13 @@ const HeaderInNormal = ({
|
||||
{components?.left}
|
||||
<Divider type="vertical" className="mx-auto h-3.5" />
|
||||
<RunAndHistory {...runAndHistoryProps} />
|
||||
<div className="shrink-0 cursor-pointer rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs backdrop-blur-[10px]">
|
||||
{components?.chatVariableTrigger}
|
||||
<EnvButton disabled={nodesReadOnly} />
|
||||
<GlobalVariableButton disabled={nodesReadOnly} />
|
||||
</div>
|
||||
{showContextButtons && (
|
||||
<div className="shrink-0 cursor-pointer rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs backdrop-blur-[10px]">
|
||||
{components?.chatVariableTrigger}
|
||||
{showEnvButton && <EnvButton disabled={nodesReadOnly} />}
|
||||
{showGlobalVariableButton && <GlobalVariableButton disabled={nodesReadOnly} />}
|
||||
</div>
|
||||
)}
|
||||
{components?.middle}
|
||||
<VersionHistoryButton onClick={onStartRestoring} />
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user