feat(web): autosave snippet drafts

This commit is contained in:
JzoNg 2026-06-22 15:32:58 +08:00
parent b8cc01cf10
commit b0d6567a63
2 changed files with 46 additions and 503 deletions

View File

@ -15,7 +15,6 @@ const mockReset = vi.fn()
const mockSetFields = vi.fn()
const mockSetNavigationState = vi.fn()
const mockPublishSnippetMutateAsync = vi.fn()
const mockUseSnippetPublishedWorkflow = vi.fn()
const mockFetchInspectVars = vi.fn()
const mockHandleBackupDraft = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
@ -25,11 +24,7 @@ const mockHandleStartWorkflowRun = vi.fn()
const mockHandleStopRun = vi.fn()
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleCheckBeforePublish = vi.fn()
const mockPush = vi.hoisted(() => vi.fn())
const mockUseAvailableNodesMetaData = vi.hoisted(() => vi.fn())
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
value: ['snippets.create_and_modify'],
}))
const mockInspectVarsCrud = {
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
@ -54,11 +49,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
},
}))
vi.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
}),
}))
let capturedHooksStore: Record<string, unknown> | undefined
let capturedWorkflowNodes: WorkflowProps['nodes'] | undefined
let snippetDetailStoreState: {
@ -76,18 +66,11 @@ vi.mock('@/app/components/snippets/store', () => ({
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
usePublishSnippetWorkflowMutation: () => ({
mutateAsync: mockPublishSnippetMutateAsync,
isPending: false,
}),
useSnippetPublishedWorkflow: () => mockUseSnippetPublishedWorkflow(),
}))
vi.mock('@/app/components/snippets/hooks/use-configs-map', () => ({
@ -170,28 +153,15 @@ vi.mock('@/app/components/workflow', () => ({
vi.mock('@/app/components/snippets/components/snippet-children', () => ({
default: ({
onCancel,
onEdit,
onExitEditingWithoutSave,
onPublish,
canSave,
canEdit,
isEditing,
}: {
canSave: boolean
canEdit: boolean
isEditing: boolean
onCancel: () => void
onEdit: () => void
onExitEditingWithoutSave: () => void
onPublish: () => void
}) => (
<div>
{!isEditing && canEdit && <button type="button" onClick={onEdit}>edit</button>}
<a href="/snippets">snippets list</a>
<button type="button" onClick={onExitEditingWithoutSave}>exit without save</button>
<button type="button" disabled={!canSave} onClick={onPublish}>publish</button>
<button type="button" onClick={onCancel}>cancel</button>
</div>
),
}))
@ -280,13 +250,6 @@ describe('SnippetMain', () => {
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
mockPublishSnippetMutateAsync.mockResolvedValue({ created_at: 1_744_000_000 })
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {
graph: payload.graph,
input_fields: payload.inputFields,
},
refetch: vi.fn(),
})
const llmNodeMetadata = createNodeMetadata(BlockEnum.LLM)
const humanInputNodeMetadata = createNodeMetadata(BlockEnum.HumanInput)
const endNodeMetadata = createNodeMetadata(BlockEnum.End)
@ -321,11 +284,10 @@ describe('SnippetMain', () => {
setFields: mockSetFields,
setNavigationState: mockSetNavigationState,
}
mockWorkspacePermissionKeys.value = ['snippets.create_and_modify']
})
describe('Initial Mode', () => {
it('should enter draft editing mode by default when there is no published workflow', () => {
it('should render the draft graph by default when there is no published workflow', () => {
const draftNode = createDraftNode('draft-node')
renderSnippetMain({
@ -337,8 +299,7 @@ describe('SnippetMain', () => {
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node'])
})
it('should stay readonly without snippet create-and-modify permission', async () => {
mockWorkspacePermissionKeys.value = []
it('should keep the snippet canvas editable and sync draft changes without permission gating', async () => {
const draftNode = createDraftNode('draft-node')
renderSnippetMain({
@ -347,14 +308,17 @@ describe('SnippetMain', () => {
})
expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument()
expect(mockSetNavigationState).toHaveBeenCalledWith(expect.objectContaining({
readonly: false,
}))
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
await doSyncWorkflowDraft()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
})
it('should enter readonly mode with published graph by default when published workflow exists', async () => {
it('should render the draft graph even when a published workflow exists', async () => {
const publishedNode = createDraftNode('published-node')
const draftNode = createDraftNode('draft-node')
@ -364,35 +328,13 @@ describe('SnippetMain', () => {
workflowDraftNodes: [draftNode],
})
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['published-node'])
expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument()
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node'])
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
await doSyncWorkflowDraft()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
})
it('should switch from readonly published graph to draft graph without forced draft sync', async () => {
const publishedNode = createDraftNode('published-node')
const draftNode = createDraftNode('draft-node')
renderSnippetMain({
hasPublishedWorkflow: true,
workflowNodes: [publishedNode],
workflowDraftNodes: [draftNode],
})
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
await waitFor(() => {
expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node'])
})
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as ((notRefreshWhenSyncError?: boolean) => Promise<void>)
await doSyncWorkflowDraft(true)
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
})
})
@ -459,94 +401,13 @@ describe('SnippetMain', () => {
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith()
})
it('should sync workflow draft before routing without saving changes', async () => {
it('should sync workflow draft when the page closes', () => {
renderSnippetMain({ hasInitialDraftChanges: true })
fireEvent.click(screen.getByRole('link', { name: 'snippets list' }))
fireEvent.click(await screen.findByRole('button', { name: 'snippet.doNotSave' }))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets')
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith(true)
expect(mockDoSyncWorkflowDraft.mock.invocationCallOrder[0]!).toBeLessThan(mockPush.mock.invocationCallOrder[0]!)
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled()
})
it('should sync workflow draft before exiting editing without saving changes', async () => {
renderSnippetMain({ hasInitialDraftChanges: true })
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith(true)
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled()
})
it('should not sync draft from workflow autosave while readonly', async () => {
renderSnippetMain({ hasInitialDraftChanges: true })
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
})
mockDoSyncWorkflowDraft.mockClear()
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
const syncWorkflowDraftWhenPageClose = capturedHooksStore?.syncWorkflowDraftWhenPageClose as (() => void)
await doSyncWorkflowDraft()
syncWorkflowDraftWhenPageClose()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockSyncWorkflowDraftWhenPageClose).not.toHaveBeenCalled()
})
it('should skip forced draft sync caused by re-entering editing mode', async () => {
renderSnippetMain({ hasInitialDraftChanges: true })
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
})
mockDoSyncWorkflowDraft.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as ((notRefreshWhenSyncError?: boolean) => Promise<void>)
await doSyncWorkflowDraft(true)
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
})
it('should use latest synced draft when re-entering editing mode', async () => {
const latestDraftNode = {
id: 'latest-node',
position: { x: 10, y: 20 },
data: { type: BlockEnum.Code, title: 'Latest draft node' },
} as WorkflowProps['nodes'][number]
mockDoSyncWorkflowDraft.mockResolvedValueOnce({
graph: {
nodes: [latestDraftNode],
edges: [],
viewport: { x: 30, y: 40, zoom: 1.2 },
},
input_fields: [payload.inputFields[0]],
})
renderSnippetMain({ hasInitialDraftChanges: true })
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
await waitFor(() => {
expect(capturedWorkflowNodes?.map(node => node.id)).toContain('latest-node')
})
expect(mockSyncWorkflowDraftWhenPageClose).toHaveBeenCalledTimes(1)
})
})
@ -635,74 +496,6 @@ describe('SnippetMain', () => {
})
})
describe('Cancel', () => {
it('should restore from the published workflow and reset published input fields', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'cancel' }))
await waitFor(() => {
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalledWith({
graph: payload.graph,
input_fields: payload.inputFields,
})
expect(mockSetFields).toHaveBeenCalledWith(payload.inputFields)
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith(payload.inputFields, {
onRefresh: expect.any(Function),
})
})
})
it('should update local draft state with the published workflow after canceling changes', async () => {
const latestDraftNode = {
id: 'latest-draft-node',
position: { x: 10, y: 20 },
data: { type: BlockEnum.Code, title: 'Latest draft node' },
} as WorkflowProps['nodes'][number]
const publishedNode = {
id: 'published-node',
position: { x: 30, y: 40 },
data: { type: BlockEnum.Code, title: 'Published node' },
} as WorkflowProps['nodes'][number]
const publishedWorkflow = {
graph: {
nodes: [publishedNode],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
input_fields: payload.inputFields,
}
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: publishedWorkflow,
refetch: vi.fn(),
})
mockDoSyncWorkflowDraft.mockResolvedValueOnce({
graph: {
nodes: [latestDraftNode],
edges: [],
viewport: { x: 30, y: 40, zoom: 1.2 },
},
input_fields: payload.inputFields,
})
renderSnippetMain({ hasInitialDraftChanges: true })
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
await waitFor(() => {
expect(capturedWorkflowNodes?.map(node => node.id)).toContain('latest-draft-node')
})
fireEvent.click(screen.getByRole('button', { name: 'cancel' }))
await waitFor(() => {
expect(capturedWorkflowNodes?.map(node => node.id)).toContain('published-node')
})
})
})
describe('Inspect Vars', () => {
it('should pass inspect vars handlers to WorkflowWithInnerContext', () => {
renderSnippetMain()

View File

@ -9,7 +9,6 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
@ -23,9 +22,6 @@ import {
initialEdges,
initialNodes,
} from '@/app/components/workflow/utils'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import { useConfigsMap } from '../hooks/use-configs-map'
import { useGetRunAndTraceUrl } from '../hooks/use-get-run-and-trace-url'
import { useInspectVarsCrud } from '../hooks/use-inspect-vars-crud'
@ -34,10 +30,8 @@ import { useSnippetRefreshDraft } from '../hooks/use-snippet-refresh-draft'
import { useSnippetRun } from '../hooks/use-snippet-run'
import { useSnippetStartRun } from '../hooks/use-snippet-start-run'
import { useSnippetDetailStore } from '../store'
import { canCreateAndModifySnippets } from '../utils/permission'
import { useSnippetInputFieldActions } from './hooks/use-snippet-input-field-actions'
import { useSnippetPublish } from './hooks/use-snippet-publish'
import SaveBeforeLeavingDialog from './save-before-leaving-dialog'
import SnippetChildren from './snippet-children'
type SnippetMainProps = {
@ -54,19 +48,9 @@ type SnippetMainProps = {
type SnippetMainContentProps = {
snippetId: string
fields: SnippetInputField[]
canDiscardChanges: boolean
canEdit: boolean
canSave: boolean
hasDraftChanges: boolean
isEditing: boolean
onBeforePublish: () => Promise<Omit<SnippetDraftSyncPayload, 'hash'> | void>
onCancel: () => void | Promise<void>
onDiscardRoute: () => void | Promise<void>
onEdit: () => void
onExitEditing: () => void | Promise<void>
onExitEditingWithoutSave: () => void | Promise<void>
onSaved: (syncedDraftPayload?: Omit<SnippetDraftSyncPayload, 'hash'> | void) => void
onSavedAndExitEditing: () => void
}
const unsupportedSnippetBlockTypes = new Set([
@ -94,15 +78,10 @@ const SnippetMainContent = ({
snippetId,
fields,
canSave,
hasDraftChanges,
isEditing,
onBeforePublish,
onDiscardRoute,
onSaved,
}: SnippetMainContentProps) => {
const { push } = useRouter()
const { t } = useTranslation('snippet')
const [pendingHref, setPendingHref] = useState<string>()
const {
handlePublish,
isPublishing,
@ -127,166 +106,42 @@ const SnippetMainContent = ({
return didSave
}, [handlePublish, onBeforePublish, onSaved, t])
const navigateToPendingHref = useCallback((href: string) => {
const url = new URL(href, window.location.href)
if (url.origin === window.location.origin)
push(`${url.pathname}${url.search}${url.hash}`)
else
window.location.assign(url.href)
}, [push])
const handleDiscardAndRoute = useCallback(async () => {
if (!pendingHref)
return
await onDiscardRoute()
navigateToPendingHref(pendingHref)
setPendingHref(undefined)
}, [navigateToPendingHref, onDiscardRoute, pendingHref])
const handleSaveAndRoute = useCallback(async () => {
if (!pendingHref)
return
const didSave = await handlePublishSnippet()
if (!didSave)
return
navigateToPendingHref(pendingHref)
setPendingHref(undefined)
}, [handlePublishSnippet, navigateToPendingHref, pendingHref])
useEffect(() => {
if (!isEditing || !hasDraftChanges)
return
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [hasDraftChanges, isEditing])
useEffect(() => {
if (!isEditing || !hasDraftChanges)
return
const handleClick = (event: MouseEvent) => {
if (
event.defaultPrevented
|| event.button !== 0
|| event.metaKey
|| event.ctrlKey
|| event.shiftKey
|| event.altKey
) {
return
}
const anchor = (event.target as Element | null)?.closest?.('a[href]')
if (!(anchor instanceof HTMLAnchorElement))
return
if (anchor.target && anchor.target !== '_self')
return
if (anchor.hasAttribute('download'))
return
const nextUrl = new URL(anchor.href, window.location.href)
const currentUrl = new URL(window.location.href)
if (nextUrl.href === currentUrl.href)
return
event.preventDefault()
event.stopPropagation()
setPendingHref(nextUrl.href)
}
document.addEventListener('click', handleClick, true)
return () => document.removeEventListener('click', handleClick, true)
}, [hasDraftChanges, isEditing])
return (
<>
<SnippetChildren
snippetId={snippetId}
fields={fields}
canSave={canSave}
isPublishing={isPublishing}
onPublish={handlePublishSnippet}
/>
<SaveBeforeLeavingDialog
open={!!pendingHref}
onOpenChange={open => !open && setPendingHref(undefined)}
disabled={isPublishing}
saveDisabled={!canSave}
loading={isPublishing}
onDiscard={handleDiscardAndRoute}
onSave={handleSaveAndRoute}
/>
</>
<SnippetChildren
snippetId={snippetId}
fields={fields}
canSave={canSave}
isPublishing={isPublishing}
onPublish={handlePublishSnippet}
/>
)
}
const SnippetMain = ({
payload,
draftPayload,
hasInitialDraftChanges,
hasPublishedWorkflow,
snippetId,
nodes,
edges,
viewport,
draftNodes,
draftEdges,
draftViewport,
}: SnippetMainProps) => {
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canCreateAndModifySnippet = canCreateAndModifySnippets(workspacePermissionKeys)
const [isEditingState, setIsEditingState] = useState(!hasPublishedWorkflow)
const isEditing = canCreateAndModifySnippet && isEditingState
const [localDraftState, setLocalDraftState] = useState<LocalDraftState>()
const [draftChangeState, setDraftChangeState] = useState({
initial: hasInitialDraftChanges,
snippetId,
value: hasInitialDraftChanges,
})
if (draftChangeState.snippetId !== snippetId || draftChangeState.initial !== hasInitialDraftChanges) {
const [localDraftSnippetId, setLocalDraftSnippetId] = useState(snippetId)
if (localDraftSnippetId !== snippetId) {
setLocalDraftState(undefined)
setDraftChangeState({
initial: hasInitialDraftChanges,
snippetId,
value: hasInitialDraftChanges,
})
setLocalDraftSnippetId(snippetId)
}
const hasDraftChanges = draftChangeState.value
const currentCanvasNodeCount = useStore(state => state.nodes.filter(node => !node.data?._isTempNode).length)
const skipNextForcedDraftSyncRef = useRef(false)
const setHasDraftChanges = useCallback((value: boolean) => {
setDraftChangeState(prev => ({
...prev,
value,
}))
}, [])
const effectiveDraftPayload = localDraftState?.payload ?? draftPayload
const effectiveDraftNodes = localDraftState?.nodes ?? draftNodes
const effectiveDraftEdges = localDraftState?.edges ?? draftEdges
const effectiveDraftViewport = localDraftState?.viewport ?? draftViewport
const displayPayload = isEditing ? effectiveDraftPayload : payload
const displayNodes = isEditing ? effectiveDraftNodes : nodes
const displayEdges = isEditing ? effectiveDraftEdges : edges
const displayViewport = isEditing ? effectiveDraftViewport : viewport
const { graph, snippet } = displayPayload
const { graph, snippet } = effectiveDraftPayload
const canSave = currentCanvasNodeCount > 0
const {
doSyncWorkflowDraft: syncWorkflowDraft,
syncInputFieldsDraft,
syncWorkflowDraftWhenPageClose,
} = useNodesSyncDraft(snippetId)
const workflowStore = useWorkflowStore()
const publishedWorkflowQuery = useSnippetPublishedWorkflow(snippetId)
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
const {
handleBackupDraft,
@ -316,10 +171,6 @@ const SnippetMain = ({
invalidateConversationVarValues,
} = useInspectVarsCrud(snippetId)
const workflowAvailableNodesMetaData = useAvailableNodesMetaData()
const {
data: publishedWorkflow,
refetch: refetchPublishedWorkflow,
} = publishedWorkflowQuery
const availableNodesMetaData = useMemo(() => {
const nodes = workflowAvailableNodesMetaData.nodes.filter(node =>
!unsupportedSnippetBlockTypes.has(node.metaData.type))
@ -350,9 +201,9 @@ const SnippetMain = ({
})))
const {
fields,
handleFieldsChange,
handleFieldsChange: handleSnippetFieldsChange,
} = useSnippetInputFieldActions({
canEdit: canCreateAndModifySnippet,
canEdit: true,
snippetId,
})
const {
@ -369,73 +220,45 @@ const SnippetMain = ({
}, [reset, snippetId])
useEffect(() => {
setFields(displayPayload.inputFields)
}, [displayPayload.inputFields, setFields, snippetId])
setFields(effectiveDraftPayload.inputFields)
}, [effectiveDraftPayload.inputFields, setFields, snippetId])
useEffect(() => {
workflowStore.setState({ canvasReadOnly: !isEditing })
workflowStore.setState({ canvasReadOnly: false })
return () => {
workflowStore.setState({ canvasReadOnly: false })
}
}, [isEditing, workflowStore])
}, [workflowStore])
useEffect(() => {
workflowStore.temporal.getState().pause()
workflowStore.getState().setWorkflowHistory({
nodes: displayNodes,
edges: displayEdges,
nodes: effectiveDraftNodes,
edges: effectiveDraftEdges,
workflowHistoryEvent: undefined,
workflowHistoryEventMeta: undefined,
})
workflowStore.temporal.getState().clear()
workflowStore.temporal.getState().resume()
}, [displayEdges, displayNodes, workflowStore])
}, [effectiveDraftEdges, effectiveDraftNodes, workflowStore])
const doSyncWorkflowDraft = useCallback((
...args: Parameters<typeof syncWorkflowDraft>
) => {
if (!canCreateAndModifySnippet || !isEditing)
return Promise.resolve()
) => syncWorkflowDraft(...args), [syncWorkflowDraft])
const [
notRefreshWhenSyncError,
callback,
] = args
if (skipNextForcedDraftSyncRef.current && notRefreshWhenSyncError === true && !callback) {
skipNextForcedDraftSyncRef.current = false
return Promise.resolve()
}
if (isEditing)
setHasDraftChanges(true)
return syncWorkflowDraft(...args)
}, [canCreateAndModifySnippet, isEditing, setHasDraftChanges, syncWorkflowDraft])
const syncWorkflowDraftWhenPageCloseInEditing = useCallback(() => {
if (!canCreateAndModifySnippet || !isEditing)
return
syncWorkflowDraftWhenPageClose()
}, [canCreateAndModifySnippet, isEditing, syncWorkflowDraftWhenPageClose])
const handleFieldsChangeInEditing = useCallback((nextFields: SnippetInputField[]) => {
if (!canCreateAndModifySnippet || !isEditing)
return
handleFieldsChange(nextFields)
setHasDraftChanges(true)
}, [canCreateAndModifySnippet, handleFieldsChange, isEditing, setHasDraftChanges])
const handleFieldsChange = useCallback((nextFields: SnippetInputField[]) => {
handleSnippetFieldsChange(nextFields)
}, [handleSnippetFieldsChange])
useEffect(() => {
setNavigationState({
snippetId,
snippet,
readonly: !isEditing,
onFieldsChange: handleFieldsChangeInEditing,
readonly: false,
onFieldsChange: handleFieldsChange,
})
}, [handleFieldsChangeInEditing, isEditing, setNavigationState, snippet, snippetId])
}, [handleFieldsChange, setNavigationState, snippet, snippetId])
const updateLocalDraftFromSyncPayload = useCallback((
syncedDraftPayload?: Omit<SnippetDraftSyncPayload, 'hash'> | void,
@ -469,67 +292,10 @@ const SnippetMain = ({
setFields(inputFields)
}, [draftPayload, fields, setFields])
const handleCancelChanges = useCallback(async () => {
if (!canCreateAndModifySnippet)
return
const workflow = publishedWorkflow ?? (await refetchPublishedWorkflow()).data
if (!workflow)
return
handleRestoreFromPublishedWorkflow(workflow as never)
const publishedInputFields = Array.isArray(workflow.input_fields)
? workflow.input_fields as SnippetInputField[]
: []
updateLocalDraftFromSyncPayload({
graph: workflow.graph,
input_fields: publishedInputFields,
})
void syncInputFieldsDraft(publishedInputFields, {
onRefresh: setFields,
})
setHasDraftChanges(false)
}, [canCreateAndModifySnippet, handleRestoreFromPublishedWorkflow, publishedWorkflow, refetchPublishedWorkflow, setFields, setHasDraftChanges, syncInputFieldsDraft, updateLocalDraftFromSyncPayload])
const handleExitEditing = useCallback(async () => {
if (!canCreateAndModifySnippet || hasDraftChanges)
return
setIsEditingState(false)
}, [canCreateAndModifySnippet, hasDraftChanges])
const handleExitEditingWithoutSave = useCallback(async () => {
if (!canCreateAndModifySnippet)
return
const syncedDraftPayload = await syncWorkflowDraft(true)
updateLocalDraftFromSyncPayload(syncedDraftPayload)
skipNextForcedDraftSyncRef.current = true
setIsEditingState(false)
}, [canCreateAndModifySnippet, syncWorkflowDraft, updateLocalDraftFromSyncPayload])
const handleDiscardAndRoute = useCallback(async () => {
if (!canCreateAndModifySnippet)
return
const syncedDraftPayload = await syncWorkflowDraft(true)
updateLocalDraftFromSyncPayload(syncedDraftPayload)
skipNextForcedDraftSyncRef.current = true
}, [canCreateAndModifySnippet, syncWorkflowDraft, updateLocalDraftFromSyncPayload])
const handleEdit = useCallback(() => {
if (!canCreateAndModifySnippet)
return
skipNextForcedDraftSyncRef.current = true
setIsEditingState(true)
}, [canCreateAndModifySnippet])
const hooksStore = useMemo(() => {
return {
doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: syncWorkflowDraftWhenPageCloseInEditing,
syncWorkflowDraftWhenPageClose,
handleRefreshWorkflowDraft,
handleBackupDraft,
handleLoadBackupDraft,
@ -585,41 +351,25 @@ const SnippetMain = ({
renameInspectVarName,
resetConversationVar,
resetToLastRunVar,
syncWorkflowDraftWhenPageCloseInEditing,
syncWorkflowDraftWhenPageClose,
])
return (
<div className="relative flex h-full min-h-0 min-w-0">
<div className="relative min-h-0 min-w-0 grow">
<WorkflowWithInnerContext
key={`${snippetId}-${isEditing ? 'draft' : 'published'}`}
nodes={displayNodes}
edges={displayEdges}
viewport={displayViewport ?? graph.viewport}
key={`${snippetId}-draft`}
nodes={effectiveDraftNodes}
edges={effectiveDraftEdges}
viewport={effectiveDraftViewport ?? graph.viewport}
hooksStore={hooksStore as unknown as Partial<HooksStoreShape>}
>
<SnippetMainContent
snippetId={snippetId}
fields={fields}
canDiscardChanges={hasPublishedWorkflow}
canEdit={canCreateAndModifySnippet}
canSave={canSave}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
onBeforePublish={() => syncWorkflowDraft(true)}
onCancel={handleCancelChanges}
onDiscardRoute={handleDiscardAndRoute}
onEdit={handleEdit}
onExitEditing={handleExitEditing}
onExitEditingWithoutSave={handleExitEditingWithoutSave}
onSaved={(syncedDraftPayload) => {
updateLocalDraftFromSyncPayload(syncedDraftPayload)
setHasDraftChanges(false)
}}
onSavedAndExitEditing={() => {
setHasDraftChanges(false)
setIsEditingState(false)
}}
onSaved={updateLocalDraftFromSyncPayload}
/>
</WorkflowWithInnerContext>
</div>