Skip to content

Commit f6cda93

Browse files
committed
fix(workflows): preserve scoped fields and snapshot ports
1 parent 2bfd64a commit f6cda93

24 files changed

Lines changed: 439 additions & 144 deletions

File tree

‎apps/docs/openapi-v2-workflows.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9447,9 +9447,14 @@
94479447
}
94489448
],
94499449
"description": "Target version value."
9450+
},
9451+
"scope": {
9452+
"type": "string",
9453+
"enum": ["block", "subblock"],
9454+
"description": "Whether the field is a block setting or a subblock input."
94509455
}
94519456
},
9452-
"required": ["field", "oldValue", "newValue"],
9457+
"required": ["field", "oldValue", "newValue", "scope"],
94539458
"additionalProperties": false
94549459
},
94559460
"description": "Changed field values."

‎apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -423,9 +423,9 @@ function WorkflowPreviewBlockInner({ id, data }: NodeProps<WorkflowPreviewBlockN
423423
triggerMode: isTrigger,
424424
errorEnabled,
425425
subBlocks: {
426-
conditions: { value: rawValues.conditions },
427-
routes: { value: rawValues.routes },
428-
context: { value: rawValues.context },
426+
conditions: { value: extractValue(subBlockValues?.conditions) },
427+
routes: { value: extractValue(subBlockValues?.routes) },
428+
context: { value: extractValue(subBlockValues?.context) },
429429
},
430430
}
431431
const ports = getCanvasPorts(portBlock, hasErrorConnection)
@@ -648,9 +648,6 @@ function shouldSkipPreviewBlockRender(
648648
return false
649649
}
650650

