Skip to content

Commit aa99db6

Browse files
fix(subflows): tag dropdown + resolution logic (#2949)
* fix(subflows): tag dropdown + resolution logic * fixes; * revert parallel change
1 parent 748793e commit aa99db6

File tree

10 files changed

+219
-68
lines changed

10 files changed

+219
-68
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,44 +1312,48 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
13121312
if (currentLoop && isLoopBlock) {
13131313
containingLoopBlockId = blockId
13141314
const loopType = currentLoop.loopType || 'for'
1315-
const contextualTags: string[] = ['index']
1316-
if (loopType === 'forEach') {
1317-
contextualTags.push('currentItem')
1318-
contextualTags.push('items')
1319-
}
13201315

13211316
const loopBlock = blocks[blockId]
13221317
if (loopBlock) {
13231318
const loopBlockName = loopBlock.name || loopBlock.type
1319+
const normalizedLoopName = normalizeName(loopBlockName)
1320+
const contextualTags: string[] = [`${normalizedLoopName}.index`]
1321+
if (loopType === 'forEach') {
1322+
contextualTags.push(`${normalizedLoopName}.currentItem`)
1323+
contextualTags.push(`${normalizedLoopName}.items`)
1324+
}
13241325

13251326
loopBlockGroup = {
13261327
blockName: loopBlockName,
13271328
blockId: blockId,
13281329
blockType: 'loop',
13291330
tags: contextualTags,
13301331
distance: 0,
1332+
isContextual: true,
13311333
}
13321334
}
13331335
} else if (containingLoop) {
13341336
const [loopId, loop] = containingLoop
13351337
containingLoopBlockId = loopId
13361338
const loopType = loop.loopType || 'for'
1337-
const contextualTags: string[] = ['index']
1338-
if (loopType === 'forEach') {
1339-
contextualTags.push('currentItem')
1340-
contextualTags.push('items')
1341-
}
13421339

13431340
const containingLoopBlock = blocks[loopId]
13441341
if (containingLoopBlock) {
13451342
const loopBlockName = containingLoopBlock.name || containingLoopBlock.type
1343+
const normalizedLoopName = normalizeName(loopBlockName)
1344+
const contextualTags: string[] = [`${normalizedLoopName}.index`]
1345+
if (loopType === 'forEach') {
1346+
contextualTags.push(`${normalizedLoopName}.currentItem`)
1347+
contextualTags.push(`${normalizedLoopName}.items`)
1348+
}
13461349

13471350
loopBlockGroup = {
13481351
blockName: loopBlockName,
13491352
blockId: loopId,
13501353
blockType: 'loop',
13511354
tags: contextualTags,
13521355
distance: 0,
1356+
isContextual: true,
13531357
}
13541358
}
13551359
}
@@ -1363,22 +1367,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
13631367
const [parallelId, parallel] = containingParallel
13641368
containingParallelBlockId = parallelId
13651369
const parallelType = parallel.parallelType || 'count'
1366-
const contextualTags: string[] = ['index']
1367-
if (parallelType === 'collection') {
1368-
contextualTags.push('currentItem')
1369-
contextualTags.push('items')
1370-
}
13711370

13721371
const containingParallelBlock = blocks[parallelId]
13731372
if (containingParallelBlock) {
13741373
const parallelBlockName = containingParallelBlock.name || containingParallelBlock.type
1374+
const normalizedParallelName = normalizeName(parallelBlockName)
1375+
const contextualTags: string[] = [`${normalizedParallelName}.index`]
1376+
if (parallelType === 'collection') {
1377+
contextualTags.push(`${normalizedParallelName}.currentItem`)
1378+
contextualTags.push(`${normalizedParallelName}.items`)
1379+
}
13751380

13761381
parallelBlockGroup = {
13771382
blockName: parallelBlockName,
13781383
blockId: parallelId,
13791384
blockType: 'parallel',
13801385
tags: contextualTags,
13811386
distance: 0,
1387+
isContextual: true,
13821388
}
13831389
}
13841390
}
@@ -1645,38 +1651,29 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
16451651
const nestedBlockTagGroups: NestedBlockTagGroup[] = useMemo(() => {
16461652
return filteredBlockTagGroups.map((group: BlockTagGroup) => {
16471653
const normalizedBlockName = normalizeName(group.blockName)
1648-
1649-
// Handle loop/parallel contextual tags (index, currentItem, items)
16501654
const directTags: NestedTag[] = []
16511655
const tagsForTree: string[] = []
16521656

16531657
group.tags.forEach((tag: string) => {
16541658
const tagParts = tag.split('.')
16551659

1656-
// Loop/parallel contextual tags without block prefix
1657-
if (
1658-
(group.blockType === 'loop' || group.blockType === 'parallel') &&
1659-
tagParts.length === 1
1660-
) {
1660+
if (tagParts.length === 1) {
16611661
directTags.push({
16621662
key: tag,
16631663
display: tag,
16641664
fullTag: tag,
16651665
})
16661666
} else if (tagParts.length === 2) {
1667-
// Direct property like blockname.property
16681667
directTags.push({
16691668
key: tagParts[1],
16701669
display: tagParts[1],
16711670
fullTag: tag,
16721671
})
16731672
} else {
1674-
// Nested property - add to tree builder
16751673
tagsForTree.push(tag)
16761674
}
16771675
})
16781676

1679-
// Build recursive tree from nested tags
16801677
const nestedTags = [...directTags, ...buildNestedTagTree(tagsForTree, normalizedBlockName)]
16811678

16821679
return {
@@ -1800,13 +1797,19 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
18001797
processedTag = tag
18011798
}
18021799
} else if (
1803-
blockGroup &&
1800+
blockGroup?.isContextual &&
18041801
(blockGroup.blockType === 'loop' || blockGroup.blockType === 'parallel')
18051802
) {
1806-
if (!tag.includes('.') && ['index', 'currentItem', 'items'].includes(tag)) {
1807-
processedTag = `${blockGroup.blockType}.${tag}`
1803+
const tagParts = tag.split('.')
1804+
if (tagParts.length === 1) {
1805+
processedTag = blockGroup.blockType
18081806
} else {
1809-
processedTag = tag
1807+
const lastPart = tagParts[tagParts.length - 1]
1808+
if (['index', 'currentItem', 'items'].includes(lastPart)) {
1809+
processedTag = `${blockGroup.blockType}.${lastPart}`
1810+
} else {
1811+
processedTag = tag
1812+
}
18101813
}
18111814
}
18121815

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export interface BlockTagGroup {
77
blockType: string
88
tags: string[]
99
distance: number
10+
/** True if this is a contextual group (loop/parallel iteration context available inside the subflow) */
11+
isContextual?: boolean
1012
}
1113

1214
/**

apps/sim/executor/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ export const SPECIAL_REFERENCE_PREFIXES = [
120120
REFERENCE.PREFIX.VARIABLE,
121121
] as const
122122

123+
export const RESERVED_BLOCK_NAMES = [
124+
REFERENCE.PREFIX.LOOP,
125+
REFERENCE.PREFIX.PARALLEL,
126+
REFERENCE.PREFIX.VARIABLE,
127+
] as const
128+
123129
export const LOOP_REFERENCE = {
124130
ITERATION: 'iteration',
125131
INDEX: 'index',

apps/sim/executor/variables/resolvers/loop.test.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { loggerMock } from '@sim/testing'
22
import { describe, expect, it, vi } from 'vitest'
33
import type { LoopScope } from '@/executor/execution/state'
4+
import { InvalidFieldError } from '@/executor/utils/block-reference'
45
import { LoopResolver } from './loop'
56
import type { ResolutionContext } from './reference'
67

@@ -62,7 +63,12 @@ function createTestContext(
6263

6364
describe('LoopResolver', () => {
6465
describe('canResolve', () => {
65-
it.concurrent('should return true for loop references', () => {
66+
it.concurrent('should return true for bare loop reference', () => {
67+
const resolver = new LoopResolver(createTestWorkflow())
68+
expect(resolver.canResolve('<loop>')).toBe(true)
69+
})
70+
71+
it.concurrent('should return true for known loop properties', () => {
6672
const resolver = new LoopResolver(createTestWorkflow())
6773
expect(resolver.canResolve('<loop.index>')).toBe(true)
6874
expect(resolver.canResolve('<loop.iteration>')).toBe(true)
@@ -78,6 +84,13 @@ describe('LoopResolver', () => {
7884
expect(resolver.canResolve('<loop.items.0>')).toBe(true)
7985
})
8086

87+
it.concurrent('should return true for unknown loop properties (validates in resolve)', () => {
88+
const resolver = new LoopResolver(createTestWorkflow())
89+
expect(resolver.canResolve('<loop.results>')).toBe(true)
90+
expect(resolver.canResolve('<loop.output>')).toBe(true)
91+
expect(resolver.canResolve('<loop.unknownProperty>')).toBe(true)
92+
})
93+
8194
it.concurrent('should return false for non-loop references', () => {
8295
const resolver = new LoopResolver(createTestWorkflow())
8396
expect(resolver.canResolve('<block.output>')).toBe(false)
@@ -181,20 +194,34 @@ describe('LoopResolver', () => {
181194
})
182195

183196
describe('edge cases', () => {
184-
it.concurrent('should return undefined for invalid loop reference (missing property)', () => {
197+
it.concurrent('should return context object for bare loop reference', () => {
185198
const resolver = new LoopResolver(createTestWorkflow())
186-
const loopScope = createLoopScope({ iteration: 0 })
199+
const loopScope = createLoopScope({ iteration: 2, item: 'test', items: ['a', 'b', 'c'] })
187200
const ctx = createTestContext('block-1', loopScope)
188201

189-
expect(resolver.resolve('<loop>', ctx)).toBeUndefined()
202+
expect(resolver.resolve('<loop>', ctx)).toEqual({
203+
index: 2,
204+
currentItem: 'test',
205+
items: ['a', 'b', 'c'],
206+
})
207+
})
208+
209+
it.concurrent('should return minimal context object for for-loop (no items)', () => {
210+
const resolver = new LoopResolver(createTestWorkflow())
211+
const loopScope = createLoopScope({ iteration: 5 })
212+
const ctx = createTestContext('block-1', loopScope)
213+
214+
expect(resolver.resolve('<loop>', ctx)).toEqual({
215+
index: 5,
216+
})
190217
})
191218

192-
it.concurrent('should return undefined for unknown loop property', () => {
219+
it.concurrent('should throw InvalidFieldError for unknown loop property', () => {
193220
const resolver = new LoopResolver(createTestWorkflow())
194221
const loopScope = createLoopScope({ iteration: 0 })
195222
const ctx = createTestContext('block-1', loopScope)
196223

197-
expect(resolver.resolve('<loop.unknownProperty>', ctx)).toBeUndefined()
224+
expect(() => resolver.resolve('<loop.unknownProperty>', ctx)).toThrow(InvalidFieldError)
198225
})
199226

200227
it.concurrent('should handle iteration index 0 correctly', () => {

apps/sim/executor/variables/resolvers/loop.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { isReference, parseReferencePath, REFERENCE } from '@/executor/constants'
3+
import { InvalidFieldError } from '@/executor/utils/block-reference'
34
import { extractBaseBlockId } from '@/executor/utils/subflow-utils'
45
import {
56
navigatePath,
@@ -13,6 +14,8 @@ const logger = createLogger('LoopResolver')
1314
export class LoopResolver implements Resolver {
1415
constructor(private workflow: SerializedWorkflow) {}
1516

17+
private static KNOWN_PROPERTIES = ['iteration', 'index', 'item', 'currentItem', 'items']
18+
1619
canResolve(reference: string): boolean {
1720
if (!isReference(reference)) {
1821
return false
@@ -27,16 +30,15 @@ export class LoopResolver implements Resolver {
2730

2831
resolve(reference: string, context: ResolutionContext): any {
2932
const parts = parseReferencePath(reference)
30-
if (parts.length < 2) {
31-
logger.warn('Invalid loop reference - missing property', { reference })
33+
if (parts.length === 0) {
34+
logger.warn('Invalid loop reference', { reference })
3235
return undefined
3336
}
3437

35-
const [_, property, ...pathParts] = parts
38+
const loopId = this.findLoopForBlock(context.currentNodeId)
3639
let loopScope = context.loopScope
3740

3841
if (!loopScope) {
39-
const loopId = this.findLoopForBlock(context.currentNodeId)
4042
if (!loopId) {
4143
return undefined
4244
}
@@ -48,6 +50,27 @@ export class LoopResolver implements Resolver {
4850
return undefined
4951
}
5052

53+
const isForEach = loopId ? this.isForEachLoop(loopId) : loopScope.items !== undefined
54+
55+
if (parts.length === 1) {
56+
const result: Record<string, any> = {
57+
index: loopScope.iteration,
58+
}
59+
if (loopScope.item !== undefined) {
60+
result.currentItem = loopScope.item
61+
}
62+
if (loopScope.items !== undefined) {
63+
result.items = loopScope.items
64+
}
65+
return result
66+
}
67+
68+
const [_, property, ...pathParts] = parts
69+
if (!LoopResolver.KNOWN_PROPERTIES.includes(property)) {
70+
const availableFields = isForEach ? ['index', 'currentItem', 'items'] : ['index']
71+
throw new InvalidFieldError('loop', property, availableFields)
72+
}
73+
5174
let value: any
5275
switch (property) {
5376
case 'iteration':
@@ -61,12 +84,8 @@ export class LoopResolver implements Resolver {
6184
case 'items':
6285
value = loopScope.items
6386
break
64-
default:
65-
logger.warn('Unknown loop property', { property })
66-
return undefined
6787
}
6888

69-
// If there are additional path parts, navigate deeper
7089
if (pathParts.length > 0) {
7190
return navigatePath(value, pathParts)
7291
}
@@ -85,4 +104,9 @@ export class LoopResolver implements Resolver {
85104

86105
return undefined
87106
}
107+
108+
private isForEachLoop(loopId: string): boolean {
109+
const loopConfig = this.workflow.loops?.[loopId]
110+
return loopConfig?.loopType === 'forEach'
111+
}
88112
}

0 commit comments

Comments
 (0)