Skip to content

Commit da28f8a

Browse files
committed
improved autolayout
1 parent e2b4eb3 commit da28f8a

9 files changed

Lines changed: 586 additions & 80 deletions

File tree

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

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
1010
import { createMcpToolId } from '@/lib/mcp/shared'
1111
import { getProviderIdFromServiceId } from '@/lib/oauth'
1212
import type { FilterRule, SortRule } from '@/lib/table/types'
13-
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
13+
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
14+
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
1415
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
1516
import {
1617
buildCanonicalIndex,
@@ -1145,33 +1146,14 @@ export const WorkflowBlock = memo(function WorkflowBlock({
11451146
useBlockDimensions({
11461147
blockId: id,
11471148
calculateDimensions: () => {
1148-
const shouldShowDefaultHandles =
1149-
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
1150-
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
1151-
1152-
const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0
1153-
1154-
let rowsCount = 0
1155-
if (type === 'condition') {
1156-
rowsCount = conditionRows.length + defaultHandlesRow
1157-
} else if (type === 'router_v2') {
1158-
// +1 for context row, plus route rows
1159-
rowsCount = 1 + routerRows.length + defaultHandlesRow
1160-
} else {
1161-
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
1162-
rowsCount = subblockRowCount + defaultHandlesRow
1163-
}
1164-
1165-
const contentHeight = hasContentBelowHeader
1166-
? BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
1167-
rowsCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
1168-
: 0
1169-
const calculatedHeight = Math.max(
1170-
BLOCK_DIMENSIONS.HEADER_HEIGHT + contentHeight,
1171-
BLOCK_DIMENSIONS.MIN_HEIGHT
1172-
)
1173-
1174-
return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: calculatedHeight }
1149+
return calculateWorkflowBlockDimensions({
1150+
blockType: type,
1151+
category: config.category,
1152+
displayTriggerMode,
1153+
visibleSubBlockCount: subBlockRows.reduce((acc, row) => acc + row.length, 0),
1154+
conditionRowCount: conditionRows.length,
1155+
routerRowCount: routerRows.length,
1156+
})
11751157
},
11761158
dependencies: [
11771159
type,

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {
99
type ServerToolContext,
1010
} from '@/lib/copilot/tools/server/base-tool'
1111
import { env } from '@/lib/core/config/env'
12-
import { applyTargetedLayout, getTargetedLayoutImpact } from '@/lib/workflows/autolayout'
12+
import {
13+
applyTargetedLayout,
14+
getTargetedLayoutImpact,
15+
transferBlockHeights,
16+
} from '@/lib/workflows/autolayout'
1317
import {
1418
DEFAULT_HORIZONTAL_SPACING,
1519
DEFAULT_VERTICAL_SPACING,
@@ -235,17 +239,19 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
235239
// Persist the workflow state to the database
236240
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
237241

238-
const { layoutBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
242+
const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
239243
before: workflowState,
240244
after: finalWorkflowState,
241245
})
242246

243247
let layoutedBlocks = finalWorkflowState.blocks
244248

245-
if (layoutBlockIds.length > 0 || shiftSourceBlockIds.length > 0) {
249+
if (layoutBlockIds.length > 0 || resizedBlockIds.length > 0 || shiftSourceBlockIds.length > 0) {
246250
try {
251+
transferBlockHeights(workflowState.blocks, finalWorkflowState.blocks)
247252
layoutedBlocks = applyTargetedLayout(finalWorkflowState.blocks, finalWorkflowState.edges, {
248253
changedBlockIds: layoutBlockIds,
254+
resizedBlockIds,
249255
shiftSourceBlockIds,
250256
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
251257
verticalSpacing: DEFAULT_VERTICAL_SPACING,

apps/sim/lib/workflows/autolayout/change-set.test.ts

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,43 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { describe, expect, it } from 'vitest'
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import {
66
getTargetedLayoutChangeSet,
77
getTargetedLayoutImpact,
88
} from '@/lib/workflows/autolayout/change-set'
99
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
1010

11+
const { mockGetBlock } = vi.hoisted(() => ({
12+
mockGetBlock: vi.fn(),
13+
}))
14+
15+
vi.mock('@/blocks', () => ({
16+
getBlock: mockGetBlock,
17+
}))
18+
19+
const JIRA_TEST_BLOCK_CONFIG = {
20+
category: 'tools',
21+
subBlocks: [
22+
{ id: 'operation', type: 'dropdown' },
23+
{ id: 'domain', type: 'short-input' },
24+
{ id: 'credential', type: 'oauth-input', mode: 'basic' },
25+
{ id: 'issueKey', type: 'short-input', condition: { field: 'operation', value: 'read' } },
26+
{ id: 'projectId', type: 'short-input', condition: { field: 'operation', value: 'write' } },
27+
{ id: 'summary', type: 'short-input', condition: { field: 'operation', value: 'write' } },
28+
{ id: 'description', type: 'long-input', condition: { field: 'operation', value: 'write' } },
29+
{ id: 'priority', type: 'short-input', condition: { field: 'operation', value: 'write' } },
30+
{ id: 'labels', type: 'short-input', condition: { field: 'operation', value: 'write' } },
31+
{ id: 'issueType', type: 'short-input', condition: { field: 'operation', value: 'write' } },
32+
{ id: 'parentIssue', type: 'short-input', condition: { field: 'operation', value: 'write' } },
33+
{ id: 'assignee', type: 'short-input', condition: { field: 'operation', value: 'write' } },
34+
{ id: 'reporter', type: 'short-input', condition: { field: 'operation', value: 'write' } },
35+
{ id: 'environment', type: 'long-input', condition: { field: 'operation', value: 'write' } },
36+
{ id: 'components', type: 'short-input', condition: { field: 'operation', value: 'write' } },
37+
{ id: 'fixVersions', type: 'short-input', condition: { field: 'operation', value: 'write' } },
38+
],
39+
} as const
40+
1141
function createBlock(
1242
id: string,
1343
overrides: Partial<BlockState> = {},
@@ -39,7 +69,44 @@ function createWorkflowState({
3969
}
4070
}
4171

72+
function createJiraBlock(
73+
id: string,
74+
operation: 'read' | 'write',
75+
overrides: Partial<BlockState> = {}
76+
): BlockState {
77+
return createBlock(id, {
78+
type: 'jira',
79+
position: { x: 100, y: 100 },
80+
height: 100,
81+
layout: { measuredWidth: 250, measuredHeight: 100 },
82+
subBlocks: {
83+
operation: {
84+
id: 'operation',
85+
type: 'dropdown',
86+
value: operation,
87+
},
88+
domain: {
89+
id: 'domain',
90+
type: 'short-input',
91+
value: 'company.atlassian.net',
92+
},
93+
credential: {
94+
id: 'credential',
95+
type: 'oauth-input',
96+
value: 'credential-1',
97+
},
98+
},
99+
...overrides,
100+
})
101+
}
102+
42103
describe('getTargetedLayoutChangeSet', () => {
104+
beforeEach(() => {
105+
mockGetBlock.mockImplementation((type: string) =>
106+
type === 'jira' ? JIRA_TEST_BLOCK_CONFIG : undefined
107+
)
108+
})
109+
43110
it('does not relayout newly added blocks that already have valid positions', () => {
44111
const before = createWorkflowState({
45112
blocks: {
@@ -77,7 +144,15 @@ describe('getTargetedLayoutChangeSet', () => {
77144
it('keeps subblock-only edits anchored', () => {
78145
const before = createWorkflowState({
79146
blocks: {
80-
start: createBlock('start'),
147+
start: createBlock('start', {
148+
subBlocks: {
149+
prompt: {
150+
id: 'prompt',
151+
type: 'long-input',
152+
value: 'old value',
153+
},
154+
},
155+
}),
81156
},
82157
})
83158

@@ -98,10 +173,40 @@ describe('getTargetedLayoutChangeSet', () => {
98173
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
99174
})
100175

176+
it('reopens edited blocks when an operation change increases their visible height', () => {
177+
const before = createWorkflowState({
178+
blocks: {
179+
jira: createJiraBlock('jira', 'read'),
180+
},
181+
})
182+
183+
const after = createWorkflowState({
184+
blocks: {
185+
jira: createJiraBlock('jira', 'write'),
186+
},
187+
})
188+
189+
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
190+
expect(getTargetedLayoutImpact({ before, after })).toEqual({
191+
layoutBlockIds: [],
192+
resizedBlockIds: ['jira'],
193+
shiftSourceBlockIds: [],
194+
})
195+
})
196+
101197
it('does not relayout a pre-existing block legitimately placed at the origin', () => {
102198
const before = createWorkflowState({
103199
blocks: {
104-
start: createBlock('start', { position: { x: 0, y: 0 } }),
200+
start: createBlock('start', {
201+
position: { x: 0, y: 0 },
202+
subBlocks: {
203+
prompt: {
204+
id: 'prompt',
205+
type: 'long-input',
206+
value: 'old value',
207+
},
208+
},
209+
}),
105210
},
106211
})
107212

@@ -167,6 +272,7 @@ describe('getTargetedLayoutChangeSet', () => {
167272

168273
expect(getTargetedLayoutImpact({ before, after })).toEqual({
169274
layoutBlockIds: ['function1'],
275+
resizedBlockIds: [],
170276
shiftSourceBlockIds: [],
171277
})
172278
})
@@ -231,6 +337,7 @@ describe('getTargetedLayoutChangeSet', () => {
231337

232338
expect(getTargetedLayoutImpact({ before, after })).toEqual({
233339
layoutBlockIds: [],
340+
resizedBlockIds: [],
234341
shiftSourceBlockIds: ['source'],
235342
})
236343
})
@@ -279,6 +386,7 @@ describe('getTargetedLayoutChangeSet', () => {
279386

280387
expect(getTargetedLayoutImpact({ before, after })).toEqual({
281388
layoutBlockIds: [],
389+
resizedBlockIds: [],
282390
shiftSourceBlockIds: ['a-b'],
283391
})
284392
})
@@ -327,6 +435,7 @@ describe('getTargetedLayoutChangeSet', () => {
327435

328436
expect(getTargetedLayoutImpact({ before, after })).toEqual({
329437
layoutBlockIds: ['inserted'],
438+
resizedBlockIds: [],
330439
shiftSourceBlockIds: ['inserted'],
331440
})
332441
})

apps/sim/lib/workflows/autolayout/change-set.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Edge } from 'reactflow'
2+
import { getBlockMetrics } from '@/lib/workflows/autolayout/utils'
23
import type { WorkflowState } from '@/stores/workflows/workflow/types'
34

45
interface TargetedLayoutChangeSetOptions {
@@ -8,18 +9,21 @@ interface TargetedLayoutChangeSetOptions {
89

910
export interface TargetedLayoutImpact {
1011
layoutBlockIds: string[]
12+
resizedBlockIds: string[]
1113
shiftSourceBlockIds: string[]
1214
}
1315

1416
/**
15-
* Computes the minimal structural change set that should be reopened for
16-
* targeted layout after a workflow edit.
17+
* Computes the minimal change set that should be reopened for targeted layout
18+
* after a workflow edit. `layoutBlockIds` are fully repositioned, while
19+
* `resizedBlockIds` keep their existing position and only shift neighbors.
1720
*/
1821
export function getTargetedLayoutImpact({
1922
before,
2023
after,
2124
}: TargetedLayoutChangeSetOptions): TargetedLayoutImpact {
2225
const layoutBlockIds = new Set<string>()
26+
const resizedBlockIds = new Set<string>()
2327
const afterBlockIds = new Set(Object.keys(after.blocks || {}))
2428
const beforeBlockIds = new Set(Object.keys(before.blocks || {}))
2529

@@ -40,6 +44,9 @@ export function getTargetedLayoutImpact({
4044
const previousParentId = before.blocks[blockId]?.data?.parentId ?? null
4145
const currentParentId = after.blocks[blockId]?.data?.parentId ?? null
4246
if (previousParentId === currentParentId) {
47+
if (hasLayoutRelevantSizeChange(before.blocks[blockId], after.blocks[blockId])) {
48+
resizedBlockIds.add(blockId)
49+
}
4350
continue
4451
}
4552

@@ -57,6 +64,7 @@ export function getTargetedLayoutImpact({
5764
if (addedEdges.length === 0) {
5865
return {
5966
layoutBlockIds: Array.from(layoutBlockIds),
67+
resizedBlockIds: Array.from(resizedBlockIds),
6068
shiftSourceBlockIds: [],
6169
}
6270
}
@@ -94,6 +102,7 @@ export function getTargetedLayoutImpact({
94102

95103
return {
96104
layoutBlockIds: Array.from(layoutBlockIds),
105+
resizedBlockIds: Array.from(resizedBlockIds),
97106
shiftSourceBlockIds: Array.from(shiftSourceBlockIds),
98107
}
99108
}
@@ -123,6 +132,24 @@ function getBlocksWithInvalidPositions(
123132
})
124133
}
125134

135+
/**
136+
* Returns true when a persisted block changed size enough that anchored layout
137+
* should reopen its column and shift affected siblings.
138+
*/
139+
function hasLayoutRelevantSizeChange(
140+
beforeBlock: WorkflowState['blocks'][string] | undefined,
141+
afterBlock: WorkflowState['blocks'][string] | undefined
142+
): boolean {
143+
if (!beforeBlock || !afterBlock) {
144+
return false
145+
}
146+
147+
const beforeMetrics = getBlockMetrics(beforeBlock)
148+
const afterMetrics = getBlockMetrics(afterBlock)
149+
150+
return beforeMetrics.height !== afterMetrics.height || beforeMetrics.width !== afterMetrics.width
151+
}
152+
126153
/**
127154
* Returns added edges that participate in layout within a shared parent scope.
128155
*/

0 commit comments

Comments
 (0)