Skip to content

Commit a2c794a

Browse files
committed
added ability to edit parameter and workflow descriptions
1 parent 530a329 commit a2c794a

File tree

5 files changed

+289
-84
lines changed

5 files changed

+289
-84
lines changed

apps/sim/app/api/v1/workflows/[id]/route.ts

Lines changed: 7 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,54 +3,14 @@ import { permissions, workflow, workflowBlocks } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
6-
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
6+
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
77
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
88
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
99

1010
const logger = createLogger('V1WorkflowDetailsAPI')
1111

1212
export const revalidate = 0
1313

14-
interface InputField {
15-
name: string
16-
type: string
17-
description?: string
18-
}
19-
20-
/**
21-
* Extracts input fields from workflow blocks.
22-
* Finds the starter/trigger block and extracts its inputFormat configuration.
23-
*/
24-
function extractInputFields(blocks: Array<{ type: string; subBlocks: unknown }>): InputField[] {
25-
const starterBlock = blocks.find((block) => isValidStartBlockType(block.type))
26-
27-
if (!starterBlock) {
28-
return []
29-
}
30-
31-
const subBlocks = starterBlock.subBlocks as Record<string, { value?: unknown }> | undefined
32-
const inputFormat = subBlocks?.inputFormat?.value
33-
34-
if (!Array.isArray(inputFormat)) {
35-
return []
36-
}
37-
38-
return inputFormat
39-
.filter(
40-
(field: unknown): field is { name: string; type?: string; description?: string } =>
41-
typeof field === 'object' &&
42-
field !== null &&
43-
'name' in field &&
44-
typeof (field as { name: unknown }).name === 'string' &&
45-
(field as { name: string }).name.trim() !== ''
46-
)
47-
.map((field) => ({
48-
name: field.name,
49-
type: field.type || 'string',
50-
...(field.description && { description: field.description }),
51-
}))
52-
}
53-
5414
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
5515
const requestId = crypto.randomUUID().slice(0, 8)
5616

@@ -98,15 +58,19 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
9858
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
9959
}
10060

101-
const blocks = await db
61+
const blockRows = await db
10262
.select({
63+
id: workflowBlocks.id,
10364
type: workflowBlocks.type,
10465
subBlocks: workflowBlocks.subBlocks,
10566
})
10667
.from(workflowBlocks)
10768
.where(eq(workflowBlocks.workflowId, id))
10869

109-
const inputs = extractInputFields(blocks)
70+
const blocksRecord = Object.fromEntries(
71+
blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }])
72+
)
73+
const inputs = extractInputFieldsFromBlocks(blocksRecord)
11074

