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
74 changes: 74 additions & 0 deletions apps/sim/app/api/workflows/[id]/references/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetSession, mockGetUserEntityPermissions, mockGetWorkflowReferences } = vi.hoisted(
() => ({
mockGetSession: vi.fn(),
mockGetUserEntityPermissions: vi.fn(),
mockGetWorkflowReferences: vi.fn(),
})
)

vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))

vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))

vi.mock('@/lib/workflows/references/operations', () => ({
getWorkflowReferences: mockGetWorkflowReferences,
}))

import { GET } from '@/app/api/workflows/[id]/references/route'

const REFERENCES = {
callers: [{ id: 'b', name: 'B', cycle: false, children: [] }],
callees: [],
}

function callRoute(id = 'wf-1', workspaceId: string | null = 'ws-1') {
const url = workspaceId
? `http://localhost:3000/api/workflows/${id}/references?workspaceId=${workspaceId}`
: `http://localhost:3000/api/workflows/${id}/references`
return GET(createMockRequest('GET', undefined, {}, url), { params: Promise.resolve({ id }) })
}

describe('GET /api/workflows/[id]/references', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockGetUserEntityPermissions.mockResolvedValue('read')
mockGetWorkflowReferences.mockResolvedValue(REFERENCES)
})

it('returns 401 without a session', async () => {
mockGetSession.mockResolvedValue(null)
const response = await callRoute()
expect(response.status).toBe(401)
expect(mockGetWorkflowReferences).not.toHaveBeenCalled()
})

it('returns 400 without a workspaceId', async () => {
const response = await callRoute('wf-1', null)
expect(response.status).toBe(400)
})

it('returns 403 when the user cannot access the workspace', async () => {
mockGetUserEntityPermissions.mockResolvedValue(null)
const response = await callRoute()
expect(response.status).toBe(403)
expect(mockGetWorkflowReferences).not.toHaveBeenCalled()
})

it('returns the reference trees for the workflow', async () => {
const response = await callRoute()
expect(response.status).toBe(200)
expect(await response.json()).toEqual(REFERENCES)
expect(mockGetWorkflowReferences).toHaveBeenCalledWith('ws-1', 'wf-1')
})
})
30 changes: 30 additions & 0 deletions apps/sim/app/api/workflows/[id]/references/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getWorkflowReferencesContract } from '@/lib/api/contracts/workflow-references'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getWorkflowReferences } from '@/lib/workflows/references/operations'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

type RouteContext = { params: Promise<{ id: string }> }

export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const parsed = await parseRequest(getWorkflowReferencesContract, request, context)
if (!parsed.success) return parsed.response

const { params, query } = parsed.data

const permission = await getUserEntityPermissions(session.user.id, 'workspace', query.workspaceId)
if (permission === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}

const references = await getWorkflowReferences(query.workspaceId, params.id)
return NextResponse.json(references)
})
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
SquareArrowUpRight,
Trash,
Unlock,
Workflow,
} from '@sim/emcn/icons'
import { Pin, PinOff } from 'lucide-react'