651-
/** Skip subBlockValues comparison in lightweight mode */
652-
if (nextProps.data.lightweight) return true
653-
654651
if (
655652
prevProps.data.workflowMap !== nextProps.data.workflowMap ||
656653
prevProps.data.workflowLabelsReady !== nextProps.data.workflowLabelsReady

‎apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/change-list.tsx‎

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ import { FieldChangeRow } from '@/app/workspace/[workspaceId]/w/components/workf
3636
import {
3737
type BlockChangeEntry,
3838
listBlockChanges,
39-
listOneSidedFields,
4039
} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/utils'
4140
import { BlockTile } from '@/blocks/block-tile'
4241
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -60,12 +59,15 @@ function connectionLabel(edge: EdgeChange, blocks: Record<string, BlockState>):
6059
const block = blocks[edge[type]]
6160
const handle = type === 'source' ? edge.sourceHandle : edge.targetHandle
6261
const defaultHandle = type === 'source' ? WORKFLOW_SOURCE_HANDLE_ID : WORKFLOW_TARGET_HANDLE_ID
63-
const name = humanizeBlockName(type === 'source' ? edge.sourceName : edge.targetName)
62+
const name = humanizeBlockName(block?.name || block?.type || 'Unavailable block')
6463
if (!handle || handle === defaultHandle) return name
6564
const ports = block ? getCanvasPorts(block, true).filter((port) => port.type === type) : []
6665
const index = ports.findIndex((port) => port.handleId === handle)
6766
const title = ports[index]?.title
68-
const label = title === 'else if' ? `else if ${index}` : (title ?? handle)
67+
const label =
68+
title === 'else if'
69+
? `else if ${index}`
70+
: (title ?? (type === 'source' ? 'Unavailable output' : 'Unavailable input'))
6971
return `${name} (${label})`
7072
}
7173
return `${endpointLabel('source')} → ${endpointLabel('target')}`
@@ -95,7 +97,6 @@ export function ChangeList({
9597
() => listBlockChanges(summary, baseBlocks, targetBlocks, containers),
9698
[summary, baseBlocks, targetBlocks, containers]
9799
)
98-
const blocks = useMemo(() => ({ ...baseBlocks, ...targetBlocks }), [baseBlocks, targetBlocks])
99100
const cardRefs = useRef<Map<string, HTMLDivElement>>(null)
100101
cardRefs.current ??= new Map()
101102

@@ -136,7 +137,6 @@ export function ChangeList({
136137
<BlockCard
137138
key={entry.id}
138139
entry={entry}
139-
blocks={blocks}
140140
selectedBlockId={containsSelection(entry, selectedBlockId) ? selectedBlockId : null}
141141
onToggleSelected={toggleSelected}
142142
registerCard={registerCard}
@@ -201,7 +201,6 @@ function Section({ title, children }: SectionProps) {
201201

202202
interface BlockCardProps {
203203
entry: BlockChangeEntry
204-
blocks: Record<string, BlockState>
205204
/** The selected block id when it is this card or one nested inside it, else null */
206205
selectedBlockId: string | null
207206
onToggleSelected: (id: string) => void
@@ -217,7 +216,6 @@ interface BlockCardProps {
217216
*/
218217
const BlockCard = memo(function BlockCard({
219218
entry,
220-
blocks,
221219
selectedBlockId,
222220
onToggleSelected,
223221
registerCard,
@@ -230,15 +228,11 @@ const BlockCard = memo(function BlockCard({
230228
setSeenSelection(selectedBlockId)
231229
if (selectedBlockId !== null && !selected) setCollapsed(false)
232230
}
233-
const block = blocks[entry.id]
234231
const setCardRef = useCallback(
235232
(node: HTMLDivElement | null) => registerCard(entry.id, node),
236233
[registerCard, entry.id]
237234
)
238-
const fields =
239-
entry.status === 'modified'
240-
? entry.changes
241-
: [...entry.changes, ...(block ? listOneSidedFields(block, entry.status) : [])]
235+
const fields = entry.changes
242236
const hasBody =
243237
fields.length > 0 ||
244238
entry.children.length > 0 ||
@@ -254,6 +248,7 @@ const BlockCard = memo(function BlockCard({
254248
<div ref={setCardRef}>
255249
<CollapsibleCard
256250
collapsed={!hasBody || collapsed}
251+
selected={selected}
257252
onToggleCollapse={toggle}
258253
title={
259254
<span className='flex min-w-0 items-center gap-2'>
@@ -294,8 +289,9 @@ const BlockCard = memo(function BlockCard({
294289

295290
{fields.map((change) => (
296291
<FieldChangeRow
297-
key={change.field}
292+
key={`${change.scope}:${change.field}`}
298293
blockType={entry.type}
294+
scope={change.scope}
299295
field={change.field}
300296
oldValue={change.oldValue}
301297
newValue={change.newValue}
@@ -309,7 +305,6 @@ const BlockCard = memo(function BlockCard({
309305
<BlockCard
310306
key={child.id}
311307
entry={child}
312-
blocks={blocks}
313308
selectedBlockId={
314309
containsSelection(child, selectedBlockId) ? selectedBlockId : null
315310
}

‎apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/field-change-row.tsx‎

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@
22

33
import { ChipModalField, ChipTag, cn } from '@sim/emcn'
44
import { ArrowRight } from '@sim/emcn/icons'
5-
import { isContainerType } from '@/lib/workflows/autolayout'
65
import { formatValueForDisplay, resolveFieldLabel } from '@/lib/workflows/comparison/resolve-values'
76
import { StructuredValueDiff } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/structured-value-diff'
87
import {
98
InlineDiff,
109
TextDiff,
1110
} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/text-diff'
12-
import { getStructuredValuePresentation } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/value-presentation'
11+
import {
12+
getStructuredValuePresentation,
13+
mappingPresentation,
14+
} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/value-presentation'
1315
import {
1416
classifyChange,
1517
containerFieldLabel,
16-
ENGINE_FIELD_LABELS,
1718
findSubBlockConfig,
1819
formatScalar,
1920
isBlankValue,
@@ -24,6 +25,7 @@ import { formatParameterLabel } from '@/tools/params'
2425

2526
interface FieldChangeRowProps {
2627
blockType: string
28+
scope: 'block' | 'subblock' | 'container'
2729
field: string
2830
oldValue: unknown
2931
newValue: unknown
@@ -36,27 +38,46 @@ interface FieldChangeRowProps {
3638
* empty (a block that exists on one side only, or a value first set or
3739
* cleared) shows that one side alone, in that side's colour.
3840
*/
39-
export function FieldChangeRow({ blockType, field, oldValue, newValue }: FieldChangeRowProps) {
40-
const config = findSubBlockConfig(blockType, field)
41-
const kind = classifyChange(blockType, field, oldValue, newValue)
42-
const before =
43-
kind === 'structured' ? getStructuredValuePresentation(config, field, oldValue) : null
44-
const after =
45-
kind === 'structured' ? getStructuredValuePresentation(config, field, newValue) : null
41+
export function FieldChangeRow({
42+
blockType,
43+
scope,
44+
field,
45+
oldValue,
46+
newValue,
47+
}: FieldChangeRowProps) {
48+
const valueBlockType = scope === 'subblock' ? blockType : undefined
49+
const config = findSubBlockConfig(valueBlockType, field)
50+
const kind = classifyChange(valueBlockType, field, oldValue, newValue)
51+
const structuredSettings = scope === 'block' && kind === 'json'
52+
const present = (value: unknown) =>
53+
structuredSettings
54+
? mappingPresentation(value)
55+
: kind === 'structured'
56+
? getStructuredValuePresentation(config, field, value)
57+
: null
58+
const before = present(oldValue)
59+
const after = present(newValue)
4660
const oneSided = isBlankValue(oldValue) !== isBlankValue(newValue)
4761
const wordDiff = kind === 'scalar' && isSentenceLike(oldValue) && isSentenceLike(newValue)
48-
const resolvedLabel = isContainerType(blockType)
49-
? containerFieldLabel(field)
50-
: (ENGINE_FIELD_LABELS[field] ?? resolveFieldLabel(blockType, field))
62+
const resolvedLabel =
63+
scope === 'container' ? containerFieldLabel(field) : resolveFieldLabel(blockType, field, scope)
5164
const label = resolvedLabel === field ? formatParameterLabel(field) : resolvedLabel
52-
const textual = kind === 'text' || kind === 'json'
65+
const textual = !structuredSettings && (kind === 'text' || kind === 'json')
5366
const scalar = kind === 'scalar' || kind === 'toggle'
5467
const text = (value: unknown) => {
55-
const formatted = toDiffText(value, blockType, field)
68+
const formatted = toDiffText(value, valueBlockType, field)
5669
return kind === 'json' && typeof value === 'string' ? JSON.stringify(formatted) : formatted
5770
}
58-
let oldText = textual ? text(oldValue) : scalar ? formatScalar(blockType, field, oldValue) : ''
59-
let newText = textual ? text(newValue) : scalar ? formatScalar(blockType, field, newValue) : ''
71+
let oldText = textual
72+
? text(oldValue)
73+
: scalar
74+
? formatScalar(valueBlockType, field, oldValue)
75+
: ''
76+
let newText = textual
77+
? text(newValue)
78+
: scalar
79+
? formatScalar(valueBlockType, field, newValue)
80+
: ''
6081
if (
6182
scalar &&
6283
(config?.type === 'dropdown' || config?.type === 'combobox') &&
@@ -99,7 +120,7 @@ export function FieldChangeRow({ blockType, field, oldValue, newValue }: FieldCh
99120
(oneSided ? (
100121
<ValueChip
101122
tone={isBlankValue(oldValue) ? 'added' : 'removed'}
102-
text={formatScalar(blockType, field, isBlankValue(oldValue) ? newValue : oldValue)}
123+
text={formatScalar(valueBlockType, field, isBlankValue(oldValue) ? newValue : oldValue)}
103124
/>
104125
) : wordDiff ? (
105126
<InlineDiff oldText={oldText} newText={newText} />

‎apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/value-presentation.test.ts‎

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,63 @@
11
import { describe, expect, it } from 'vitest'
2+
import { diffOrderedRows } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/ordered-row-diff'
23
import { getStructuredValuePresentation } from '@/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/value-presentation'
34

45
describe('structured workflow value presentation', () => {
5-
it('preserves duplicate header rows and order while ignoring editor row IDs and blank starter rows', () => {
6-
const config = { id: 'headers', type: 'table' as const, columns: ['Key', 'Value'] }
6+
it.each([false, true])(
7+
'preserves header order and ignores editor metadata (encoded: %s)',
8+
(encoded) => {
9+
const stored = (value: unknown) => (encoded ? JSON.stringify(value) : value)
10+
const config = { id: 'headers', type: 'table' as const, columns: ['Key', 'Value'] }
11+
const source = [
12+
{ id: 'first', cells: { Key: 'X-Mode', Value: 'first' } },
13+
{ id: 'second', cells: { Key: 'X-Mode', Value: 'last' } },
14+
{ id: 'blank', cells: { Key: '', Value: '' } },
15+
{ id: 'empty-value', cells: { Key: 'X-Empty', Value: '' } },
16+
]
17+
const result = getStructuredValuePresentation(config, 'headers', stored(source))!
18+
expect(result.columns).toEqual(['Key', 'Value'])
19+
expect(result.rows.map((row) => row.cells)).toEqual([
20+
{ Key: 'X-Mode', Value: 'first' },
21+
{ Key: 'X-Mode', Value: 'last' },
22+
{ Key: 'X-Empty', Value: '' },
23+
])
24+
const recreated = source.map((row, index) => ({ ...row, id: `recreated-${index}` }))
25+
expect(
26+
getStructuredValuePresentation(config, 'headers', stored(recreated))?.rows.map(
27+
(row) => row.key
28+
)
29+
).toEqual(result.rows.map((row) => row.key))
30+
expect(
31+
getStructuredValuePresentation(config, 'headers', stored([...source].reverse()))?.rows.map(
32+
(row) => row.key
33+
)
34+
).not.toEqual(result.rows.map((row) => row.key))
35+
}
36+
)
37+
38+
it('keeps surviving router rows unchanged when a preceding route is removed', () => {
39+
const config = { id: 'routes', type: 'router-input' as const }
740
const source = [
8-
{ id: 'first', cells: { Key: 'X-Mode', Value: 'first' } },
9-
{ id: 'second', cells: { Key: 'X-Mode', Value: 'last' } },
10-
{ id: 'blank', cells: { Key: '', Value: '' } },
11-
{ id: 'empty-value', cells: { Key: 'X-Empty', Value: '' } },
41+
{ id: 'a', value: 'first' },
42+
{ id: 'b', value: 'second' },
43+
{ id: 'c', value: 'third' },
1244
]
13-
const result = getStructuredValuePresentation(config, 'headers', source)!
14-
expect(result.columns).toEqual(['Key', 'Value'])
15-
expect(result.rows.map((row) => row.cells)).toEqual([
16-
{ Key: 'X-Mode', Value: 'first' },
17-
{ Key: 'X-Mode', Value: 'last' },
18-
{ Key: 'X-Empty', Value: '' },
45+
const before = getStructuredValuePresentation(config, 'routes', source)!
46+
const after = getStructuredValuePresentation(config, 'routes', source.slice(1))!
47+
expect(diffOrderedRows(before.rows, after.rows).map(({ kind }) => kind)).toEqual([
48+
'removed',
49+
'context',
50+
'context',
51+
])
52+
const changed = getStructuredValuePresentation(config, 'routes', [
53+
{ ...source[1], value: 'edited' },
54+
source[2],
55+
])!
56+
expect(diffOrderedRows(after.rows, changed.rows).map(({ kind }) => kind)).toEqual([
57+
'removed',
58+
'added',
59+
'context',
1960
])
20-
const recreated = source.map((row, index) => ({ ...row, id: `recreated-${index}` }))
21-
expect(
22-
getStructuredValuePresentation(config, 'headers', recreated)?.rows.map((row) => row.key)
23-
).toEqual(result.rows.map((row) => row.key))
24-
expect(
25-
getStructuredValuePresentation(config, 'headers', [...source].reverse())?.rows.map(
26-
(row) => row.key
27-
)
28-
).not.toEqual(result.rows.map((row) => row.key))
2961
})
3062

3163
it('keeps secret-only edits distinct before the renderer masks cells', () => {

‎apps/sim/app/workspace/[workspaceId]/w/components/workflow-diff/components/change-list/value-presentation.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,11 @@ function branchPresentation(value: unknown, config: SubBlockConfig): StructuredV
114114
Branch: titles[index],
115115
...(Object.hasOwn(entry, 'value') ? { [description]: entry.value } : {}),
116116
}
117-
return row(entry, withDetails(cells, entry, ['value'], ['id', 'title']), titles[index])
117+
return row(
118+
entry,
119+
withDetails(cells, entry, ['value'], ['id', 'title']),
120+
condition ? titles[index] : undefined
121+
)
118122
})
119123
return { columns: columnsFor(rows, ['Branch', description]), rows }
120124
}
@@ -153,7 +157,8 @@ function checkboxPresentation(value: unknown, config: SubBlockConfig): Structure
153157
return { columns: ['Option', 'Selected'], rows }
154158
}
155159

156-
function mappingPresentation(value: unknown): StructuredValuePresentation {
160+
/** Named values use the same ordered field rows for subblock mappings and block settings. */
161+
export function mappingPresentation(value: unknown): StructuredValuePresentation {
157162
if (!isRecordLike(value) || Object.keys(value).length === 0) return valuePresentation(value)
158163
return {
159164
columns: ['Field', 'Value'],

0 commit comments

Comments
 (0)