fix(web): snippet draft sync

This commit is contained in:
JzoNg 2026-04-28 14:42:51 +08:00
parent 9dd73b4d47
commit 77afc805e1
5 changed files with 80 additions and 10 deletions

View File

@ -201,6 +201,14 @@ describe('Nav Component', () => {
expect(mockSetAppDetail).not.toHaveBeenCalled() expect(mockSetAppDetail).not.toHaveBeenCalled()
}) })
it('should not call setAppDetail from snippets segment', () => {
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} />)
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', () => { it('should show ArrowNarrowLeft on hover when curNav is provided and activated', () => {
const curNav = navigationItems[0] const curNav = navigationItems[0]
render(<Nav {...defaultProps} curNav={curNav} />) render(<Nav {...defaultProps} curNav={curNav} />)
@ -238,19 +246,20 @@ describe('Nav Component', () => {
}) })
it('should navigate when an item is selected', async () => { it('should navigate when an item is selected', async () => {
render(<Nav {...defaultProps} curNav={curNav} />) vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} curNav={curNav} />)
const selectorButton = screen.getByRole('button', { name: /Item 1/i }) const selectorButton = screen.getByRole('button', { name: /Item 1/i })
await act(async () => { await act(async () => {
fireEvent.click(selectorButton) fireEvent.click(selectorButton)
}) })
mockSetAppDetail.mockClear()
const item2 = await screen.findByText('Item 2') const item2 = await screen.findByText('Item 2')
await act(async () => { await act(async () => {
fireEvent.click(item2) fireEvent.click(item2)
}) })
expect(mockSetAppDetail).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/item2') expect(mockPush).toHaveBeenCalledWith('/item2')
}) })

View File

@ -51,6 +51,8 @@ const Nav = ({
// Don't clear state if opening in new tab/window // Don't clear state if opening in new tab/window
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
return return
if (segment === 'snippets')
return
setAppDetail() 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')} 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')}

View File

@ -10,6 +10,8 @@ const mockPostWithKeepalive = vi.fn()
const mockSyncDraftWorkflow = vi.fn() const mockSyncDraftWorkflow = vi.fn()
const mockSetDraftUpdatedAt = vi.fn() const mockSetDraftUpdatedAt = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn() const mockSetSyncWorkflowDraftHash = vi.fn()
let deferSerialCallbacks = false
let queuedSerialCallbacks: Array<() => Promise<void> | void> = []
let reactFlowState: { let reactFlowState: {
getNodes: typeof mockGetNodes getNodes: typeof mockGetNodes
@ -37,6 +39,11 @@ vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
if (checkFn?.()) if (checkFn?.())
return return
if (deferSerialCallbacks) {
queuedSerialCallbacks.push(() => fn(...args))
return Promise.resolve()
}
return fn(...args) return fn(...args)
}, },
})) }))
@ -77,6 +84,8 @@ const createInputField = (variable: string): SnippetInputField => ({
describe('snippet/use-nodes-sync-draft', () => { describe('snippet/use-nodes-sync-draft', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
deferSerialCallbacks = false
queuedSerialCallbacks = []
reactFlowState = { reactFlowState = {
getNodes: mockGetNodes, getNodes: mockGetNodes,
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }], 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 () => { it('should include the latest graph when syncing input fields', async () => {
const { result } = renderHook(() => useNodesSyncDraft('snippet-1')) const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
const nextFields = [createInputField('summary')] const nextFields = [createInputField('summary')]

View File

@ -136,21 +136,20 @@ export const useNodesSyncDraft = (snippetId: string) => {
}, [getDraftSyncPayload, getNodesReadOnly, snippetId, workflowStore]) }, [getDraftSyncPayload, getNodesReadOnly, snippetId, workflowStore])
const performSync = useCallback(async ( const performSync = useCallback(async (
draftPayload: Omit<SnippetDraftSyncPayload, 'hash'> | null,
notRefreshWhenSyncError?: boolean, notRefreshWhenSyncError?: boolean,
callback?: SyncDraftCallback, callback?: SyncDraftCallback,
) => { ) => {
const draftPayload = getDraftSyncPayload()
if (!draftPayload) if (!draftPayload)
return return
await syncDraft(draftPayload, notRefreshWhenSyncError, callback) await syncDraft(draftPayload, notRefreshWhenSyncError, callback)
}, [getDraftSyncPayload, syncDraft]) }, [syncDraft])
const performInputFieldsSync = useCallback(async ( const performInputFieldsSync = useCallback(async (
inputFields: SnippetInputField[], draftPayload: Omit<SnippetDraftSyncPayload, 'hash'> | null,
callback?: SyncInputFieldsDraftCallback, callback?: SyncInputFieldsDraftCallback,
) => { ) => {
const draftPayload = getDraftSyncPayload(inputFields)
if (!draftPayload) if (!draftPayload)
return return
@ -165,10 +164,29 @@ export const useNodesSyncDraft = (snippetId: string) => {
callback?.onRefresh?.(refreshedInputFields) callback?.onRefresh?.(refreshedInputFields)
}, },
) )
}, [getDraftSyncPayload, syncDraft]) }, [syncDraft])
const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly) const syncWorkflowDraftWithPayload = useSerialAsyncCallback(performSync, getNodesReadOnly)
const syncInputFieldsDraft = useSerialAsyncCallback(performInputFieldsSync) 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 { return {
doSyncWorkflowDraft, doSyncWorkflowDraft,

View File

@ -498,7 +498,7 @@ export type ChildNodeTypeCount = {
[key: string]: number [key: string]: number
} }
const TRIGGER_NODE_TYPES = [ export const TRIGGER_NODE_TYPES = [
BlockEnum.TriggerSchedule, BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook, BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin, BlockEnum.TriggerPlugin,