Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions apps/web/src/components/flows/__tests__/flows-page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @vitest-environment jsdom */
import { FlowRunStatus, FlowRunTrigger } from '@prisma/client'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { FlowsPage } from '@/components/flows/flows-page'
Expand Down Expand Up @@ -175,14 +175,46 @@ describe('FlowsPage', () => {
expect(screen.getByText('Running')).toBeTruthy()
})

it('runs flows from the desktop list and navigates to history', async () => {
render(<FlowsPage slug="alice" navigateToHistoryOnRun />)
await waitFor(() => expect(screen.getByText('Weekly Review')).toBeTruthy())

fireEvent.click(screen.getByRole('button', { name: 'Run' }))
await waitFor(() => expect(clientMocks.runFlowRequest).toHaveBeenCalledWith('alice', 'flow-1'))
expect(clientMocks.push).toHaveBeenCalledWith('/u/alice/flows/flow-1/runs')
expect(clientMocks.fetchFlowList).toHaveBeenCalledTimes(1)
it('keeps the list in place after a run and refreshes it silently while the run is active', async () => {
const pollCallbacks: Array<() => void> = []
const setIntervalSpy = vi.spyOn(global, 'setInterval').mockImplementation(((callback: () => void) => {
pollCallbacks.push(callback)
return 0 as unknown as ReturnType<typeof setInterval>
}) as typeof setInterval)
const clearIntervalSpy = vi.spyOn(global, 'clearInterval').mockImplementation(() => undefined)

try {
clientMocks.fetchFlowList
.mockResolvedValueOnce({ ok: true, data: { flows: [createFlow()] } })
.mockResolvedValueOnce({ ok: true, data: { flows: [createFlow({ latestRun: createRun(FlowRunStatus.running) })] } })
.mockResolvedValue({ ok: true, data: { flows: [createFlow({ latestRun: createRun(FlowRunStatus.succeeded) })] } })

render(<FlowsPage slug="alice" />)
await waitFor(() => expect(screen.getByText('Weekly Review')).toBeTruthy())

fireEvent.click(screen.getByRole('button', { name: 'Run' }))
await waitFor(() => expect(clientMocks.runFlowRequest).toHaveBeenCalledWith('alice', 'flow-1'))
await waitFor(() => expect(clientMocks.fetchFlowList).toHaveBeenCalledTimes(2))

// Active run registered a silent poll (StrictMode may register the
// effect twice); firing the latest one refreshes without mounting the
// loader or navigating away.
expect(pollCallbacks.length).toBeGreaterThan(0)
await act(async () => {
pollCallbacks[pollCallbacks.length - 1]!()
})
await waitFor(() => expect(clientMocks.fetchFlowList).toHaveBeenCalledTimes(3))

expect(clientMocks.push).not.toHaveBeenCalled()
expect(screen.getByText('Weekly Review')).toBeTruthy()
expect(screen.queryByText('Loading flows...')).toBeNull()

// Once no run is active, the poll tears itself down.
expect(clearIntervalSpy).toHaveBeenCalled()
} finally {
setIntervalSpy.mockRestore()
clearIntervalSpy.mockRestore()
}
})

it('shows action errors from run and network failures', async () => {
Expand Down
36 changes: 23 additions & 13 deletions apps/web/src/components/flows/flows-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { useCallback, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'

import { ClockCountdown, ClockCounterClockwise, DotsThreeVertical, DownloadSimple, GitBranch, PencilSimple, Play, SpinnerGap, TreeStructure } from '@phosphor-icons/react'

Expand All @@ -17,12 +16,15 @@ import { getFlowErrorMessage } from '@/lib/flows/errors'
import type { FlowListItem } from '@/lib/flows/types'
import { cn } from '@/lib/utils'

// While any visible flow has an active run, refresh the list quietly so the
// badges track the run without leaving the page.
const ACTIVE_RUN_REFRESH_INTERVAL_MS = 5000

type FlowsPageProps = {
buildCreateHref?: () => string
buildEditHref?: (flowId: string) => string
buildHistoryHref?: (flowId: string) => string
hideHeader?: boolean
navigateToHistoryOnRun?: boolean
slug: string
}

Expand All @@ -44,16 +46,15 @@ function getRunBadgeLabel(flow: FlowListItem): string {
return 'Last run failed'
}

export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hideHeader = false, navigateToHistoryOnRun = false, slug }: FlowsPageProps) {
const router = useRouter()
export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hideHeader = false, slug }: FlowsPageProps) {
const [flows, setFlows] = useState<FlowListItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [runningFlowId, setRunningFlowId] = useState<string | null>(null)

const loadFlows = useCallback(async () => {
setIsLoading(true)
const loadFlows = useCallback(async (options: { silent?: boolean } = {}) => {
if (!options.silent) setIsLoading(true)
setLoadError(null)
try {
const result = await fetchFlowList(slug)
Expand All @@ -66,7 +67,7 @@ export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hi
} catch {
setLoadError('network_error')
} finally {
setIsLoading(false)
if (!options.silent) setIsLoading(false)
}
}, [slug])

Expand All @@ -90,18 +91,13 @@ export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hi
return
}

if (navigateToHistoryOnRun) {
router.push(getHistoryHref(flowId))
return
}

await loadFlows()
} catch {
setActionError('network_error')
} finally {
setRunningFlowId(null)
}
}, [getHistoryHref, loadFlows, navigateToHistoryOnRun, router, slug])
}, [loadFlows, slug])

useEffect(() => {
let cancelled = false
Expand Down Expand Up @@ -138,6 +134,20 @@ export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hi
const sortedFlows = useMemo(() => [...flows].sort((left, right) => left.name.localeCompare(right.name)), [flows])
const myFlows = useMemo(() => sortedFlows.filter((flow) => flow.permissions.isOwner), [sortedFlows])
const teamFlows = useMemo(() => sortedFlows.filter((flow) => !flow.permissions.isOwner), [sortedFlows])
const hasActiveRun = useMemo(
() => flows.some((flow) => flow.latestRun?.status === 'running' || flow.latestRun?.status === 'waiting_for_human'),
[flows],
)

useEffect(() => {
if (!hasActiveRun) return

const interval = setInterval(() => {
void loadFlows({ silent: true })
}, ACTIVE_RUN_REFRESH_INTERVAL_MS)

return () => clearInterval(interval)
}, [hasActiveRun, loadFlows])

function renderFlowGrid(items: FlowListItem[]) {
return (
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/components/workspace/workspace-flows-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export function WorkspaceFlowsView({
buildCreateHref={buildCreateHref}
buildEditHref={buildEditHref}
buildHistoryHref={buildHistoryHref}
navigateToHistoryOnRun
/>
</CatalogFrame>
)
Expand Down
Loading