diff --git a/web/app/components/header/nav/__tests__/index.spec.tsx b/web/app/components/header/nav/__tests__/index.spec.tsx
index 3dce8375b3..90d4cc4f73 100644
--- a/web/app/components/header/nav/__tests__/index.spec.tsx
+++ b/web/app/components/header/nav/__tests__/index.spec.tsx
@@ -201,6 +201,14 @@ describe('Nav Component', () => {
expect(mockSetAppDetail).not.toHaveBeenCalled()
})
+ it('should not call setAppDetail from snippets segment', () => {
+ vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
+ render()
+ const link = screen.getByRole('link')
+ fireEvent.click(link.firstChild!)
+ expect(mockSetAppDetail).not.toHaveBeenCalled()
+ })
+
it('should show ArrowNarrowLeft on hover when curNav is provided and activated', () => {
const curNav = navigationItems[0]
render()
@@ -238,19 +246,20 @@ describe('Nav Component', () => {
})
it('should navigate when an item is selected', async () => {
- render()
+ vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
+ render()
const selectorButton = screen.getByRole('button', { name: /Item 1/i })
await act(async () => {
fireEvent.click(selectorButton)
})
+ mockSetAppDetail.mockClear()
const item2 = await screen.findByText('Item 2')
await act(async () => {
fireEvent.click(item2)
})
- expect(mockSetAppDetail).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/item2')
})
diff --git a/web/app/components/header/nav/index.tsx b/web/app/components/header/nav/index.tsx
index 766aca14e3..b59aa14f61 100644
--- a/web/app/components/header/nav/index.tsx
+++ b/web/app/components/header/nav/index.tsx
@@ -51,6 +51,8 @@ const Nav = ({
// Don't clear state if opening in new tab/window
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
return
+ if (segment === 'snippets')
+ return
setAppDetail()
}}
className={cn('flex h-7 cursor-pointer items-center rounded-[10px] px-2.5', isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text', curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover')}
diff --git a/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts
index 30c9a780fa..7e38fbdb9a 100644
--- a/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts
+++ b/web/app/components/snippets/hooks/__tests__/use-nodes-sync-draft.spec.ts
@@ -10,6 +10,8 @@ const mockPostWithKeepalive = vi.fn()
const mockSyncDraftWorkflow = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
+let deferSerialCallbacks = false
+let queuedSerialCallbacks: Array<() => Promise | void> = []
let reactFlowState: {
getNodes: typeof mockGetNodes
@@ -37,6 +39,11 @@ vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
if (checkFn?.())
return
+ if (deferSerialCallbacks) {
+ queuedSerialCallbacks.push(() => fn(...args))
+ return Promise.resolve()
+ }
+
return fn(...args)
},
}))
@@ -77,6 +84,8 @@ const createInputField = (variable: string): SnippetInputField => ({
describe('snippet/use-nodes-sync-draft', () => {
beforeEach(() => {
vi.clearAllMocks()
+ deferSerialCallbacks = false
+ queuedSerialCallbacks = []
reactFlowState = {
getNodes: mockGetNodes,
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }],
@@ -121,6 +130,38 @@ describe('snippet/use-nodes-sync-draft', () => {
})
})
+ it('should snapshot graph before queued draft sync executes', async () => {
+ deferSerialCallbacks = true
+ const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
+
+ await act(async () => {
+ await result.current.doSyncWorkflowDraft()
+ })
+
+ mockGetNodes.mockReturnValue([
+ { id: 'late-node', position: { x: 9, y: 9 }, data: { title: 'Late' } },
+ ])
+ reactFlowState.edges = [{ id: 'late-edge', source: 'late-node', target: 'late-target', data: { stable: false } }]
+ reactFlowState.transform = [99, 88, 0.5]
+
+ await act(async () => {
+ await Promise.all(queuedSerialCallbacks.map(run => run()))
+ })
+
+ expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
+ params: { snippetId: 'snippet-1' },
+ body: {
+ graph: {
+ nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start' } }],
+ edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }],
+ viewport: { x: 12, y: 24, zoom: 1.5 },
+ },
+ input_fields: [createInputField('topic')],
+ hash: 'draft-hash',
+ },
+ })
+ })
+
it('should include the latest graph when syncing input fields', async () => {
const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
const nextFields = [createInputField('summary')]
diff --git a/web/app/components/snippets/hooks/use-nodes-sync-draft.ts b/web/app/components/snippets/hooks/use-nodes-sync-draft.ts
index 1c3efb5647..824d04c9b2 100644
--- a/web/app/components/snippets/hooks/use-nodes-sync-draft.ts
+++ b/web/app/components/snippets/hooks/use-nodes-sync-draft.ts
@@ -136,21 +136,20 @@ export const useNodesSyncDraft = (snippetId: string) => {
}, [getDraftSyncPayload, getNodesReadOnly, snippetId, workflowStore])
const performSync = useCallback(async (
+ draftPayload: Omit | null,
notRefreshWhenSyncError?: boolean,
callback?: SyncDraftCallback,
) => {
- const draftPayload = getDraftSyncPayload()
if (!draftPayload)
return
await syncDraft(draftPayload, notRefreshWhenSyncError, callback)
- }, [getDraftSyncPayload, syncDraft])
+ }, [syncDraft])
const performInputFieldsSync = useCallback(async (
- inputFields: SnippetInputField[],
+ draftPayload: Omit | null,
callback?: SyncInputFieldsDraftCallback,
) => {
- const draftPayload = getDraftSyncPayload(inputFields)
if (!draftPayload)
return
@@ -165,10 +164,29 @@ export const useNodesSyncDraft = (snippetId: string) => {
callback?.onRefresh?.(refreshedInputFields)
},
)
- }, [getDraftSyncPayload, syncDraft])
+ }, [syncDraft])
- const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly)
- const syncInputFieldsDraft = useSerialAsyncCallback(performInputFieldsSync)
+ const syncWorkflowDraftWithPayload = useSerialAsyncCallback(performSync, getNodesReadOnly)
+ const syncInputFieldsDraftWithPayload = useSerialAsyncCallback(performInputFieldsSync)
+
+ const doSyncWorkflowDraft = useCallback((
+ notRefreshWhenSyncError?: boolean,
+ callback?: SyncDraftCallback,
+ ) => {
+ if (getNodesReadOnly())
+ return Promise.resolve()
+
+ const draftPayload = getDraftSyncPayload()
+ return syncWorkflowDraftWithPayload(draftPayload, notRefreshWhenSyncError, callback)
+ }, [getDraftSyncPayload, getNodesReadOnly, syncWorkflowDraftWithPayload])
+
+ const syncInputFieldsDraft = useCallback((
+ inputFields: SnippetInputField[],
+ callback?: SyncInputFieldsDraftCallback,
+ ) => {
+ const draftPayload = getDraftSyncPayload(inputFields)
+ return syncInputFieldsDraftWithPayload(draftPayload, callback)
+ }, [getDraftSyncPayload, syncInputFieldsDraftWithPayload])
return {
doSyncWorkflowDraft,
diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts
index fa1b26074e..efd21e099c 100644
--- a/web/app/components/workflow/types.ts
+++ b/web/app/components/workflow/types.ts
@@ -498,7 +498,7 @@ export type ChildNodeTypeCount = {
[key: string]: number
}
-const TRIGGER_NODE_TYPES = [
+export const TRIGGER_NODE_TYPES = [
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,