feat(web): snippet header in graph

This commit is contained in:
JzoNg 2026-03-29 15:02:34 +08:00
parent a6e9316de3
commit c41ba7d627
10 changed files with 249 additions and 48 deletions

View File

@ -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>

View File

@ -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}

View File

@ -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)
})
})
})

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -191,6 +191,7 @@ const SnippetMain = ({
hooksStore={hooksStore}
>
<SnippetChildren
snippetId={snippetId}
fields={fields}
uiMeta={uiMeta}
editingField={editingField}

View File

@ -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', () => {

View File

@ -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>