From c41ba7d6277632256069347ea62537b8646fbc03 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Sun, 29 Mar 2026 15:02:34 +0800 Subject: [PATCH] feat(web): snippet header in graph --- .../snippets/__tests__/index.spec.tsx | 15 ++++ .../snippets/components/snippet-children.tsx | 3 + .../snippet-header/__tests__/index.spec.tsx | 73 ++++++++++++++++ .../components/snippet-header/index.tsx | 85 +++++++++---------- .../snippet-header/input-field-button.tsx | 31 +++++++ .../components/snippet-header/publisher.tsx | 27 ++++++ .../components/snippet-header/run-mode.tsx | 28 ++++++ .../snippets/components/snippet-main.tsx | 1 + .../header/__tests__/header-layouts.spec.tsx | 14 +++ .../workflow/header/header-in-normal.tsx | 20 +++-- 10 files changed, 249 insertions(+), 48 deletions(-) create mode 100644 web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx create mode 100644 web/app/components/snippets/components/snippet-header/input-field-button.tsx create mode 100644 web/app/components/snippets/components/snippet-header/publisher.tsx create mode 100644 web/app/components/snippets/components/snippet-header/run-mode.tsx diff --git a/web/app/components/snippets/__tests__/index.spec.tsx b/web/app/components/snippets/__tests__/index.spec.tsx index 65f2c8971c..e0a2777f2a 100644 --- a/web/app/components/snippets/__tests__/index.spec.tsx +++ b/web/app/components/snippets/__tests__/index.spec.tsx @@ -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 ( +
+ {props.normal?.components?.left} + {CustomRunMode && } + {props.normal?.components?.middle} +
+ ) + }, +})) + vi.mock('@/app/components/workflow/context', () => ({ WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
{children}
diff --git a/web/app/components/snippets/components/snippet-children.tsx b/web/app/components/snippets/components/snippet-children.tsx index 0bf08530a1..1c3728290f 100644 --- a/web/app/components/snippets/components/snippet-children.tsx +++ b/web/app/components/snippets/components/snippet-children.tsx @@ -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 = ({
({ + default: (props: HeaderProps) => { + const CustomRunMode = props.normal?.runAndHistoryProps?.components?.RunMode + + return ( +
+ {props.normal?.components?.left} + {CustomRunMode && } + {props.normal?.components?.middle} +
+ ) + }, +})) + +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( + , + ) + + 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( + , + ) + + 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) + }) + }) +}) diff --git a/web/app/components/snippets/components/snippet-header/index.tsx b/web/app/components/snippets/components/snippet-header/index.tsx index aba4efadbd..d25d9a5d17 100644 --- a/web/app/components/snippets/components/snippet-header/index.tsx +++ b/web/app/components/snippets/components/snippet-header/index.tsx @@ -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 ( -
- + const headerProps: HeaderProps = useMemo(() => { + return { + normal: { + components: { + left: , + middle: , + }, + controls: { + showEnvButton: false, + showGlobalVariableButton: false, + }, + runAndHistoryProps: { + showRunButton: true, + viewHistoryProps, + components: { + RunMode, + }, + }, + }, + viewHistory: { + viewHistoryProps, + }, + } + }, [inputFieldCount, onToggleInputPanel, onTogglePublishMenu, viewHistoryProps]) - - -
- -
- - -
- ) + return
} -export default SnippetHeader +export default memo(SnippetHeader) diff --git a/web/app/components/snippets/components/snippet-header/input-field-button.tsx b/web/app/components/snippets/components/snippet-header/input-field-button.tsx new file mode 100644 index 0000000000..1bcecf5668 --- /dev/null +++ b/web/app/components/snippets/components/snippet-header/input-field-button.tsx @@ -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 ( + + ) +} + +export default memo(InputFieldButton) diff --git a/web/app/components/snippets/components/snippet-header/publisher.tsx b/web/app/components/snippets/components/snippet-header/publisher.tsx new file mode 100644 index 0000000000..0d4cf02611 --- /dev/null +++ b/web/app/components/snippets/components/snippet-header/publisher.tsx @@ -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 ( + + ) +} + +export default memo(Publisher) diff --git a/web/app/components/snippets/components/snippet-header/run-mode.tsx b/web/app/components/snippets/components/snippet-header/run-mode.tsx new file mode 100644 index 0000000000..d18df6f23a --- /dev/null +++ b/web/app/components/snippets/components/snippet-header/run-mode.tsx @@ -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 ( + + ) +} + +export default React.memo(RunMode) diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index 85b8d9bf06..a5441b6df8 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -191,6 +191,7 @@ const SnippetMain = ({ hooksStore={hooksStore} > { 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( + , + ) + + expect(screen.queryByTestId('env-button')).not.toBeInTheDocument() + expect(screen.queryByTestId('global-variable-button')).not.toBeInTheDocument() + }) }) describe('HeaderInRestoring', () => { diff --git a/web/app/components/workflow/header/header-in-normal.tsx b/web/app/components/workflow/header/header-in-normal.tsx index 52ffee4ed5..2b050e6a57 100644 --- a/web/app/components/workflow/header/header-in-normal.tsx +++ b/web/app/components/workflow/header/header-in-normal.tsx @@ -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} -
- {components?.chatVariableTrigger} - - -
+ {showContextButtons && ( +
+ {components?.chatVariableTrigger} + {showEnvButton && } + {showGlobalVariableButton && } +
+ )} {components?.middle}