Skip to content

Commit 2eea3ca

Browse files
feat(import-export): improvements to export workspace, maintain file structure, include workflow variables (#1799)
* feat(import-export): improvements to export workspace, maintain file structure * fix type' * import/export variables * fix var ref id bug
1 parent fb445b1 commit 2eea3ca

File tree

9 files changed

+602
-110
lines changed

9 files changed

+602
-110
lines changed

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx

Lines changed: 191 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
88
import { createLogger } from '@/lib/logs/console/logger'
99
import { generateFolderName } from '@/lib/naming'
1010
import { cn } from '@/lib/utils'
11+
import {
12+
extractWorkflowName,
13+
extractWorkflowsFromFiles,
14+
extractWorkflowsFromZip,
15+
} from '@/lib/workflows/import-export'
1116
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
1217
import { useFolderStore } from '@/stores/folders/store'
1318
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
@@ -114,105 +119,202 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
114119
}
115120
}, [createFolder, workspaceId, isCreating])
116121

117-
const handleDirectImport = useCallback(
118-
async (content: string, filename?: string) => {
119-
if (!content.trim()) {
120-
logger.error('JSON content is required')
121-
return
122-
}
123-
124-
setIsImporting(true)
125-
126-
try {
127-
// First validate the JSON without importing
128-
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(content)
129-
130-
if (!workflowData || parseErrors.length > 0) {
131-
logger.error('Failed to parse JSON:', { errors: parseErrors })
132-
return
133-
}
134-
135-
// Generate workflow name from filename or fallback to time-based name
136-
const getWorkflowName = () => {
137-
if (filename) {
138-
// Remove file extension and use the filename
139-
const nameWithoutExtension = filename.replace(/\.json$/i, '')
140-
return (
141-
nameWithoutExtension.trim() || `Imported Workflow - ${new Date().toLocaleString()}`
142-
)
143-
}
144-
return `Imported Workflow - ${new Date().toLocaleString()}`
145-
}
146-
147-
// Clear workflow diff store when creating a new workflow from import
148-
const { clearDiff } = useWorkflowDiffStore.getState()
149-
clearDiff()
150-
151-
// Create a new workflow
152-
const newWorkflowId = await createWorkflow({
153-
name: getWorkflowName(),
154-
description: 'Workflow imported from JSON',
155-
workspaceId,
156-
})
157-
158-
// Save workflow state to database first
159-
const response = await fetch(`/api/workflows/${newWorkflowId}/state`, {
160-
method: 'PUT',
161-
headers: {
162-
'Content-Type': 'application/json',
163-
},
164-
body: JSON.stringify(workflowData),
165-
})
166-
167-
if (!response.ok) {
168-
logger.error('Failed to persist imported workflow to database')
169-
throw new Error('Failed to save workflow')
170-
}
171-
172-
logger.info('Imported workflow persisted to database')
173-
174-
// Pre-load the workflow state before navigating
175-
const { setActiveWorkflow } = useWorkflowRegistry.getState()
176-
await setActiveWorkflow(newWorkflowId)
177-
178-
// Navigate to the new workflow (replace to avoid history entry)
179-
router.replace(`/workspace/${workspaceId}/w/${newWorkflowId}`)
180-
181-
logger.info('Workflow imported successfully from JSON')
182-
} catch (error) {
183-
logger.error('Failed to import workflow:', { error })
184-
} finally {
185-
setIsImporting(false)
186-
}
187-
},
188-
[createWorkflow, workspaceId, router]
189-
)
190-
191122
const handleImportWorkflow = useCallback(() => {
192123
setIsOpen(false)
193124
fileInputRef.current?.click()
194125
}, [])
195126

