refactor(web): snippet main

This commit is contained in:
JzoNg
2026-03-29 17:50:30 +08:00
parent 0ad268aa7d
commit 9636472db7
5 changed files with 463 additions and 96 deletions

View File

@@ -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> = {}): 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)
})
})
})

View File

@@ -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<typeof mockUseKeyPress>) => 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()
})
})
})

View File

@@ -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<SnippetInputField[]>(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,
}
}

View File

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

View File

@@ -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<SnippetInputField[]>(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}