Skip to content
Merged
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
94 changes: 94 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,94 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

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

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

vi.mock('@sim/platform-authz/workflow', () => ({
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow,
}))

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') {
const url = `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' } })
mockAuthorizeWorkflow.mockResolvedValue({
allowed: true,
status: 200,
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
workspacePermission: '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 404 when the workflow does not exist', async () => {
mockAuthorizeWorkflow.mockResolvedValue({
allowed: false,
status: 404,
message: 'Workflow not found',
workflow: null,
workspacePermission: null,
})
const response = await callRoute()
expect(response.status).toBe(404)
expect(mockGetWorkflowReferences).not.toHaveBeenCalled()
})

it('returns 403 when the user cannot read the workflow', async () => {
mockAuthorizeWorkflow.mockResolvedValue({
allowed: false,
status: 403,
message: 'Unauthorized: Access denied to read this workflow',
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
workspacePermission: null,
})
const response = await callRoute()
expect(response.status).toBe(403)
expect(mockGetWorkflowReferences).not.toHaveBeenCalled()
})

it('returns the reference trees scoped to the workflow workspace', async () => {
const response = await callRoute()
expect(response.status).toBe(200)
expect(await response.json()).toEqual(REFERENCES)
expect(mockAuthorizeWorkflow).toHaveBeenCalledWith({
workflowId: 'wf-1',
userId: 'user-1',
action: 'read',
})
expect(mockGetWorkflowReferences).toHaveBeenCalledWith('ws-1', 'wf-1')
})
})
37 changes: 37 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,37 @@
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
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'

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 { id } = parsed.data.params

const auth = await authorizeWorkflowByWorkspacePermission({
workflowId: id,
userId: session.user.id,
action: 'read',
})
if (!auth.allowed || !auth.workflow?.workspaceId) {
return NextResponse.json(
{ error: auth.message ?? 'Access denied' },
{ status: auth.allowed ? 403 : auth.status }
)
}

const references = await getWorkflowReferences(auth.workflow.workspaceId, 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,68 @@
'use client'

import { Workflow } from '@sim/emcn/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 row padding: lands depth-0 content on the modal's px-4 text gutter. */
BASE_INDENT: 8,
} 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-level={depth + 1}>
<button
type='button'
onClick={() => onNavigate(node.id)}
style={{ paddingLeft: CONFIG.BASE_INDENT + depth * CONFIG.INDENT_PER_LEVEL }}
className='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-hover)]'
>
<Workflow className='size-[14px] shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>{node.name}</span>
{node.cycle && (
<span className='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}
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,74 @@
'use client'

import { useState } from 'react'
import { ChipModal, ChipModalBody, ChipModalHeader, ChipModalTabs } from '@sim/emcn'
import { useRouter } from 'next/navigation'
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 {
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. Mounted on
* demand by the owning row, so state initializes fresh per open.
*/
export function ReferencesModal({
onClose,
workspaceId,
workflowId,
workflowName,
}: ReferencesModalProps) {
const router = useRouter()
const [activeTab, setActiveTab] = useState<ReferencesTab>('callers')

const { data, isPending, isError } = useWorkflowReferences(workflowId)

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

const nodes = data?.[activeTab] ?? []

return (
<ChipModal open 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