Skip to content

Commit e25214f

Browse files
authored
ensure duplicated blocks have unique '<Base> N' names using normalized comparison; unify client naming via shared helper and wire into duplicate flows; keep server unchanged (#1273)
1 parent 784992f commit e25214f

4 files changed

Lines changed: 80 additions & 40 deletions

File tree

apps/sim/hooks/use-collaborative-workflow.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'
22
import type { Edge } from 'reactflow'
33
import { useSession } from '@/lib/auth-client'
44
import { createLogger } from '@/lib/logs/console/logger'
5+
import { generateUniqueBlockDuplicateName } from '@/lib/naming'
56
import { getBlock } from '@/blocks'
67
import { resolveOutputType } from '@/blocks/utils'
78
import { useSocket } from '@/contexts/socket-context'
@@ -960,10 +961,8 @@ export function useCollaborativeWorkflow() {
960961
y: sourceBlock.position.y + 20,
961962
}
962963

963-
const match = sourceBlock.name.match(/(.*?)(\d+)?$/)
964-
const newName = match?.[2]
965-
? `${match[1]}${Number.parseInt(match[2]) + 1}`
966-
: `${sourceBlock.name} 1`
964+
const existingNames = Object.values(workflowStore.blocks).map((b) => b.name)
965+
const newName = generateUniqueBlockDuplicateName(existingNames, sourceBlock.name)
967966

968967
// Get subblock values from the store
969968
const subBlockValues = subBlockStore.workflowValues[activeWorkflowId || '']?.[sourceId] || {}

apps/sim/lib/naming.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { generateUniqueBlockDuplicateName, normalizeBlockName } from '@/lib/naming'
3+
4+
describe('block naming helpers', () => {
5+
it('normalizes names by lowercasing and removing spaces only', () => {
6+
expect(normalizeBlockName('My Agent')).toBe('myagent')
7+
expect(normalizeBlockName(' My Agent ')).toBe('myagent')
8+
expect(normalizeBlockName('My__Agent')).toBe('my__agent')
9+
})
10+
11+
it('duplicates base without suffix as "Base 1"', () => {
12+
const existing = ['Agent']
13+
expect(generateUniqueBlockDuplicateName(existing, 'Agent')).toBe('Agent 1')
14+
})
15+
16+
it('skips to next available when immediate next collides (normalized)', () => {
17+
const existing = ['Agent', 'agent1']
18+
expect(generateUniqueBlockDuplicateName(existing, 'Agent')).toBe('Agent 2')
19+
})
20+
21+
it('increments numeric suffix when present and finds next free', () => {
22+
const existing = ['Agent', 'Agent 5', 'Agent 6']
23+
expect(generateUniqueBlockDuplicateName(existing, 'Agent 5')).toBe('Agent 7')
24+
})
25+
26+
it('handles names with no whitespace before digits as new base', () => {
27+
const existing = ['Agent5']
28+
expect(generateUniqueBlockDuplicateName(existing, 'Agent5')).toBe('Agent5 1')
29+
})
30+
31+
it('handles multiple spaces and prevents normalized collisions', () => {
32+
const existing = ['myagent1', 'My Agent']
33+
expect(generateUniqueBlockDuplicateName(existing, 'My Agent')).toBe('My Agent 2')
34+
})
35+
36+
it('fills gaps by choosing the next available number', () => {
37+
const existing = ['Agent', 'Agent 1', 'Agent 3', 'Agent 4']
38+
expect(generateUniqueBlockDuplicateName(existing, 'Agent')).toBe('Agent 2')
39+
})
40+
41+
it('falls back to "Block" base for empty or whitespace-only names', () => {
42+
const existing1: string[] = []
43+
expect(generateUniqueBlockDuplicateName(existing1, '')).toBe('Block 1')
44+
45+
const existing2 = ['Block 1']
46+
expect(generateUniqueBlockDuplicateName(existing2, ' ')).toBe('Block 2')
47+
})
48+
})

apps/sim/lib/naming.ts

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,32 @@ const NOUNS = [
159159
'Crumpet',
160160
]
161161

162+
export function normalizeBlockName(name: string): string {
163+
return name.toLowerCase().replace(/\s+/g, '')
164+
}
165+
166+
export function generateUniqueBlockDuplicateName(
167+
existingNames: string[],
168+
sourceName: string
169+
): string {
170+
const normalizedSet = new Set(
171+
(existingNames || []).filter((n) => typeof n === 'string').map((n) => normalizeBlockName(n))
172+
)
173+
174+
const trimmed = (sourceName || '').trim()
175+
const match = trimmed.match(/^(.*?)(?:\s+(\d+))?$/)
176+
const baseRaw = match ? match[1] || '' : trimmed
177+
const base = baseRaw.trim() || 'Block'
178+
const start = match?.[2] ? Number.parseInt(match[2], 10) + 1 : 1
179+
180+
let n = start
181+
while (true) {
182+
const candidate = `${base} ${n}`
183+
if (!normalizedSet.has(normalizeBlockName(candidate))) return candidate
184+
n += 1
185+
}
186+
}
187+
162188
/**
163189
* Generates the next incremental name for entities following pattern: "{prefix} {number}"
164190
*
@@ -170,67 +196,41 @@ export function generateIncrementalName<T extends NameableEntity>(
170196
existingEntities: T[],
171197
prefix: string
172198
): string {
173-
// Create regex pattern for the prefix (e.g., /^Workspace (\d+)$/)
174199
const pattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} (\\d+)$`)
175-
176-
// Extract numbers from existing entities that match the pattern
177200
const existingNumbers = existingEntities
178201
.map((entity) => entity.name.match(pattern))
179202
.filter((match) => match !== null)
180203
.map((match) => Number.parseInt(match![1], 10))
181-
182-
// Find next available number (highest + 1, or 1 if none exist)
183204
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
184-
185205
return `${prefix} ${nextNumber}`
186206
}
187207

188-
/**
189-
* Generates the next workspace name
190-
*/
191208
export async function generateWorkspaceName(): Promise<string> {
192209
const response = await fetch('/api/workspaces')
193210
const data = (await response.json()) as WorkspacesApiResponse
194211
const workspaces = data.workspaces || []
195-
196212
return generateIncrementalName(workspaces, 'Workspace')
197213
}
198214

199-
/**
200-
* Generates the next folder name for a workspace
201-
*/
202215
export async function generateFolderName(workspaceId: string): Promise<string> {
203216
const response = await fetch(`/api/folders?workspaceId=${workspaceId}`)
204217
const data = (await response.json()) as FoldersApiResponse
205218
const folders = data.folders || []
206-
207-
// Filter to only root-level folders (parentId is null)
208219
const rootFolders = folders.filter((folder) => folder.parentId === null)
209-
210220
return generateIncrementalName(rootFolders, 'Folder')
211221
}
212222

213-
/**
214-
* Generates the next subfolder name for a parent folder
215-
*/
216223
export async function generateSubfolderName(
217224
workspaceId: string,
218225
parentFolderId: string
219226
): Promise<string> {
220227
const response = await fetch(`/api/folders?workspaceId=${workspaceId}`)
221228
const data = (await response.json()) as FoldersApiResponse
222229
const folders = data.folders || []
223-
224-
// Filter to only subfolders of the specified parent
225230
const subfolders = folders.filter((folder) => folder.parentId === parentFolderId)
226-
227231
return generateIncrementalName(subfolders, 'Subfolder')
228232
}
229233

230-
/**
231-
* Generates a creative workflow name using random adjectives and nouns
232-
* @returns A creative workflow name like "blazing-phoenix" or "crystal-dragon"
233-
*/
234234
export function generateCreativeWorkflowName(): string {
235235
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
236236
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]

apps/sim/stores/workflows/workflow/store.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Edge } from 'reactflow'
22
import { create } from 'zustand'
33
import { devtools } from 'zustand/middleware'
44
import { createLogger } from '@/lib/logs/console/logger'
5+
import { generateUniqueBlockDuplicateName, normalizeBlockName } from '@/lib/naming'
56
import { getBlock } from '@/blocks'
67
import { resolveOutputType } from '@/blocks/utils'
78
import {
@@ -521,11 +522,8 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
521522
y: block.position.y + 20,
522523
}
523524

524-
// More efficient name handling
525-
const match = block.name.match(/(.*?)(\d+)?$/)
526-
const newName = match?.[2]
527-
? `${match[1]}${Number.parseInt(match[2]) + 1}`
528-
: `${block.name} 1`
525+
const existingNames = Object.values(get().blocks).map((b: any) => b.name)
526+
const newName = generateUniqueBlockDuplicateName(existingNames, block.name)
529527

530528
// Get merged state to capture current subblock values
531529
const mergedBlock = mergeSubblockState(get().blocks, id)[id]
@@ -602,11 +600,6 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
602600
const oldBlock = get().blocks[id]
603601
if (!oldBlock) return false
604602

605-
// Helper function to normalize block names (same as resolver)
606-
const normalizeBlockName = (blockName: string): string => {
607-
return blockName.toLowerCase().replace(/\s+/g, '')
608-
}
609-
610603
// Check for normalized name collisions
611604
const normalizedNewName = normalizeBlockName(name)
612605
const currentBlocks = get().blocks

0 commit comments

Comments
 (0)