mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 13:01:16 +08:00
feat(web): autosave snippet drafts
This commit is contained in:
parent
b8cc01cf10
commit
b0d6567a63
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user