11175
const response = {
11276
id: workflowData.id,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -452,39 +452,6 @@ console.log(limits);`
452452
</div>
453453
)}
454454

455-
{/* <div>
456-
<div className='mb-[6.5px] flex items-center justify-between'>
457-
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
458-
URL
459-
</Label>
460-
<Tooltip.Root>
461-
<Tooltip.Trigger asChild>
462-
<Button
463-
variant='ghost'
464-
onClick={() => handleCopy('endpoint', info.endpoint)}
465-
aria-label='Copy endpoint'
466-
className='!p-1.5 -my-1.5'
467-
>
468-
{copied.endpoint ? (
469-
<Check className='h-3 w-3' />
470-
) : (
471-
<Clipboard className='h-3 w-3' />
472-
)}
473-
</Button>
474-
</Tooltip.Trigger>
475-
<Tooltip.Content>
476-
<span>{copied.endpoint ? 'Copied' : 'Copy'}</span>
477-
</Tooltip.Content>
478-
</Tooltip.Root>
479-
</div>
480-
<Code.Viewer
481-
code={info.endpoint}
482-
language='javascript'
483-
wrapText
484-
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
485-
/>
486-
</div> */}
487-
488455
<div>
489456
<div className='mb-[6.5px] flex items-center justify-between'>
490457
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
'use client'
2+
3+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4+
import {
5+
Badge,
6+
Button,
7+
Input,
8+
Label,
9+
Modal,
10+
ModalBody,
11+
ModalContent,
12+
ModalFooter,
13+
ModalHeader,
14+
Textarea,
15+
} from '@/components/emcn'
16+
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
17+
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
18+
import type { InputFormatField } from '@/lib/workflows/types'
19+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
20+
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
21+
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
22+
23+
type NormalizedField = InputFormatField & { name: string }
24+
25+
interface ApiInfoModalProps {
26+
open: boolean
27+
onOpenChange: (open: boolean) => void
28+
workflowId: string
29+
}
30+
31+
export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalProps) {
32+
const blocks = useWorkflowStore((state) => state.blocks)
33+
const setValue = useSubBlockStore((state) => state.setValue)
34+
const subBlockValues = useSubBlockStore((state) =>
35+
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
36+
)
37+
38+
const workflowMetadata = useWorkflowRegistry((state) =>
39+
workflowId ? state.workflows[workflowId] : undefined
40+
)
41+
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
42+
43+
const [description, setDescription] = useState('')
44+
const [paramDescriptions, setParamDescriptions] = useState<Record<string, string>>({})
45+
const [isSaving, setIsSaving] = useState(false)
46+
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
47+
48+
const initialDescriptionRef = useRef('')
49+
const initialParamDescriptionsRef = useRef<Record<string, string>>({})
50+
51+
const starterBlockId = useMemo(() => {
52+
for (const [blockId, block] of Object.entries(blocks)) {
53+
if (!block || typeof block !== 'object') continue
54+
const blockType = (block as { type?: string }).type
55+
if (blockType && isValidStartBlockType(blockType)) {
56+
return blockId
57+
}
58+
}
59+
return null
60+
}, [blocks])
61+
62+
const inputFormat = useMemo((): NormalizedField[] => {
63+
if (!starterBlockId) return []
64+
65+
const storeValue = subBlockValues[starterBlockId]?.inputFormat
66+
const normalized = normalizeInputFormatValue(storeValue) as NormalizedField[]
67+
if (normalized.length > 0) return normalized
68+
69+
const startBlock = blocks[starterBlockId]
70+
const blockValue = startBlock?.subBlocks?.inputFormat?.value
71+
return normalizeInputFormatValue(blockValue) as NormalizedField[]
72+
}, [starterBlockId, subBlockValues, blocks])
73+
74+
useEffect(() => {
75+
if (open) {
76+
const normalizedDesc = workflowMetadata?.description?.toLowerCase().trim()
77+
const isDefaultDescription =
78+
!workflowMetadata?.description ||
79+
workflowMetadata.description === workflowMetadata.name ||
80+
normalizedDesc === 'new workflow' ||
81+
normalizedDesc === 'your first workflow - start building here!'
82+
83+
const initialDescription = isDefaultDescription ? '' : workflowMetadata?.description || ''
84+
setDescription(initialDescription)
85+
initialDescriptionRef.current = initialDescription
86+
87+
const descriptions: Record<string, string> = {}
88+
for (const field of inputFormat) {
89+
if (field.description) {
90+
descriptions[field.name] = field.description
91+
}
92+
}
93+
setParamDescriptions(descriptions)
94+
initialParamDescriptionsRef.current = { ...descriptions }
95+
}
96+
}, [open, workflowMetadata, inputFormat])
97+
98+
const hasChanges = useMemo(() => {
99+
if (description !== initialDescriptionRef.current) return true
100+
101+
const currentKeys = Object.keys(paramDescriptions)
102+
const initialKeys = Object.keys(initialParamDescriptionsRef.current)
103+
104+
if (currentKeys.length !== initialKeys.length) return true
105+
106+
for (const key of currentKeys) {
107+
const currentValue = paramDescriptions[key] || ''
108+
const initialValue = initialParamDescriptionsRef.current[key] || ''
109+
if (currentValue !== initialValue) return true
110+
}
111+
112+
return false
113+
}, [description, paramDescriptions])
114+
115+
const handleParamDescriptionChange = (fieldName: string, value: string) => {
116+
setParamDescriptions((prev) => ({
117+
...prev,
118+
[fieldName]: value,
119+
}))
120+
}
121+
122+
const handleCloseAttempt = useCallback(() => {
123+
if (hasChanges && !isSaving) {
124+
setShowUnsavedChangesAlert(true)
125+
} else {
126+
onOpenChange(false)
127+
}
128+
}, [hasChanges, isSaving, onOpenChange])
129+
130+
const handleDiscardChanges = useCallback(() => {
131+
setShowUnsavedChangesAlert(false)
132+
setDescription(initialDescriptionRef.current)
133+
setParamDescriptions({ ...initialParamDescriptionsRef.current })
134+
onOpenChange(false)
135+
}, [onOpenChange])
136+
137+
const handleSave = useCallback(async () => {
138+
if (!workflowId) return
139+
140+
setIsSaving(true)
141+
try {
142+
if (description.trim() !== (workflowMetadata?.description || '')) {
143+
updateWorkflow(workflowId, { description: description.trim() || 'New workflow' })
144+
}
145+
146+
if (starterBlockId) {
147+
const currentValue = subBlockValues[starterBlockId]?.inputFormat || inputFormat
148+
const updatedValue = (currentValue as NormalizedField[]).map((field) => ({
149+
...field,
150+
description: paramDescriptions[field.name]?.trim() || undefined,
151+
}))
152+
setValue(starterBlockId, 'inputFormat', updatedValue)
153+
}
154+
155+
onOpenChange(false)
156+
} finally {
157+
setIsSaving(false)
158+
}
159+
}, [
160+
workflowId,
161+
description,
162+
workflowMetadata,
163+
updateWorkflow,
164+
starterBlockId,
165+
subBlockValues,
166+
inputFormat,
167+
paramDescriptions,
168+
setValue,
169+
onOpenChange,
170+
])
171+
172+
return (
173+
<>
174+
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
175+
<ModalContent className='max-w-[480px]'>
176+
<ModalHeader>
177+
<span>Edit API Info</span>
178+
</ModalHeader>
179+
<ModalBody className='space-y-[12px]'>
180+
<div>
181+
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
182+
Description
183+
</Label>
184+
<Textarea
185+
placeholder='Describe what this workflow API does...'
186+
className='min-h-[80px] resize-none'
187+
value={description}
188+
onChange={(e) => setDescription(e.target.value)}
189+
/>
190+
</div>
191+
192+
{inputFormat.length > 0 && (
193+
<div>
194+
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
195+
Parameters ({inputFormat.length})
196+
</Label>
197+
<div className='flex flex-col gap-[8px]'>
198+
{inputFormat.map((field) => (
199+
<div
200+
key={field.name}
201+
className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'
202+
>
203+
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
204+
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
205+
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
206+
{field.name}
207+
</span>
208+
<Badge size='sm'>{field.type || 'string'}</Badge>
209+
</div>
210+
</div>
211+
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
212+
<div className='flex flex-col gap-[6px]'>
213+
<Label className='text-[13px]'>Description</Label>
214+
<Input
215+
value={paramDescriptions[field.name] || ''}
216+
onChange={(e) =>
217+
handleParamDescriptionChange(field.name, e.target.value)
218+
}
219+
placeholder={`Enter description for ${field.name}`}
220+
/>
221+
</div>
222+
</div>
223+
</div>
224+
))}
225+
</div>
226+
</div>
227+
)}
228+
</ModalBody>
229+
<ModalFooter>
230+
<Button variant='default' onClick={handleCloseAttempt} disabled={isSaving}>
231+
Cancel
232+
</Button>
233+
<Button variant='tertiary' onClick={handleSave} disabled={isSaving || !hasChanges}>
234+
{isSaving ? 'Saving...' : 'Save'}
235+
</Button>
236+
</ModalFooter>
237+
</ModalContent>
238+
</Modal>
239+
240+
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
241+
<ModalContent className='max-w-[400px]'>
242+
<ModalHeader>
243+
<span>Unsaved Changes</span>
244+
</ModalHeader>
245+
<ModalBody>
246+
<p className='text-[14px] text-[var(--text-secondary)]'>
247+
You have unsaved changes. Are you sure you want to discard them?
248+
</p>
249+
</ModalBody>
250+
<ModalFooter>
251+
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
252+
Keep Editing
253+
</Button>
254+
<Button variant='destructive' onClick={handleDiscardChanges}>
255+
Discard Changes
256+
</Button>
257+
</ModalFooter>
258+
</ModalContent>
259+
</Modal>
260+
</>
261+
)
262+
}

0 commit comments

Comments
 (0)