196127
const handleFileChange = useCallback(
197128
async (event: React.ChangeEvent<HTMLInputElement>) => {
198-
const file = event.target.files?.[0]
199-
if (!file) return
129+
const files = event.target.files
130+
if (!files || files.length === 0) return
131+
132+
setIsImporting(true)
200133

201134
try {
202-
const content = await file.text()
135+
const fileArray = Array.from(files)
136+
const hasZip = fileArray.some((f) => f.name.toLowerCase().endsWith('.zip'))
137+
const jsonFiles = fileArray.filter((f) => f.name.toLowerCase().endsWith('.json'))
138+
139+
let importedWorkflows: Array<{ content: string; name: string; folderPath: string[] }> = []
140+
141+
if (hasZip && fileArray.length === 1) {
142+
const zipFile = fileArray[0]
143+
const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile)
144+
importedWorkflows = extractedWorkflows
145+
146+
const { createFolder } = useFolderStore.getState()
147+
const folderName = metadata?.workspaceName || zipFile.name.replace(/\.zip$/i, '')
148+
const importFolder = await createFolder({
149+
name: folderName,
150+
workspaceId,
151+
})
152+
153+
const folderMap = new Map<string, string>()
154+
155+
for (const workflow of importedWorkflows) {
156+
try {
157+
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(
158+
workflow.content
159+
)
160+
161+
if (!workflowData || parseErrors.length > 0) {
162+
logger.warn(`Failed to parse ${workflow.name}:`, parseErrors)
163+
continue
164+
}
165+
166+
let targetFolderId = importFolder.id
167+
168+
if (workflow.folderPath.length > 0) {
169+
const folderPathKey = workflow.folderPath.join('/')
170+
171+
if (!folderMap.has(folderPathKey)) {
172+
let parentId = importFolder.id
173+
174+
for (let i = 0; i < workflow.folderPath.length; i++) {
175+
const pathSegment = workflow.folderPath.slice(0, i + 1).join('/')
176+
177+
if (!folderMap.has(pathSegment)) {
178+
const subFolder = await createFolder({
179+
name: workflow.folderPath[i],
180+
workspaceId,
181+
parentId,
182+
})
183+
folderMap.set(pathSegment, subFolder.id)
184+
parentId = subFolder.id
185+
} else {
186+
parentId = folderMap.get(pathSegment)!
187+
}
188+
}
189+
}
190+
191+
targetFolderId = folderMap.get(folderPathKey)!
192+
}
193+
194+
const workflowName = extractWorkflowName(workflow.content)
195+
const { clearDiff } = useWorkflowDiffStore.getState()
196+
clearDiff()
197+
198+
const newWorkflowId = await createWorkflow({
199+
name: workflowName,
200+
description: 'Imported from workspace export',
201+
workspaceId,
202+
folderId: targetFolderId,
203+
})
204+
205+
const response = await fetch(`/api/workflows/${newWorkflowId}/state`, {
206+
method: 'PUT',
207+
headers: {
208+
'Content-Type': 'application/json',
209+
},
210+
body: JSON.stringify(workflowData),
211+
})
212+
213+
if (!response.ok) {
214+
logger.error(`Failed to save imported workflow ${newWorkflowId}`)
215+
continue
216+
}
217+
218+
if (workflowData.variables && workflowData.variables.length > 0) {
219+
const variablesPayload = workflowData.variables.map((v: any) => ({
220+
id: typeof v.id === 'string' && v.id.trim() ? v.id : crypto.randomUUID(),
221+
workflowId: newWorkflowId,
222+
name: v.name,
223+
type: v.type,
224+
value: v.value,
225+
}))
226+
227+
await fetch(`/api/workflows/${newWorkflowId}/variables`, {
228+
method: 'POST',
229+
headers: {
230+
'Content-Type': 'application/json',
231+
},
232+
body: JSON.stringify({ variables: variablesPayload }),
233+
})
234+
}
235+
236+
logger.info(`Imported workflow: ${workflowName}`)
237+
} catch (error) {
238+
logger.error(`Failed to import ${workflow.name}:`, error)
239+
}
240+
}
241+
} else if (jsonFiles.length > 0) {
242+
importedWorkflows = await extractWorkflowsFromFiles(jsonFiles)
243+
244+
for (const workflow of importedWorkflows) {
245+
try {
246+
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(
247+
workflow.content
248+
)
249+
250+
if (!workflowData || parseErrors.length > 0) {
251+
logger.warn(`Failed to parse ${workflow.name}:`, parseErrors)
252+
continue
253+
}
254+
255+
const workflowName = extractWorkflowName(workflow.content)
256+
const { clearDiff } = useWorkflowDiffStore.getState()
257+
clearDiff()
258+
259+
const newWorkflowId = await createWorkflow({
260+
name: workflowName,
261+
description: 'Imported from JSON',
262+
workspaceId,
263+
})
264+
265+
const response = await fetch(`/api/workflows/${newWorkflowId}/state`, {
266+
method: 'PUT',
267+
headers: {
268+
'Content-Type': 'application/json',
269+
},
270+
body: JSON.stringify(workflowData),
271+
})
272+
273+
if (!response.ok) {
274+
logger.error(`Failed to save imported workflow ${newWorkflowId}`)
275+
continue
276+
}
277+
278+
if (workflowData.variables && workflowData.variables.length > 0) {
279+
const variablesPayload = workflowData.variables.map((v: any) => ({
280+
id: typeof v.id === 'string' && v.id.trim() ? v.id : crypto.randomUUID(),
281+
workflowId: newWorkflowId,
282+
name: v.name,
283+
type: v.type,
284+
value: v.value,
285+
}))
286+
287+
await fetch(`/api/workflows/${newWorkflowId}/variables`, {
288+
method: 'POST',
289+
headers: {
290+
'Content-Type': 'application/json',
291+
},
292+
body: JSON.stringify({ variables: variablesPayload }),
293+
})
294+
}
295+
296+
logger.info(`Imported workflow: ${workflowName}`)
297+
} catch (error) {
298+
logger.error(`Failed to import ${workflow.name}:`, error)
299+
}
300+
}
301+
}
203302

204-
// Import directly with filename
205-
await handleDirectImport(content, file.name)
206-
} catch (error) {
207-
logger.error('Failed to read file:', { error })
208-
}
303+
const { loadWorkflows } = useWorkflowRegistry.getState()
304+
await loadWorkflows(workspaceId)
209305

210-
// Reset file input
211-
if (fileInputRef.current) {
212-
fileInputRef.current.value = ''
306+
const { fetchFolders } = useFolderStore.getState()
307+
await fetchFolders(workspaceId)
308+
} catch (error) {
309+
logger.error('Failed to import workflows:', error)
310+
} finally {
311+
setIsImporting(false)
312+
if (fileInputRef.current) {
313+
fileInputRef.current.value = ''
314+
}
213315
}
214316
},
215-
[handleDirectImport]
317+
[workspaceId, createWorkflow]
216318
)
217319

218320
// Button event handlers
@@ -360,7 +462,7 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
360462
>
361463
<Download className={iconClassName} />
362464
<span className={textClassName}>
363-
{isImporting ? 'Importing...' : 'Import workflow'}
465+
{isImporting ? 'Importing...' : 'Import Workflows'}
364466
</span>
365467
</button>
366468
</PopoverContent>
@@ -369,7 +471,8 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
369471
<input
370472
ref={fileInputRef}
371473
type='file'
372-
accept='.json'
474+
accept='.json,.zip'
475+
multiple
373476
style={{ display: 'none' }}
374477
onChange={handleFileChange}
375478
/>

0 commit comments

Comments
 (0)