diff --git a/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts b/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts new file mode 100644 index 00000000000..ae9819e4644 --- /dev/null +++ b/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts @@ -0,0 +1,160 @@ +import type { SnippetInputField } from '@/models/snippet' +import { act, renderHook } from '@testing-library/react' +import { toast } from '@/app/components/base/ui/toast' +import { PipelineInputVarType } from '@/models/pipeline' +import { useSnippetInputFieldActions } from '../use-snippet-input-field-actions' + +const mockSyncInputFieldsDraft = vi.fn() +const mockCloseEditor = vi.fn() +const mockOpenEditor = vi.fn() +const mockSetInputPanelOpen = vi.fn() +const mockToggleInputPanel = vi.fn() + +let snippetDetailStoreState: { + editingField: SnippetInputField | null + isEditorOpen: boolean + isInputPanelOpen: boolean + closeEditor: typeof mockCloseEditor + openEditor: typeof mockOpenEditor + setInputPanelOpen: typeof mockSetInputPanelOpen + toggleInputPanel: typeof mockToggleInputPanel +} + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: vi.fn(), + }, +})) + +vi.mock('../../../hooks/use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + syncInputFieldsDraft: mockSyncInputFieldsDraft, + }), +})) + +vi.mock('../../../store', () => ({ + useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState), +})) + +const createField = (overrides: Partial = {}): SnippetInputField => ({ + type: PipelineInputVarType.textInput, + label: 'Blog URL', + variable: 'blog_url', + required: true, + ...overrides, +}) + +describe('useSnippetInputFieldActions', () => { + beforeEach(() => { + vi.clearAllMocks() + snippetDetailStoreState = { + editingField: null, + isEditorOpen: false, + isInputPanelOpen: true, + closeEditor: mockCloseEditor, + openEditor: mockOpenEditor, + setInputPanelOpen: mockSetInputPanelOpen, + toggleInputPanel: mockToggleInputPanel, + } + mockSyncInputFieldsDraft.mockResolvedValue(undefined) + }) + + describe('Field sync', () => { + it('should remove a field and sync the draft', () => { + const { result } = renderHook(() => useSnippetInputFieldActions({ + snippetId: 'snippet-1', + initialFields: [createField()], + })) + + act(() => { + result.current.handleRemoveField(0) + }) + + expect(result.current.fields).toEqual([]) + expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], { + onRefresh: expect.any(Function), + }) + }) + + it('should append a new field and close the editor after syncing', () => { + const { result } = renderHook(() => useSnippetInputFieldActions({ + snippetId: 'snippet-1', + initialFields: [createField()], + })) + + act(() => { + result.current.handleSubmitField(createField({ + label: 'Topic', + variable: 'topic', + })) + }) + + expect(result.current.fields).toEqual([ + createField(), + createField({ + label: 'Topic', + variable: 'topic', + }), + ]) + expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([ + createField(), + createField({ + label: 'Topic', + variable: 'topic', + }), + ], { + onRefresh: expect.any(Function), + }) + expect(mockCloseEditor).toHaveBeenCalledTimes(1) + }) + + it('should reject duplicated variables without syncing', () => { + const { result } = renderHook(() => useSnippetInputFieldActions({ + snippetId: 'snippet-1', + initialFields: [createField()], + })) + + act(() => { + result.current.handleSubmitField(createField({ + label: 'Duplicated', + variable: 'blog_url', + })) + }) + + expect(toast.error).toHaveBeenCalledWith('datasetPipeline.inputFieldPanel.error.variableDuplicate') + expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled() + expect(mockCloseEditor).not.toHaveBeenCalled() + expect(result.current.fields).toEqual([createField()]) + }) + }) + + describe('Panel actions', () => { + it('should close the editor before toggling the input panel when the panel is open', () => { + const { result } = renderHook(() => useSnippetInputFieldActions({ + snippetId: 'snippet-1', + initialFields: [createField()], + })) + + act(() => { + result.current.handleToggleInputPanel() + }) + + expect(mockCloseEditor).toHaveBeenCalledTimes(1) + expect(mockToggleInputPanel).toHaveBeenCalledTimes(1) + }) + + it('should close the input panel and clear the editor state', () => { + const { result } = renderHook(() => useSnippetInputFieldActions({ + snippetId: 'snippet-1', + initialFields: [createField()], + })) + + act(() => { + result.current.handleCloseInputPanel() + }) + + expect(mockCloseEditor).toHaveBeenCalledTimes(1) + expect(mockSetInputPanelOpen).toHaveBeenCalledWith(false) + }) + }) +}) diff --git a/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts b/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts new file mode 100644 index 00000000000..8ffe98182e9 --- /dev/null +++ b/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts @@ -0,0 +1,127 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { toast } from '@/app/components/base/ui/toast' +import { useSnippetPublish } from '../use-snippet-publish' + +const mockMutateAsync = vi.fn() +const mockSetPublishMenuOpen = vi.fn() +const mockUseKeyPress = vi.fn() + +let isPublishMenuOpen = false +let isPending = false +let shortcutHandler: ((event: KeyboardEvent) => void) | undefined + +vi.mock('ahooks', () => ({ + useKeyPress: (...args: Parameters) => mockUseKeyPress(...args), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})) + +vi.mock('@/service/use-snippet-workflows', () => ({ + usePublishSnippetWorkflowMutation: () => ({ + mutateAsync: mockMutateAsync, + isPending, + }), +})) + +vi.mock('../../../store', () => ({ + useSnippetDetailStore: (selector: (state: { + isPublishMenuOpen: boolean + setPublishMenuOpen: typeof mockSetPublishMenuOpen + }) => unknown) => selector({ + isPublishMenuOpen, + setPublishMenuOpen: mockSetPublishMenuOpen, + }), +})) + +describe('useSnippetPublish', () => { + beforeEach(() => { + vi.clearAllMocks() + isPublishMenuOpen = false + isPending = false + shortcutHandler = undefined + mockMutateAsync.mockResolvedValue(undefined) + mockUseKeyPress.mockImplementation((_key, handler) => { + shortcutHandler = handler + }) + }) + + describe('Publish action', () => { + it('should publish the snippet, close the menu, and show success feedback', async () => { + const { result } = renderHook(() => useSnippetPublish({ + snippetId: 'snippet-1', + section: 'orchestrate', + })) + + await act(async () => { + await result.current.handlePublish() + }) + + expect(mockMutateAsync).toHaveBeenCalledWith({ + params: { snippetId: 'snippet-1' }, + }) + expect(mockSetPublishMenuOpen).toHaveBeenCalledWith(false) + expect(toast.success).toHaveBeenCalledWith('snippet.publishSuccess') + }) + + it('should surface publish errors through toast feedback', async () => { + mockMutateAsync.mockRejectedValue(new Error('publish failed')) + + const { result } = renderHook(() => useSnippetPublish({ + snippetId: 'snippet-1', + section: 'orchestrate', + })) + + await act(async () => { + await result.current.handlePublish() + }) + + expect(toast.error).toHaveBeenCalledWith('publish failed') + expect(mockSetPublishMenuOpen).not.toHaveBeenCalled() + }) + }) + + describe('Keyboard shortcut', () => { + it('should trigger publish on ctrl+shift+p in the orchestrate section', async () => { + renderHook(() => useSnippetPublish({ + snippetId: 'snippet-1', + section: 'orchestrate', + })) + + const event = new KeyboardEvent('keydown') + const preventDefault = vi.spyOn(event, 'preventDefault') + + act(() => { + shortcutHandler?.(event) + }) + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + params: { snippetId: 'snippet-1' }, + }) + }) + expect(preventDefault).toHaveBeenCalledTimes(1) + }) + + it('should ignore the shortcut outside the orchestrate section', () => { + renderHook(() => useSnippetPublish({ + snippetId: 'snippet-1', + section: 'evaluation', + })) + + const event = new KeyboardEvent('keydown') + const preventDefault = vi.spyOn(event, 'preventDefault') + + act(() => { + shortcutHandler?.(event) + }) + + expect(mockMutateAsync).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts b/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts new file mode 100644 index 00000000000..738c9cc6461 --- /dev/null +++ b/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts @@ -0,0 +1,95 @@ +import type { SnippetInputField } from '@/models/snippet' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useShallow } from 'zustand/react/shallow' +import { toast } from '@/app/components/base/ui/toast' +import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft' +import { useSnippetDetailStore } from '../../store' + +type UseSnippetInputFieldActionsOptions = { + snippetId: string + initialFields: SnippetInputField[] +} + +export const useSnippetInputFieldActions = ({ + snippetId, + initialFields, +}: UseSnippetInputFieldActionsOptions) => { + const { t } = useTranslation('snippet') + const [fields, setFields] = useState(initialFields) + const { syncInputFieldsDraft } = useNodesSyncDraft(snippetId) + const { + editingField, + isEditorOpen, + isInputPanelOpen, + closeEditor, + openEditor, + setInputPanelOpen, + toggleInputPanel, + } = useSnippetDetailStore(useShallow(state => ({ + editingField: state.editingField, + isEditorOpen: state.isEditorOpen, + isInputPanelOpen: state.isInputPanelOpen, + closeEditor: state.closeEditor, + openEditor: state.openEditor, + setInputPanelOpen: state.setInputPanelOpen, + toggleInputPanel: state.toggleInputPanel, + }))) + + const handleSortChange = useCallback((newFields: SnippetInputField[]) => { + setFields(newFields) + }, []) + + const handleRemoveField = useCallback((index: number) => { + const nextFields = fields.filter((_, currentIndex) => currentIndex !== index) + setFields(nextFields) + void syncInputFieldsDraft(nextFields, { + onRefresh: setFields, + }) + }, [fields, syncInputFieldsDraft]) + + const handleSubmitField = useCallback((field: SnippetInputField) => { + const originalVariable = editingField?.variable + const duplicated = fields.some(item => item.variable === field.variable && item.variable !== originalVariable) + + if (duplicated) { + toast.error(t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' })) + return + } + + const nextFields = originalVariable + ? fields.map(item => item.variable === originalVariable ? field : item) + : [...fields, field] + + setFields(nextFields) + void syncInputFieldsDraft(nextFields, { + onRefresh: setFields, + }) + closeEditor() + }, [closeEditor, editingField?.variable, fields, syncInputFieldsDraft, t]) + + const handleToggleInputPanel = useCallback(() => { + if (isInputPanelOpen) + closeEditor() + toggleInputPanel() + }, [closeEditor, isInputPanelOpen, toggleInputPanel]) + + const handleCloseInputPanel = useCallback(() => { + closeEditor() + setInputPanelOpen(false) + }, [closeEditor, setInputPanelOpen]) + + return { + editingField, + fields, + isEditorOpen, + isInputPanelOpen, + openEditor, + closeEditor, + handleCloseInputPanel, + handleRemoveField, + handleSortChange, + handleSubmitField, + handleToggleInputPanel, + } +} diff --git a/web/app/components/snippets/components/hooks/use-snippet-publish.ts b/web/app/components/snippets/components/hooks/use-snippet-publish.ts new file mode 100644 index 00000000000..1dd0612e140 --- /dev/null +++ b/web/app/components/snippets/components/hooks/use-snippet-publish.ts @@ -0,0 +1,57 @@ +import type { SnippetSection } from '@/models/snippet' +import { useKeyPress } from 'ahooks' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useShallow } from 'zustand/react/shallow' +import { toast } from '@/app/components/base/ui/toast' +import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' +import { usePublishSnippetWorkflowMutation } from '@/service/use-snippet-workflows' +import { useSnippetDetailStore } from '../../store' + +type UseSnippetPublishOptions = { + snippetId: string + section: SnippetSection +} + +export const useSnippetPublish = ({ + snippetId, + section, +}: UseSnippetPublishOptions) => { + const { t } = useTranslation('snippet') + const publishSnippetMutation = usePublishSnippetWorkflowMutation(snippetId) + const { + isPublishMenuOpen, + setPublishMenuOpen, + } = useSnippetDetailStore(useShallow(state => ({ + isPublishMenuOpen: state.isPublishMenuOpen, + setPublishMenuOpen: state.setPublishMenuOpen, + }))) + + const handlePublish = useCallback(async () => { + try { + await publishSnippetMutation.mutateAsync({ + params: { snippetId }, + }) + setPublishMenuOpen(false) + toast.success(t('publishSuccess')) + } + catch (error) { + toast.error(error instanceof Error ? error.message : t('publishFailed')) + } + }, [publishSnippetMutation, setPublishMenuOpen, snippetId, t]) + + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (event) => { + if (section !== 'orchestrate' || publishSnippetMutation.isPending) + return + + event.preventDefault() + void handlePublish() + }, { exactMatch: true, useCapture: true }) + + return { + handlePublish, + isPublishMenuOpen, + isPublishing: publishSnippetMutation.isPending, + setPublishMenuOpen, + } +} diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index b57fed1342d..ed01d27c0b8 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -2,7 +2,7 @@ import type { NavIcon } from '@/app/components/app-sidebar/nav-link' import type { WorkflowProps } from '@/app/components/workflow' -import type { SnippetDetailPayload, SnippetInputField, SnippetSection } from '@/models/snippet' +import type { SnippetDetailPayload, SnippetSection } from '@/models/snippet' import { RiFlaskFill, RiFlaskLine, @@ -10,32 +10,25 @@ import { RiTerminalWindowLine, } from '@remixicon/react' import { - useKeyPress, -} from 'ahooks' -import { - useCallback, useEffect, useMemo, - useState, } from 'react' import { useTranslation } from 'react-i18next' -import { useShallow } from 'zustand/react/shallow' import AppSideBar from '@/app/components/app-sidebar' import NavLink from '@/app/components/app-sidebar/nav-link' import SnippetInfo from '@/app/components/app-sidebar/snippet-info' import { useStore as useAppStore } from '@/app/components/app/store' -import { toast } from '@/app/components/base/ui/toast' import Evaluation from '@/app/components/evaluation' import { WorkflowWithInnerContext } from '@/app/components/workflow' import { useAvailableNodesMetaData } from '@/app/components/workflow-app/hooks' import { BlockEnum } from '@/app/components/workflow/types' -import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -import { usePublishSnippetWorkflowMutation } from '@/service/use-snippet-workflows' import { useConfigsMap } from '../hooks/use-configs-map' import { useNodesSyncDraft } from '../hooks/use-nodes-sync-draft' import { useSnippetRefreshDraft } from '../hooks/use-snippet-refresh-draft' import { useSnippetDetailStore } from '../store' +import { useSnippetInputFieldActions } from './hooks/use-snippet-input-field-actions' +import { useSnippetPublish } from './hooks/use-snippet-publish' import SnippetChildren from './snippet-children' type SnippetMainProps = { @@ -66,11 +59,8 @@ const SnippetMain = ({ const { graph, snippet, uiMeta } = payload const media = useBreakpoints() const isMobile = media === MediaType.mobile - const [fields, setFields] = useState(payload.inputFields) - const publishSnippetMutation = usePublishSnippetWorkflowMutation(snippetId) const { doSyncWorkflowDraft, - syncInputFieldsDraft, syncWorkflowDraftWhenPageClose, } = useNodesSyncDraft(snippetId) const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId) @@ -95,29 +85,32 @@ const SnippetMain = ({ } }, [workflowAvailableNodesMetaData]) const setAppSidebarExpand = useAppStore(state => state.setAppSidebarExpand) + const reset = useSnippetDetailStore(state => state.reset) const { editingField, + fields, isEditorOpen, isInputPanelOpen, - isPublishMenuOpen, - closeEditor, openEditor, - reset, - setInputPanelOpen, + closeEditor, + handleCloseInputPanel, + handleRemoveField, + handleSortChange, + handleSubmitField, + handleToggleInputPanel, + } = useSnippetInputFieldActions({ + snippetId, + initialFields: payload.inputFields, + }) + const { + handlePublish, + isPublishMenuOpen, + isPublishing, setPublishMenuOpen, - toggleInputPanel, - } = useSnippetDetailStore(useShallow(state => ({ - editingField: state.editingField, - isEditorOpen: state.isEditorOpen, - isInputPanelOpen: state.isInputPanelOpen, - isPublishMenuOpen: state.isPublishMenuOpen, - closeEditor: state.closeEditor, - openEditor: state.openEditor, - reset: state.reset, - setInputPanelOpen: state.setInputPanelOpen, - setPublishMenuOpen: state.setPublishMenuOpen, - toggleInputPanel: state.toggleInputPanel, - }))) + } = useSnippetPublish({ + snippetId, + section, + }) useEffect(() => { reset() @@ -129,71 +122,6 @@ const SnippetMain = ({ setAppSidebarExpand(isMobile ? mode : localeMode) }, [isMobile, setAppSidebarExpand]) - const handleSortChange = (newFields: SnippetInputField[]) => { - setFields(newFields) - } - - const handleRemoveField = (index: number) => { - const nextFields = fields.filter((_, currentIndex) => currentIndex !== index) - setFields(nextFields) - void syncInputFieldsDraft(nextFields, { - onRefresh: setFields, - }) - } - - const handleSubmitField = (field: SnippetInputField) => { - const originalVariable = editingField?.variable - const duplicated = fields.some(item => item.variable === field.variable && item.variable !== originalVariable) - - if (duplicated) { - toast.error(t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' })) - return - } - - const nextFields = originalVariable - ? fields.map(item => item.variable === originalVariable ? field : item) - : [...fields, field] - - setFields(nextFields) - void syncInputFieldsDraft(nextFields, { - onRefresh: setFields, - }) - - closeEditor() - } - - const handleToggleInputPanel = () => { - if (isInputPanelOpen) - closeEditor() - toggleInputPanel() - } - - const handleCloseInputPanel = () => { - closeEditor() - setInputPanelOpen(false) - } - - const handlePublish = useCallback(async () => { - try { - await publishSnippetMutation.mutateAsync({ - params: { snippetId }, - }) - setPublishMenuOpen(false) - toast.success(t('publishSuccess')) - } - catch (error) { - toast.error(error instanceof Error ? error.message : t('publishFailed')) - } - }, [publishSnippetMutation, setPublishMenuOpen, snippetId, t]) - - useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { - if (section !== 'orchestrate' || publishSnippetMutation.isPending) - return - - e.preventDefault() - void handlePublish() - }, { exactMatch: true, useCapture: true }) - const hooksStore = useMemo(() => { return { doSyncWorkflowDraft, @@ -250,7 +178,7 @@ const SnippetMain = ({ isEditorOpen={isEditorOpen} isInputPanelOpen={isInputPanelOpen} isPublishMenuOpen={isPublishMenuOpen} - isPublishing={publishSnippetMutation.isPending} + isPublishing={isPublishing} onToggleInputPanel={handleToggleInputPanel} onPublishMenuOpenChange={setPublishMenuOpen} onCloseInputPanel={handleCloseInputPanel}