mirror of
https://github.com/langgenius/dify.git
synced 2026-05-12 07:37:09 +08:00
feat(web): input fields in snippet
This commit is contained in:
parent
1e76ef5ccb
commit
0f13aabea8
@ -22,6 +22,7 @@ vi.mock('../hooks/use-configs-map', () => ({
|
||||
vi.mock('../hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: vi.fn(),
|
||||
syncInputFieldsDraft: vi.fn(),
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -0,0 +1,216 @@
|
||||
import type { WorkflowProps } from '@/app/components/workflow'
|
||||
import type { SnippetDetailPayload, SnippetInputField, SnippetSection } from '@/models/snippet'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import SnippetMain from '../snippet-main'
|
||||
|
||||
const mockSetAppSidebarExpand = vi.fn()
|
||||
const mockSyncInputFieldsDraft = vi.fn()
|
||||
const mockCloseEditor = vi.fn()
|
||||
const mockOpenEditor = vi.fn()
|
||||
const mockReset = vi.fn()
|
||||
const mockSetInputPanelOpen = vi.fn()
|
||||
const mockToggleInputPanel = vi.fn()
|
||||
const mockTogglePublishMenu = vi.fn()
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => 'desktop',
|
||||
MediaType: { mobile: 'mobile', desktop: 'desktop' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
|
||||
setAppSidebarExpand: mockSetAppSidebarExpand,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/store', () => ({
|
||||
useSnippetDetailStore: (selector: (state: {
|
||||
editingField: SnippetInputField | null
|
||||
isEditorOpen: boolean
|
||||
isInputPanelOpen: boolean
|
||||
isPublishMenuOpen: boolean
|
||||
closeEditor: typeof mockCloseEditor
|
||||
openEditor: typeof mockOpenEditor
|
||||
reset: typeof mockReset
|
||||
setInputPanelOpen: typeof mockSetInputPanelOpen
|
||||
toggleInputPanel: typeof mockToggleInputPanel
|
||||
togglePublishMenu: typeof mockTogglePublishMenu
|
||||
}) => unknown) => selector({
|
||||
editingField: null,
|
||||
isEditorOpen: false,
|
||||
isInputPanelOpen: true,
|
||||
isPublishMenuOpen: false,
|
||||
closeEditor: mockCloseEditor,
|
||||
openEditor: mockOpenEditor,
|
||||
reset: mockReset,
|
||||
setInputPanelOpen: mockSetInputPanelOpen,
|
||||
toggleInputPanel: mockToggleInputPanel,
|
||||
togglePublishMenu: mockTogglePublishMenu,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-configs-map', () => ({
|
||||
useConfigsMap: () => ({
|
||||
flowId: 'snippet-1',
|
||||
flowType: 'snippet',
|
||||
fileSettings: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: vi.fn(),
|
||||
syncInputFieldsDraft: mockSyncInputFieldsDraft,
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-snippet-refresh-draft', () => ({
|
||||
useSnippetRefreshDraft: () => ({
|
||||
handleRefreshWorkflowDraft: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar', () => ({
|
||||
default: ({
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
}: {
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
}) => (
|
||||
<div data-testid="app-sidebar">
|
||||
<div>{renderHeader?.('expand')}</div>
|
||||
<div>{renderNavigation?.('expand')}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
|
||||
default: ({ name }: { name: string }) => <div>{name}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/snippet-info', () => ({
|
||||
default: () => <div data-testid="snippet-info" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/evaluation', () => ({
|
||||
default: () => <div data-testid="evaluation" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
WorkflowWithInnerContext: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-inner-context">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/components/snippet-children', () => ({
|
||||
default: ({
|
||||
onRemoveField,
|
||||
onSubmitField,
|
||||
}: {
|
||||
onRemoveField: (index: number) => void
|
||||
onSubmitField: (field: SnippetInputField) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onRemoveField(0)}>remove</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubmitField({
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'New Field',
|
||||
variable: 'new_field',
|
||||
required: true,
|
||||
})}
|
||||
>
|
||||
submit
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const payload: SnippetDetailPayload = {
|
||||
snippet: {
|
||||
id: 'snippet-1',
|
||||
name: 'Snippet',
|
||||
description: 'desc',
|
||||
author: '',
|
||||
updatedAt: '2026-03-29 10:00',
|
||||
usage: '0',
|
||||
icon: '',
|
||||
iconBackground: '',
|
||||
},
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
inputFields: [
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Blog URL',
|
||||
variable: 'blog_url',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
uiMeta: {
|
||||
inputFieldCount: 1,
|
||||
checklistCount: 0,
|
||||
autoSavedAt: '2026-03-29 10:00',
|
||||
},
|
||||
}
|
||||
|
||||
const renderSnippetMain = (section: SnippetSection = 'orchestrate') => {
|
||||
return render(
|
||||
<SnippetMain
|
||||
payload={payload}
|
||||
snippetId="snippet-1"
|
||||
section={section}
|
||||
nodes={[] as WorkflowProps['nodes']}
|
||||
edges={[] as WorkflowProps['edges']}
|
||||
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SnippetMain', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('Input Fields Sync', () => {
|
||||
it('should sync draft input_fields when removing a field from the panel', async () => {
|
||||
renderSnippetMain()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'remove' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
|
||||
onRefresh: expect.any(Function),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should sync draft input_fields when submitting a field from the editor', async () => {
|
||||
renderSnippetMain()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([
|
||||
payload.inputFields[0],
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'New Field',
|
||||
variable: 'new_field',
|
||||
required: true,
|
||||
},
|
||||
], {
|
||||
onRefresh: expect.any(Function),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -35,8 +35,7 @@ describe('SnippetWorkflowPanel', () => {
|
||||
onCloseEditor={vi.fn()}
|
||||
onSubmitField={vi.fn()}
|
||||
onRemoveField={vi.fn()}
|
||||
onPrimarySortChange={vi.fn()}
|
||||
onSecondarySortChange={vi.fn()}
|
||||
onSortChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import type { SnippetInputField } from '@/models/snippet'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import FieldListContainer from '@/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container'
|
||||
|
||||
type SnippetInputFieldPanelProps = {
|
||||
@ -14,8 +13,7 @@ type SnippetInputFieldPanelProps = {
|
||||
onAdd: () => void
|
||||
onEdit: (field: SnippetInputField) => void
|
||||
onRemove: (index: number) => void
|
||||
onPrimarySortChange: (fields: SnippetInputField[]) => void
|
||||
onSecondarySortChange: (fields: SnippetInputField[]) => void
|
||||
onSortChange: (fields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
const toInputFields = (list: SortableItem[]) => {
|
||||
@ -31,32 +29,19 @@ const SnippetInputFieldPanel = ({
|
||||
onAdd,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onPrimarySortChange,
|
||||
onSecondarySortChange,
|
||||
onSortChange,
|
||||
}: SnippetInputFieldPanelProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const primaryFields = fields.slice(0, 2)
|
||||
const secondaryFields = fields.slice(2)
|
||||
|
||||
const handlePrimaryRemove = useCallback((index: number) => {
|
||||
const handleRemove = useCallback((index: number) => {
|
||||
onRemove(index)
|
||||
}, [onRemove])
|
||||
|
||||
const handleSecondaryRemove = useCallback((index: number) => {
|
||||
onRemove(index + primaryFields.length)
|
||||
}, [onRemove, primaryFields.length])
|
||||
|
||||
const handlePrimaryEdit = useCallback((id: string) => {
|
||||
const field = primaryFields.find(item => item.variable === id)
|
||||
const handleEdit = useCallback((id: string) => {
|
||||
const field = fields.find(item => item.variable === id)
|
||||
if (field)
|
||||
onEdit(field)
|
||||
}, [onEdit, primaryFields])
|
||||
|
||||
const handleSecondaryEdit = useCallback((id: string) => {
|
||||
const field = secondaryFields.find(item => item.variable === id)
|
||||
if (field)
|
||||
onEdit(field)
|
||||
}, [onEdit, secondaryFields])
|
||||
}, [fields, onEdit])
|
||||
|
||||
return (
|
||||
<div className="mr-1 flex h-full w-[min(400px,calc(100vw-24px))] flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
@ -79,37 +64,19 @@ const SnippetInputFieldPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-2">
|
||||
<Button variant="secondary" size="small" className="w-full justify-center gap-1" onClick={onAdd}>
|
||||
<Button variant="primary" size="medium" className="gap-0.5 px-3" onClick={onAdd}>
|
||||
<span aria-hidden className="i-ri-add-line h-4 w-4" />
|
||||
{t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col overflow-y-auto">
|
||||
<div className="px-4 pb-1 pt-2 text-text-secondary system-xs-semibold-uppercase">
|
||||
{t('panelPrimaryGroup')}
|
||||
</div>
|
||||
<FieldListContainer
|
||||
className="flex flex-col gap-y-1 px-4 pb-2"
|
||||
inputFields={primaryFields}
|
||||
onListSortChange={list => onPrimarySortChange(toInputFields(list))}
|
||||
onRemoveField={handlePrimaryRemove}
|
||||
onEditField={handlePrimaryEdit}
|
||||
/>
|
||||
|
||||
<div className="px-4 py-2">
|
||||
<Divider type="horizontal" className="bg-divider-subtle" />
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-1 text-text-secondary system-xs-semibold-uppercase">
|
||||
{t('panelSecondaryGroup')}
|
||||
</div>
|
||||
<FieldListContainer
|
||||
className="flex flex-col gap-y-1 px-4 pb-4"
|
||||
inputFields={secondaryFields}
|
||||
onListSortChange={list => onSecondarySortChange(toInputFields(list))}
|
||||
onRemoveField={handleSecondaryRemove}
|
||||
onEditField={handleSecondaryEdit}
|
||||
className="flex flex-col gap-y-1 px-4 py-4"
|
||||
inputFields={fields}
|
||||
onListSortChange={list => onSortChange(toInputFields(list))}
|
||||
onRemoveField={handleRemove}
|
||||
onEditField={handleEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -22,8 +22,7 @@ type SnippetChildrenProps = {
|
||||
onCloseEditor: () => void
|
||||
onSubmitField: (field: SnippetInputField) => void
|
||||
onRemoveField: (index: number) => void
|
||||
onPrimarySortChange: (fields: SnippetInputField[]) => void
|
||||
onSecondarySortChange: (fields: SnippetInputField[]) => void
|
||||
onSortChange: (fields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
const SnippetChildren = ({
|
||||
@ -41,8 +40,7 @@ const SnippetChildren = ({
|
||||
onCloseEditor,
|
||||
onSubmitField,
|
||||
onRemoveField,
|
||||
onPrimarySortChange,
|
||||
onSecondarySortChange,
|
||||
onSortChange,
|
||||
}: SnippetChildrenProps) => {
|
||||
return (
|
||||
<>
|
||||
@ -66,8 +64,7 @@ const SnippetChildren = ({
|
||||
onCloseEditor={onCloseEditor}
|
||||
onSubmitField={onSubmitField}
|
||||
onRemoveField={onRemoveField}
|
||||
onPrimarySortChange={onPrimarySortChange}
|
||||
onSecondarySortChange={onSecondarySortChange}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
|
||||
{isPublishMenuOpen && (
|
||||
@ -85,8 +82,7 @@ const SnippetChildren = ({
|
||||
onAdd={() => onOpenEditor()}
|
||||
onEdit={onOpenEditor}
|
||||
onRemove={onRemoveField}
|
||||
onPrimarySortChange={onPrimarySortChange}
|
||||
onSecondarySortChange={onSecondarySortChange}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -61,6 +61,7 @@ const SnippetMain = ({
|
||||
const [fields, setFields] = useState<SnippetInputField[]>(payload.inputFields)
|
||||
const {
|
||||
doSyncWorkflowDraft,
|
||||
syncInputFieldsDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
} = useNodesSyncDraft(snippetId)
|
||||
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
|
||||
@ -100,19 +101,16 @@ const SnippetMain = ({
|
||||
setAppSidebarExpand(isMobile ? mode : localeMode)
|
||||
}, [isMobile, setAppSidebarExpand])
|
||||
|
||||
const primaryFields = useMemo(() => fields.slice(0, 2), [fields])
|
||||
const secondaryFields = useMemo(() => fields.slice(2), [fields])
|
||||
|
||||
const handlePrimarySortChange = (newFields: SnippetInputField[]) => {
|
||||
setFields([...newFields, ...secondaryFields])
|
||||
}
|
||||
|
||||
const handleSecondarySortChange = (newFields: SnippetInputField[]) => {
|
||||
setFields([...primaryFields, ...newFields])
|
||||
const handleSortChange = (newFields: SnippetInputField[]) => {
|
||||
setFields(newFields)
|
||||
}
|
||||
|
||||
const handleRemoveField = (index: number) => {
|
||||
setFields(current => current.filter((_, currentIndex) => currentIndex !== index))
|
||||
const nextFields = fields.filter((_, currentIndex) => currentIndex !== index)
|
||||
setFields(nextFields)
|
||||
void syncInputFieldsDraft(nextFields, {
|
||||
onRefresh: setFields,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmitField = (field: SnippetInputField) => {
|
||||
@ -124,10 +122,14 @@ const SnippetMain = ({
|
||||
return
|
||||
}
|
||||
|
||||
if (originalVariable)
|
||||
setFields(current => current.map(item => item.variable === originalVariable ? field : item))
|
||||
else
|
||||
setFields(current => [...current, field])
|
||||
const nextFields = originalVariable
|
||||
? fields.map(item => item.variable === originalVariable ? field : item)
|
||||
: [...fields, field]
|
||||
|
||||
setFields(nextFields)
|
||||
void syncInputFieldsDraft(nextFields, {
|
||||
onRefresh: setFields,
|
||||
})
|
||||
|
||||
closeEditor()
|
||||
}
|
||||
@ -205,8 +207,7 @@ const SnippetMain = ({
|
||||
onCloseEditor={closeEditor}
|
||||
onSubmitField={handleSubmitField}
|
||||
onRemoveField={handleRemoveField}
|
||||
onPrimarySortChange={handlePrimarySortChange}
|
||||
onSecondarySortChange={handleSecondarySortChange}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
</WorkflowWithInnerContext>
|
||||
)}
|
||||
|
||||
@ -18,8 +18,7 @@ type SnippetWorkflowPanelProps = {
|
||||
onCloseEditor: () => void
|
||||
onSubmitField: (field: SnippetInputField) => void
|
||||
onRemoveField: (index: number) => void
|
||||
onPrimarySortChange: (fields: SnippetInputField[]) => void
|
||||
onSecondarySortChange: (fields: SnippetInputField[]) => void
|
||||
onSortChange: (fields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
const SnippetPanelOnLeft = ({
|
||||
@ -32,8 +31,7 @@ const SnippetPanelOnLeft = ({
|
||||
onCloseEditor,
|
||||
onSubmitField,
|
||||
onRemoveField,
|
||||
onPrimarySortChange,
|
||||
onSecondarySortChange,
|
||||
onSortChange,
|
||||
}: SnippetWorkflowPanelProps) => {
|
||||
return (
|
||||
<div className="hidden xl:flex">
|
||||
@ -51,8 +49,7 @@ const SnippetPanelOnLeft = ({
|
||||
onAdd={() => onOpenEditor()}
|
||||
onEdit={onOpenEditor}
|
||||
onRemove={onRemoveField}
|
||||
onPrimarySortChange={onPrimarySortChange}
|
||||
onSecondarySortChange={onSecondarySortChange}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -70,8 +67,7 @@ const SnippetWorkflowPanel = ({
|
||||
onCloseEditor,
|
||||
onSubmitField,
|
||||
onRemoveField,
|
||||
onPrimarySortChange,
|
||||
onSecondarySortChange,
|
||||
onSortChange,
|
||||
}: SnippetWorkflowPanelProps) => {
|
||||
const versionHistoryPanelProps = useMemo(() => {
|
||||
return {
|
||||
@ -98,8 +94,7 @@ const SnippetWorkflowPanel = ({
|
||||
onCloseEditor={onCloseEditor}
|
||||
onSubmitField={onSubmitField}
|
||||
onRemoveField={onRemoveField}
|
||||
onPrimarySortChange={onPrimarySortChange}
|
||||
onSecondarySortChange={onSecondarySortChange}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@ -113,10 +108,10 @@ const SnippetWorkflowPanel = ({
|
||||
onCloseEditor,
|
||||
onCloseInputPanel,
|
||||
onOpenEditor,
|
||||
onPrimarySortChange,
|
||||
onRemoveField,
|
||||
onSecondarySortChange,
|
||||
onSortChange,
|
||||
onSubmitField,
|
||||
snippetId,
|
||||
versionHistoryPanelProps,
|
||||
])
|
||||
|
||||
|
||||
@ -84,6 +84,68 @@ describe('useSnippetInit', () => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should use draft input_fields for snippet inputs', () => {
|
||||
mockUseSnippetApiDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'A static snippet mock.',
|
||||
type: 'node',
|
||||
is_published: false,
|
||||
version: '1',
|
||||
use_count: 0,
|
||||
icon_info: {
|
||||
icon_type: null,
|
||||
icon: '🪄',
|
||||
icon_background: '#E0EAFF',
|
||||
},
|
||||
input_fields: [
|
||||
{
|
||||
label: 'Published field',
|
||||
variable: 'published_field',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
created_at: 1_712_300_000,
|
||||
updated_at: 1_712_300_000,
|
||||
author: 'Evan',
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
})
|
||||
mockUseSnippetDraftWorkflow.mockReturnValue({
|
||||
data: {
|
||||
id: 'draft-1',
|
||||
graph: {},
|
||||
features: {},
|
||||
input_fields: [
|
||||
{
|
||||
label: 'Draft field',
|
||||
variable: 'draft_field',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
hash: 'draft-hash',
|
||||
created_at: 1_712_300_000,
|
||||
updated_at: 1_712_345_678,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(result.current.data?.inputFields).toEqual([
|
||||
{
|
||||
label: 'Draft field',
|
||||
variable: 'draft_field',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should sync draft metadata into workflow store', () => {
|
||||
mockUseSnippetDraftWorkflow.mockImplementation((_snippetId: string, onSuccess?: (data: { updated_at: number, hash: string }) => void) => {
|
||||
onSuccess?.({
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import type { SnippetDraftSyncPayload, SnippetWorkflow } from '@/types/snippet'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
@ -18,13 +20,17 @@ const isSyncConflictError = (error: unknown): error is { bodyUsed: boolean, json
|
||||
&& typeof error.json === 'function'
|
||||
}
|
||||
|
||||
type SyncInputFieldsDraftCallback = SyncDraftCallback & {
|
||||
onRefresh?: (inputFields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
export const useNodesSyncDraft = (snippetId: string) => {
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
|
||||
|
||||
const getPostParams = useCallback(() => {
|
||||
const getGraphSyncPayload = useCallback(() => {
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
@ -32,8 +38,6 @@ export const useNodesSyncDraft = (snippetId: string) => {
|
||||
} = store.getState()
|
||||
const nodes = getNodes().filter(node => !node.data?._isTempNode)
|
||||
const [x, y, zoom] = transform
|
||||
const { syncWorkflowDraftHash } = workflowStore.getState()
|
||||
|
||||
if (!snippetId)
|
||||
return null
|
||||
|
||||
@ -55,47 +59,39 @@ export const useNodesSyncDraft = (snippetId: string) => {
|
||||
})
|
||||
|
||||
return {
|
||||
url: `/snippets/${snippetId}/workflows/draft`,
|
||||
params: {
|
||||
graph: {
|
||||
nodes: producedNodes,
|
||||
edges: producedEdges,
|
||||
viewport: { x, y, zoom },
|
||||
},
|
||||
hash: syncWorkflowDraftHash,
|
||||
graph: {
|
||||
nodes: producedNodes,
|
||||
edges: producedEdges,
|
||||
viewport: { x, y, zoom },
|
||||
},
|
||||
}
|
||||
}, [snippetId, store, workflowStore])
|
||||
}, [snippetId, store])
|
||||
|
||||
const syncWorkflowDraftWhenPageClose = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const postParams = getPostParams()
|
||||
if (postParams)
|
||||
postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params)
|
||||
}, [getNodesReadOnly, getPostParams])
|
||||
|
||||
const performSync = useCallback(async (
|
||||
const syncDraft = useCallback(async (
|
||||
payload: Omit<SnippetDraftSyncPayload, 'hash'>,
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
callback?: SyncDraftCallback,
|
||||
onRefresh?: (draftWorkflow: SnippetWorkflow) => void,
|
||||
) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const postParams = getPostParams()
|
||||
if (!postParams)
|
||||
if (!snippetId)
|
||||
return
|
||||
|
||||
const {
|
||||
setDraftUpdatedAt,
|
||||
setSyncWorkflowDraftHash,
|
||||
syncWorkflowDraftHash,
|
||||
} = workflowStore.getState()
|
||||
|
||||
try {
|
||||
const response = await consoleClient.snippets.syncDraftWorkflow({
|
||||
params: { snippetId },
|
||||
body: postParams.params,
|
||||
body: {
|
||||
...payload,
|
||||
hash: syncWorkflowDraftHash || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
setSyncWorkflowDraftHash(response.hash)
|
||||
@ -106,7 +102,7 @@ export const useNodesSyncDraft = (snippetId: string) => {
|
||||
if (isSyncConflictError(error) && !error.bodyUsed) {
|
||||
error.json().then((err) => {
|
||||
if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError)
|
||||
handleRefreshWorkflowDraft()
|
||||
handleRefreshWorkflowDraft(onRefresh)
|
||||
})
|
||||
}
|
||||
callback?.onError?.()
|
||||
@ -114,12 +110,57 @@ export const useNodesSyncDraft = (snippetId: string) => {
|
||||
finally {
|
||||
callback?.onSettled?.()
|
||||
}
|
||||
}, [getNodesReadOnly, getPostParams, handleRefreshWorkflowDraft, snippetId, workflowStore])
|
||||
}, [getNodesReadOnly, handleRefreshWorkflowDraft, snippetId, workflowStore])
|
||||
|
||||
const syncWorkflowDraftWhenPageClose = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const graphPayload = getGraphSyncPayload()
|
||||
if (!graphPayload)
|
||||
return
|
||||
|
||||
const { syncWorkflowDraftHash } = workflowStore.getState()
|
||||
postWithKeepalive(`${API_PREFIX}/snippets/${snippetId}/workflows/draft`, {
|
||||
...graphPayload,
|
||||
hash: syncWorkflowDraftHash,
|
||||
})
|
||||
}, [getGraphSyncPayload, getNodesReadOnly, snippetId, workflowStore])
|
||||
|
||||
const performSync = useCallback(async (
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
callback?: SyncDraftCallback,
|
||||
) => {
|
||||
const graphPayload = getGraphSyncPayload()
|
||||
if (!graphPayload)
|
||||
return
|
||||
|
||||
await syncDraft(graphPayload, notRefreshWhenSyncError, callback)
|
||||
}, [getGraphSyncPayload, syncDraft])
|
||||
|
||||
const performInputFieldsSync = useCallback(async (
|
||||
inputFields: SnippetInputField[],
|
||||
callback?: SyncInputFieldsDraftCallback,
|
||||
) => {
|
||||
await syncDraft(
|
||||
{ input_fields: inputFields },
|
||||
false,
|
||||
callback,
|
||||
(draftWorkflow) => {
|
||||
const refreshedInputFields = Array.isArray(draftWorkflow.input_fields)
|
||||
? draftWorkflow.input_fields as SnippetInputField[]
|
||||
: []
|
||||
callback?.onRefresh?.(refreshedInputFields)
|
||||
},
|
||||
)
|
||||
}, [syncDraft])
|
||||
|
||||
const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly)
|
||||
const syncInputFieldsDraft = useSerialAsyncCallback(performInputFieldsSync)
|
||||
|
||||
return {
|
||||
doSyncWorkflowDraft,
|
||||
syncInputFieldsDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { WorkflowDataUpdater } from '@/app/components/workflow/types'
|
||||
import type { SnippetWorkflow } from '@/types/snippet'
|
||||
import { useCallback } from 'react'
|
||||
import { useWorkflowUpdate } from '@/app/components/workflow/hooks'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
@ -8,7 +9,7 @@ export const useSnippetRefreshDraft = (snippetId: string) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
|
||||
|
||||
const handleRefreshWorkflowDraft = useCallback(() => {
|
||||
const handleRefreshWorkflowDraft = useCallback((onSuccess?: (draftWorkflow: SnippetWorkflow) => void) => {
|
||||
const {
|
||||
setDraftUpdatedAt,
|
||||
setIsSyncingWorkflowDraft,
|
||||
@ -30,6 +31,7 @@ export const useSnippetRefreshDraft = (snippetId: string) => {
|
||||
} as WorkflowDataUpdater)
|
||||
setSyncWorkflowDraftHash(response.hash)
|
||||
setDraftUpdatedAt(response.updated_at)
|
||||
onSuccess?.(response)
|
||||
}).finally(() => {
|
||||
setIsSyncingWorkflowDraft(false)
|
||||
})
|
||||
|
||||
@ -102,8 +102,8 @@ const toSnippetCanvasData = (workflow?: SnippetWorkflow): SnippetCanvasData => {
|
||||
}
|
||||
|
||||
export const buildSnippetDetailPayload = (snippet: SnippetContract, workflow?: SnippetWorkflow): SnippetDetailPayload => {
|
||||
const inputFields = Array.isArray(snippet.input_fields)
|
||||
? snippet.input_fields as SnippetInputFieldUIModel[]
|
||||
const inputFields = Array.isArray(workflow?.input_fields)
|
||||
? workflow.input_fields as SnippetInputFieldUIModel[]
|
||||
: []
|
||||
|
||||
return {
|
||||
|
||||
@ -77,6 +77,7 @@ export type SnippetWorkflow = {
|
||||
id: string
|
||||
graph: Record<string, unknown>
|
||||
features: Record<string, unknown>
|
||||
input_fields?: SnippetInputField[]
|
||||
hash: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
@ -87,7 +88,7 @@ export type SnippetDraftSyncPayload = {
|
||||
hash?: string
|
||||
environment_variables?: Record<string, unknown>[]
|
||||
conversation_variables?: Record<string, unknown>[]
|
||||
input_variables?: Record<string, unknown>[]
|
||||
input_fields?: SnippetInputField[]
|
||||
}
|
||||
|
||||
export type SnippetDraftSyncResponse = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user