Expand All @@ -31,6 +32,7 @@ interface ContextMenuProps {
menuRef: React.RefObject<HTMLDivElement | null>
onClose: () => void
onOpenInNewTab?: () => void
onFindReferences?: () => void
onMarkAsRead?: () => void
onMarkAsUnread?: () => void
onTogglePin?: () => void
Expand All @@ -53,6 +55,7 @@ interface ContextMenuProps {
onExport?: () => void
onDelete: () => void
showOpenInNewTab?: boolean
showFindReferences?: boolean
showMarkAsRead?: boolean
showMarkAsUnread?: boolean
showPin?: boolean
Expand Down Expand Up @@ -93,6 +96,7 @@ export function ContextMenu({
menuRef,
onClose,
onOpenInNewTab,
onFindReferences,
onMarkAsRead,
onMarkAsUnread,
onTogglePin,
Expand All @@ -104,6 +108,7 @@ export function ContextMenu({
onExport,
onDelete,
showOpenInNewTab = false,
showFindReferences = false,
showMarkAsRead = false,
showMarkAsUnread = false,
showPin = false,
Expand Down Expand Up @@ -133,7 +138,8 @@ export function ContextMenu({
showUploadLogo = false,
disableUploadLogo = false,
}: ContextMenuProps) {
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
const hasNavigationSection =
(showOpenInNewTab && onOpenInNewTab) || (showFindReferences && onFindReferences)
const hasStatusSection =
(showMarkAsRead && onMarkAsRead) ||
(showMarkAsUnread && onMarkAsUnread) ||
Expand Down Expand Up @@ -195,6 +201,17 @@ export function ContextMenu({
Open in new tab
</DropdownMenuItem>
)}
{showFindReferences && onFindReferences && (
<DropdownMenuItem
onSelect={() => {
onFindReferences()
onClose()
}}
>
<Workflow />
Show references
</DropdownMenuItem>
)}
{hasNavigationSection && (hasStatusSection || hasEditSection || hasCopySection) && (
<DropdownMenuSeparator />
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use client'

import { cn } from '@sim/emcn'
import { WorkflowIcon } from '@/components/icons'
import type { ReferenceNode } from '@/lib/api/contracts/workflow-references'

const CONFIG = {
/** Horizontal indent added per tree level, in pixels. */
INDENT_PER_LEVEL: 16,
/** Base horizontal padding for a row, in pixels. */
BASE_INDENT: 4,
/** Workflow-block brand color (`WorkflowBlock.bgColor`). */
ICON_COLOR: '#6366F1',
} as const

interface ReferenceTreeProps {
nodes: ReferenceNode[]
/** Invoked with a workflow id when a row is activated. */
onNavigate: (workflowId: string) => void
}

interface ReferenceTreeItemProps {
node: ReferenceNode
depth: number
onNavigate: (workflowId: string) => void
}

function ReferenceTreeItem({ node, depth, onNavigate }: ReferenceTreeItemProps) {
return (
<div role='treeitem' aria-selected={false}>
<button
type='button'
onClick={() => onNavigate(node.id)}
style={{ paddingLeft: CONFIG.BASE_INDENT + depth * CONFIG.INDENT_PER_LEVEL }}
className={cn(
'flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors',
'hover:bg-[var(--surface-active)]'
)}
>
<WorkflowIcon className='size-[14px] flex-shrink-0' style={{ color: CONFIG.ICON_COLOR }} />
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>{node.name}</span>
{node.cycle && (
<span className='flex-shrink-0 text-[var(--text-muted)] text-caption'>(cycle)</span>
)}
</button>
{node.children.length > 0 && (
<div role='group'>
{node.children.map((child) => (
<ReferenceTreeItem
key={`${child.id}-${depth}`}
node={child}
depth={depth + 1}
onNavigate={onNavigate}
/>
))}
</div>
)}
</div>
)
}

/**
* Read-only recursive tree of workflow references. Each row navigates to its
* workflow on click; cyclic leaves are marked and render no children.
*/
export function ReferenceTree({ nodes, onNavigate }: ReferenceTreeProps) {
return (
<div role='tree' className='flex flex-col'>
{nodes.map((node) => (
<ReferenceTreeItem key={node.id} node={node} depth={0} onNavigate={onNavigate} />
))}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use client'

import { useCallback, useState } from 'react'
import { ChipModal, ChipModalBody, ChipModalHeader, ChipModalTabs } from '@sim/emcn'
import { useRouter } from 'next/navigation'
import type { ReferenceNode } from '@/lib/api/contracts/workflow-references'
import { ReferenceTree } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree'
import { useWorkflowReferences } from '@/hooks/queries/workflow-references'

type ReferencesTab = 'callers' | 'callees'

const TABS = [
{ value: 'callers', label: 'Used by' },
{ value: 'callees', label: 'Uses' },
] as const

const EMPTY_MESSAGE: Record<ReferencesTab, string> = {
callers: 'No workflows call this workflow.',
callees: "This workflow doesn't call any other workflows.",
}

interface ReferencesModalProps {
isOpen: boolean
onClose: () => void
workspaceId: string
workflowId: string
workflowName: string
}

/**
* IDE-style reference viewer for a workflow. "Used by" lists the workflows that
* call it (inbound); "Uses" lists the workflows it calls (outbound). Both are
* recursive trees whose rows navigate to the referenced workflow.
*/
export function ReferencesModal({
isOpen,
onClose,
workspaceId,
workflowId,
workflowName,
}: ReferencesModalProps) {
const router = useRouter()
const [activeTab, setActiveTab] = useState<ReferencesTab>('callers')
const [prevIsOpen, setPrevIsOpen] = useState(false)

if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen)
if (isOpen) setActiveTab('callers')
}

const { data, isPending, isError } = useWorkflowReferences(workspaceId, workflowId, {
enabled: isOpen,
})

const handleNavigate = useCallback(
(targetId: string) => {
router.push(`/workspace/${workspaceId}/w/${targetId}`)
onClose()
},
[router, workspaceId, onClose]
)

const nodes: ReferenceNode[] = data ? data[activeTab] : []

return (
<ChipModal open={isOpen} onOpenChange={(next) => !next && onClose()} srTitle='References'>
<ChipModalHeader onClose={onClose}>References · {workflowName}</ChipModalHeader>
<ChipModalBody>
<ChipModalTabs
tabs={TABS}
value={activeTab}
onChange={(value) => setActiveTab(value as ReferencesTab)}
aria-label='Reference direction'
/>
{isPending ? (
<p className='px-2 text-[var(--text-muted)] text-sm'>Loading references…</p>
) : isError ? (
<p className='px-2 text-[var(--text-error)] text-sm'>Failed to load references.</p>
) : nodes.length === 0 ? (
<p className='px-2 text-[var(--text-muted)] text-sm'>{EMPTY_MESSAGE[activeTab]}</p>
) : (
<ReferenceTree nodes={nodes} onNavigate={handleNavigate} />
)}
</ChipModalBody>
</ChipModal>
)
}
Loading
Loading