mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-19 20:08:04 -05:00
Compare commits
4 Commits
feat/super
...
feat/api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
609a8a53b0 | ||
|
|
b46f760247 | ||
|
|
a2c794a77e | ||
|
|
530a3292a3 |
@@ -19,7 +19,7 @@ export interface RateLimitResult {
|
|||||||
|
|
||||||
export async function checkRateLimit(
|
export async function checkRateLimit(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
endpoint: 'logs' | 'logs-detail' = 'logs'
|
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs'
|
||||||
): Promise<RateLimitResult> {
|
): Promise<RateLimitResult> {
|
||||||
try {
|
try {
|
||||||
const auth = await authenticateV1Request(request)
|
const auth = await authenticateV1Request(request)
|
||||||
|
|||||||
102
apps/sim/app/api/v1/workflows/[id]/route.ts
Normal file
102
apps/sim/app/api/v1/workflows/[id]/route.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { permissions, workflow, workflowBlocks } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||||
|
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||||
|
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
|
||||||
|
|
||||||
|
const logger = createLogger('V1WorkflowDetailsAPI')
|
||||||
|
|
||||||
|
export const revalidate = 0
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const requestId = crypto.randomUUID().slice(0, 8)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rateLimit = await checkRateLimit(request, 'workflow-detail')
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return createRateLimitResponse(rateLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = rateLimit.userId!
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId })
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: workflow.id,
|
||||||
|
name: workflow.name,
|
||||||
|
description: workflow.description,
|
||||||
|
color: workflow.color,
|
||||||
|
folderId: workflow.folderId,
|
||||||
|
workspaceId: workflow.workspaceId,
|
||||||
|
isDeployed: workflow.isDeployed,
|
||||||
|
deployedAt: workflow.deployedAt,
|
||||||
|
runCount: workflow.runCount,
|
||||||
|
lastRunAt: workflow.lastRunAt,
|
||||||
|
variables: workflow.variables,
|
||||||
|
createdAt: workflow.createdAt,
|
||||||
|
updatedAt: workflow.updatedAt,
|
||||||
|
})
|
||||||
|
.from(workflow)
|
||||||
|
.innerJoin(
|
||||||
|
permissions,
|
||||||
|
and(
|
||||||
|
eq(permissions.entityType, 'workspace'),
|
||||||
|
eq(permissions.entityId, workflow.workspaceId),
|
||||||
|
eq(permissions.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(eq(workflow.id, id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const workflowData = rows[0]
|
||||||
|
if (!workflowData) {
|
||||||
|
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockRows = await db
|
||||||
|
.select({
|
||||||
|
id: workflowBlocks.id,
|
||||||
|
type: workflowBlocks.type,
|
||||||
|
subBlocks: workflowBlocks.subBlocks,
|
||||||
|
})
|
||||||
|
.from(workflowBlocks)
|
||||||
|
.where(eq(workflowBlocks.workflowId, id))
|
||||||
|
|
||||||
|
const blocksRecord = Object.fromEntries(
|
||||||
|
blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }])
|
||||||
|
)
|
||||||
|
const inputs = extractInputFieldsFromBlocks(blocksRecord)
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
id: workflowData.id,
|
||||||
|
name: workflowData.name,
|
||||||
|
description: workflowData.description,
|
||||||
|
color: workflowData.color,
|
||||||
|
folderId: workflowData.folderId,
|
||||||
|
workspaceId: workflowData.workspaceId,
|
||||||
|
isDeployed: workflowData.isDeployed,
|
||||||
|
deployedAt: workflowData.deployedAt?.toISOString() || null,
|
||||||
|
runCount: workflowData.runCount,
|
||||||
|
lastRunAt: workflowData.lastRunAt?.toISOString() || null,
|
||||||
|
variables: workflowData.variables || {},
|
||||||
|
inputs,
|
||||||
|
createdAt: workflowData.createdAt.toISOString(),
|
||||||
|
updatedAt: workflowData.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const limits = await getUserLimits(userId)
|
||||||
|
|
||||||
|
const apiResponse = createApiResponse({ data: response }, limits, rateLimit)
|
||||||
|
|
||||||
|
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
logger.error(`[${requestId}] Workflow details fetch error`, { error: message })
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
184
apps/sim/app/api/v1/workflows/route.ts
Normal file
184
apps/sim/app/api/v1/workflows/route.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { permissions, workflow } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { and, asc, eq, gt, or } from 'drizzle-orm'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||||
|
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
|
||||||
|
|
||||||
|
const logger = createLogger('V1WorkflowsAPI')
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const revalidate = 0
|
||||||
|
|
||||||
|
const QueryParamsSchema = z.object({
|
||||||
|
workspaceId: z.string(),
|
||||||
|
folderId: z.string().optional(),
|
||||||
|
deployedOnly: z.coerce.boolean().optional().default(false),
|
||||||
|
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
interface CursorData {
|
||||||
|
sortOrder: number
|
||||||
|
createdAt: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCursor(data: CursorData): string {
|
||||||
|
return Buffer.from(JSON.stringify(data)).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCursor(cursor: string): CursorData | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(cursor, 'base64').toString())
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const requestId = crypto.randomUUID().slice(0, 8)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rateLimit = await checkRateLimit(request, 'workflows')
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return createRateLimitResponse(rateLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = rateLimit.userId!
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const rawParams = Object.fromEntries(searchParams.entries())
|
||||||
|
|
||||||
|
const validationResult = QueryParamsSchema.safeParse(rawParams)
|
||||||
|
if (!validationResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid parameters', details: validationResult.error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = validationResult.data
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Fetching workflows for workspace ${params.workspaceId}`, {
|
||||||
|
userId,
|
||||||
|
filters: {
|
||||||
|
folderId: params.folderId,
|
||||||
|
deployedOnly: params.deployedOnly,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
eq(workflow.workspaceId, params.workspaceId),
|
||||||
|
eq(permissions.entityType, 'workspace'),
|
||||||
|
eq(permissions.entityId, params.workspaceId),
|
||||||
|
eq(permissions.userId, userId),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (params.folderId) {
|
||||||
|
conditions.push(eq(workflow.folderId, params.folderId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.deployedOnly) {
|
||||||
|
conditions.push(eq(workflow.isDeployed, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.cursor) {
|
||||||
|
const cursorData = decodeCursor(params.cursor)
|
||||||
|
if (cursorData) {
|
||||||
|
const cursorCondition = or(
|
||||||
|
gt(workflow.sortOrder, cursorData.sortOrder),
|
||||||
|
and(
|
||||||
|
eq(workflow.sortOrder, cursorData.sortOrder),
|
||||||
|
gt(workflow.createdAt, new Date(cursorData.createdAt))
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
eq(workflow.sortOrder, cursorData.sortOrder),
|
||||||
|
eq(workflow.createdAt, new Date(cursorData.createdAt)),
|
||||||
|
gt(workflow.id, cursorData.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (cursorCondition) {
|
||||||
|
conditions.push(cursorCondition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: workflow.id,
|
||||||
|
name: workflow.name,
|
||||||
|
description: workflow.description,
|
||||||
|
color: workflow.color,
|
||||||
|
folderId: workflow.folderId,
|
||||||
|
workspaceId: workflow.workspaceId,
|
||||||
|
isDeployed: workflow.isDeployed,
|
||||||
|
deployedAt: workflow.deployedAt,
|
||||||
|
runCount: workflow.runCount,
|
||||||
|
lastRunAt: workflow.lastRunAt,
|
||||||
|
sortOrder: workflow.sortOrder,
|
||||||
|
createdAt: workflow.createdAt,
|
||||||
|
updatedAt: workflow.updatedAt,
|
||||||
|
})
|
||||||
|
.from(workflow)
|
||||||
|
.innerJoin(
|
||||||
|
permissions,
|
||||||
|
and(
|
||||||
|
eq(permissions.entityType, 'workspace'),
|
||||||
|
eq(permissions.entityId, params.workspaceId),
|
||||||
|
eq(permissions.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(...orderByClause)
|
||||||
|
.limit(params.limit + 1)
|
||||||
|
|
||||||
|
const hasMore = rows.length > params.limit
|
||||||
|
const data = rows.slice(0, params.limit)
|
||||||
|
|
||||||
|
let nextCursor: string | undefined
|
||||||
|
if (hasMore && data.length > 0) {
|
||||||
|
const lastWorkflow = data[data.length - 1]
|
||||||
|
nextCursor = encodeCursor({
|
||||||
|
sortOrder: lastWorkflow.sortOrder,
|
||||||
|
createdAt: lastWorkflow.createdAt.toISOString(),
|
||||||
|
id: lastWorkflow.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedWorkflows = data.map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
name: w.name,
|
||||||
|
description: w.description,
|
||||||
|
color: w.color,
|
||||||
|
folderId: w.folderId,
|
||||||
|
workspaceId: w.workspaceId,
|
||||||
|
isDeployed: w.isDeployed,
|
||||||
|
deployedAt: w.deployedAt?.toISOString() || null,
|
||||||
|
runCount: w.runCount,
|
||||||
|
lastRunAt: w.lastRunAt?.toISOString() || null,
|
||||||
|
createdAt: w.createdAt.toISOString(),
|
||||||
|
updatedAt: w.updatedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const limits = await getUserLimits(userId)
|
||||||
|
|
||||||
|
const response = createApiResponse(
|
||||||
|
{
|
||||||
|
data: formattedWorkflows,
|
||||||
|
nextCursor,
|
||||||
|
},
|
||||||
|
limits,
|
||||||
|
rateLimit
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(response.body, { headers: response.headers })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
logger.error(`[${requestId}] Workflows fetch error`, { error: message })
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -452,39 +452,6 @@ console.log(limits);`
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* <div>
|
|
||||||
<div className='mb-[6.5px] flex items-center justify-between'>
|
|
||||||
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
|
||||||
URL
|
|
||||||
</Label>
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
onClick={() => handleCopy('endpoint', info.endpoint)}
|
|
||||||
aria-label='Copy endpoint'
|
|
||||||
className='!p-1.5 -my-1.5'
|
|
||||||
>
|
|
||||||
{copied.endpoint ? (
|
|
||||||
<Check className='h-3 w-3' />
|
|
||||||
) : (
|
|
||||||
<Clipboard className='h-3 w-3' />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content>
|
|
||||||
<span>{copied.endpoint ? 'Copied' : 'Copy'}</span>
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</div>
|
|
||||||
<Code.Viewer
|
|
||||||
code={info.endpoint}
|
|
||||||
language='javascript'
|
|
||||||
wrapText
|
|
||||||
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
|
|
||||||
/>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className='mb-[6.5px] flex items-center justify-between'>
|
<div className='mb-[6.5px] flex items-center justify-between'>
|
||||||
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
Textarea,
|
||||||
|
} from '@/components/emcn'
|
||||||
|
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||||
|
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||||
|
import type { InputFormatField } from '@/lib/workflows/types'
|
||||||
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||||
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
|
|
||||||
|
type NormalizedField = InputFormatField & { name: string }
|
||||||
|
|
||||||
|
interface ApiInfoModalProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
workflowId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalProps) {
|
||||||
|
const blocks = useWorkflowStore((state) => state.blocks)
|
||||||
|
const setValue = useSubBlockStore((state) => state.setValue)
|
||||||
|
const subBlockValues = useSubBlockStore((state) =>
|
||||||
|
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
|
||||||
|
)
|
||||||
|
|
||||||
|
const workflowMetadata = useWorkflowRegistry((state) =>
|
||||||
|
workflowId ? state.workflows[workflowId] : undefined
|
||||||
|
)
|
||||||
|
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
|
||||||
|
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [paramDescriptions, setParamDescriptions] = useState<Record<string, string>>({})
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||||
|
|
||||||
|
const initialDescriptionRef = useRef('')
|
||||||
|
const initialParamDescriptionsRef = useRef<Record<string, string>>({})
|
||||||
|
|
||||||
|
const starterBlockId = useMemo(() => {
|
||||||
|
for (const [blockId, block] of Object.entries(blocks)) {
|
||||||
|
if (!block || typeof block !== 'object') continue
|
||||||
|
const blockType = (block as { type?: string }).type
|
||||||
|
if (blockType && isValidStartBlockType(blockType)) {
|
||||||
|
return blockId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [blocks])
|
||||||
|
|
||||||
|
const inputFormat = useMemo((): NormalizedField[] => {
|
||||||
|
if (!starterBlockId) return []
|
||||||
|
|
||||||
|
const storeValue = subBlockValues[starterBlockId]?.inputFormat
|
||||||
|
const normalized = normalizeInputFormatValue(storeValue) as NormalizedField[]
|
||||||
|
if (normalized.length > 0) return normalized
|
||||||
|
|
||||||
|
const startBlock = blocks[starterBlockId]
|
||||||
|
const blockValue = startBlock?.subBlocks?.inputFormat?.value
|
||||||
|
return normalizeInputFormatValue(blockValue) as NormalizedField[]
|
||||||
|
}, [starterBlockId, subBlockValues, blocks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const normalizedDesc = workflowMetadata?.description?.toLowerCase().trim()
|
||||||
|
const isDefaultDescription =
|
||||||
|
!workflowMetadata?.description ||
|
||||||
|
workflowMetadata.description === workflowMetadata.name ||
|
||||||
|
normalizedDesc === 'new workflow' ||
|
||||||
|
normalizedDesc === 'your first workflow - start building here!'
|
||||||
|
|
||||||
|
const initialDescription = isDefaultDescription ? '' : workflowMetadata?.description || ''
|
||||||
|
setDescription(initialDescription)
|
||||||
|
initialDescriptionRef.current = initialDescription
|
||||||
|
|
||||||
|
const descriptions: Record<string, string> = {}
|
||||||
|
for (const field of inputFormat) {
|
||||||
|
if (field.description) {
|
||||||
|
descriptions[field.name] = field.description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setParamDescriptions(descriptions)
|
||||||
|
initialParamDescriptionsRef.current = { ...descriptions }
|
||||||
|
}
|
||||||
|
}, [open, workflowMetadata, inputFormat])
|
||||||
|
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (description !== initialDescriptionRef.current) return true
|
||||||
|
|
||||||
|
for (const field of inputFormat) {
|
||||||
|
const currentValue = (paramDescriptions[field.name] || '').trim()
|
||||||
|
const initialValue = (initialParamDescriptionsRef.current[field.name] || '').trim()
|
||||||
|
if (currentValue !== initialValue) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}, [description, paramDescriptions, inputFormat])
|
||||||
|
|
||||||
|
const handleParamDescriptionChange = (fieldName: string, value: string) => {
|
||||||
|
setParamDescriptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[fieldName]: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseAttempt = useCallback(() => {
|
||||||
|
if (hasChanges && !isSaving) {
|
||||||
|
setShowUnsavedChangesAlert(true)
|
||||||
|
} else {
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
}, [hasChanges, isSaving, onOpenChange])
|
||||||
|
|
||||||
|
const handleDiscardChanges = useCallback(() => {
|
||||||
|
setShowUnsavedChangesAlert(false)
|
||||||
|
setDescription(initialDescriptionRef.current)
|
||||||
|
setParamDescriptions({ ...initialParamDescriptionsRef.current })
|
||||||
|
onOpenChange(false)
|
||||||
|
}, [onOpenChange])
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!workflowId) return
|
||||||
|
|
||||||
|
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||||
|
if (activeWorkflowId !== workflowId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
if (description.trim() !== (workflowMetadata?.description || '')) {
|
||||||
|
updateWorkflow(workflowId, { description: description.trim() || 'New workflow' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (starterBlockId) {
|
||||||
|
const updatedValue = inputFormat.map((field) => ({
|
||||||
|
...field,
|
||||||
|
description: paramDescriptions[field.name]?.trim() || undefined,
|
||||||
|
}))
|
||||||
|
setValue(starterBlockId, 'inputFormat', updatedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(false)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
workflowId,
|
||||||
|
description,
|
||||||
|
workflowMetadata,
|
||||||
|
updateWorkflow,
|
||||||
|
starterBlockId,
|
||||||
|
inputFormat,
|
||||||
|
paramDescriptions,
|
||||||
|
setValue,
|
||||||
|
onOpenChange,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
|
||||||
|
<ModalContent className='max-w-[480px]'>
|
||||||
|
<ModalHeader>
|
||||||
|
<span>Edit API Info</span>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody className='space-y-[12px]'>
|
||||||
|
<div>
|
||||||
|
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder='Describe what this workflow API does...'
|
||||||
|
className='min-h-[80px] resize-none'
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inputFormat.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
|
Parameters ({inputFormat.length})
|
||||||
|
</Label>
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
{inputFormat.map((field) => (
|
||||||
|
<div
|
||||||
|
key={field.name}
|
||||||
|
className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
|
||||||
|
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||||
|
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||||
|
{field.name}
|
||||||
|
</span>
|
||||||
|
<Badge size='sm'>{field.type || 'string'}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
|
||||||
|
<div className='flex flex-col gap-[6px]'>
|
||||||
|
<Label className='text-[13px]'>Description</Label>
|
||||||
|
<Input
|
||||||
|
value={paramDescriptions[field.name] || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleParamDescriptionChange(field.name, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={`Enter description for ${field.name}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant='default' onClick={handleCloseAttempt} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant='tertiary' onClick={handleSave} disabled={isSaving || !hasChanges}>
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
|
||||||
|
<ModalContent className='max-w-[400px]'>
|
||||||
|
<ModalHeader>
|
||||||
|
<span>Unsaved Changes</span>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p className='text-[14px] text-[var(--text-secondary)]'>
|
||||||
|
You have unsaved changes. Are you sure you want to discard them?
|
||||||
|
</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
|
||||||
|
Keep Editing
|
||||||
|
</Button>
|
||||||
|
<Button variant='destructive' onClick={handleDiscardChanges}>
|
||||||
|
Discard Changes
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
|||||||
import { A2aDeploy } from './components/a2a/a2a'
|
import { A2aDeploy } from './components/a2a/a2a'
|
||||||
import { ApiDeploy } from './components/api/api'
|
import { ApiDeploy } from './components/api/api'
|
||||||
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
||||||
|
import { ApiInfoModal } from './components/general/components/api-info-modal'
|
||||||
import { GeneralDeploy } from './components/general/general'
|
import { GeneralDeploy } from './components/general/general'
|
||||||
import { McpDeploy } from './components/mcp/mcp'
|
import { McpDeploy } from './components/mcp/mcp'
|
||||||
import { TemplateDeploy } from './components/template/template'
|
import { TemplateDeploy } from './components/template/template'
|
||||||
@@ -110,6 +111,7 @@ export function DeployModal({
|
|||||||
const [chatSuccess, setChatSuccess] = useState(false)
|
const [chatSuccess, setChatSuccess] = useState(false)
|
||||||
|
|
||||||
const [isCreateKeyModalOpen, setIsCreateKeyModalOpen] = useState(false)
|
const [isCreateKeyModalOpen, setIsCreateKeyModalOpen] = useState(false)
|
||||||
|
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false)
|
||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||||
const { config: permissionConfig } = usePermissionConfig()
|
const { config: permissionConfig } = usePermissionConfig()
|
||||||
@@ -389,11 +391,6 @@ export function DeployModal({
|
|||||||
form?.requestSubmit()
|
form?.requestSubmit()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleA2aFormSubmit = useCallback(() => {
|
|
||||||
const form = document.getElementById('a2a-deploy-form') as HTMLFormElement
|
|
||||||
form?.requestSubmit()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleA2aPublish = useCallback(() => {
|
const handleA2aPublish = useCallback(() => {
|
||||||
const form = document.getElementById('a2a-deploy-form')
|
const form = document.getElementById('a2a-deploy-form')
|
||||||
const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
|
const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
|
||||||
@@ -594,7 +591,11 @@ export function DeployModal({
|
|||||||
)}
|
)}
|
||||||
{activeTab === 'api' && (
|
{activeTab === 'api' && (
|
||||||
<ModalFooter className='items-center justify-between'>
|
<ModalFooter className='items-center justify-between'>
|
||||||
<div />
|
<div>
|
||||||
|
<Button variant='default' onClick={() => setIsApiInfoModalOpen(true)}>
|
||||||
|
Edit API Info
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Button
|
<Button
|
||||||
variant='tertiary'
|
variant='tertiary'
|
||||||
@@ -880,6 +881,14 @@ export function DeployModal({
|
|||||||
canManageWorkspaceKeys={canManageWorkspaceKeys}
|
canManageWorkspaceKeys={canManageWorkspaceKeys}
|
||||||
defaultKeyType={defaultKeyType}
|
defaultKeyType={defaultKeyType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{workflowId && (
|
||||||
|
<ApiInfoModal
|
||||||
|
open={isApiInfoModalOpen}
|
||||||
|
onOpenChange={setIsApiInfoModalOpen}
|
||||||
|
workflowId={workflowId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { InputFormatField } from '@/lib/workflows/types'
|
|||||||
export interface WorkflowInputField {
|
export interface WorkflowInputField {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +38,7 @@ export function extractInputFieldsFromBlocks(
|
|||||||
if (Array.isArray(inputFormat)) {
|
if (Array.isArray(inputFormat)) {
|
||||||
return inputFormat
|
return inputFormat
|
||||||
.filter(
|
.filter(
|
||||||
(field: unknown): field is { name: string; type?: string } =>
|
(field: unknown): field is { name: string; type?: string; description?: string } =>
|
||||||
typeof field === 'object' &&
|
typeof field === 'object' &&
|
||||||
field !== null &&
|
field !== null &&
|
||||||
'name' in field &&
|
'name' in field &&
|
||||||
@@ -47,6 +48,7 @@ export function extractInputFieldsFromBlocks(
|
|||||||
.map((field) => ({
|
.map((field) => ({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
type: field.type || 'string',
|
type: field.type || 'string',
|
||||||
|
...(field.description && { description: field.description }),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ export function extractInputFieldsFromBlocks(
|
|||||||
if (Array.isArray(legacyFormat)) {
|
if (Array.isArray(legacyFormat)) {
|
||||||
return legacyFormat
|
return legacyFormat
|
||||||
.filter(
|
.filter(
|
||||||
(field: unknown): field is { name: string; type?: string } =>
|
(field: unknown): field is { name: string; type?: string; description?: string } =>
|
||||||
typeof field === 'object' &&
|
typeof field === 'object' &&
|
||||||
field !== null &&
|
field !== null &&
|
||||||
'name' in field &&
|
'name' in field &&
|
||||||
@@ -67,6 +69,7 @@ export function extractInputFieldsFromBlocks(
|
|||||||
.map((field) => ({
|
.map((field) => ({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
type: field.type || 'string',
|
type: field.type || 'string',
|
||||||
|
...(field.description && { description: field.description }),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user