mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
improved autolayout
This commit is contained in:
@@ -10,7 +10,8 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createMcpToolId } from '@/lib/mcp/shared'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import type { FilterRule, SortRule } from '@/lib/table/types'
|
||||
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
|
||||
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
@@ -1145,33 +1146,14 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
useBlockDimensions({
|
||||
blockId: id,
|
||||
calculateDimensions: () => {
|
||||
const shouldShowDefaultHandles =
|
||||
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
|
||||
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
|
||||
|
||||
const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0
|
||||
|
||||
let rowsCount = 0
|
||||
if (type === 'condition') {
|
||||
rowsCount = conditionRows.length + defaultHandlesRow
|
||||
} else if (type === 'router_v2') {
|
||||
// +1 for context row, plus route rows
|
||||
rowsCount = 1 + routerRows.length + defaultHandlesRow
|
||||
} else {
|
||||
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
|
||||
rowsCount = subblockRowCount + defaultHandlesRow
|
||||
}
|
||||
|
||||
const contentHeight = hasContentBelowHeader
|
||||
? BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
|
||||
rowsCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
|
||||
: 0
|
||||
const calculatedHeight = Math.max(
|
||||
BLOCK_DIMENSIONS.HEADER_HEIGHT + contentHeight,
|
||||
BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
)
|
||||
|
||||
return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: calculatedHeight }
|
||||
return calculateWorkflowBlockDimensions({
|
||||
blockType: type,
|
||||
category: config.category,
|
||||
displayTriggerMode,
|
||||
visibleSubBlockCount: subBlockRows.reduce((acc, row) => acc + row.length, 0),
|
||||
conditionRowCount: conditionRows.length,
|
||||
routerRowCount: routerRows.length,
|
||||
})
|
||||
},
|
||||
dependencies: [
|
||||
type,
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
type ServerToolContext,
|
||||
} from '@/lib/copilot/tools/server/base-tool'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { applyTargetedLayout, getTargetedLayoutImpact } from '@/lib/workflows/autolayout'
|
||||
import {
|
||||
applyTargetedLayout,
|
||||
getTargetedLayoutImpact,
|
||||
transferBlockHeights,
|
||||
} from '@/lib/workflows/autolayout'
|
||||
import {
|
||||
DEFAULT_HORIZONTAL_SPACING,
|
||||
DEFAULT_VERTICAL_SPACING,
|
||||
@@ -235,17 +239,19 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
|
||||
// Persist the workflow state to the database
|
||||
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
|
||||
|
||||
const { layoutBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
|
||||
const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
|
||||
before: workflowState,
|
||||
after: finalWorkflowState,
|
||||
})
|
||||
|
||||
let layoutedBlocks = finalWorkflowState.blocks
|
||||
|
||||
if (layoutBlockIds.length > 0 || shiftSourceBlockIds.length > 0) {
|
||||
if (layoutBlockIds.length > 0 || resizedBlockIds.length > 0 || shiftSourceBlockIds.length > 0) {
|
||||
try {
|
||||
transferBlockHeights(workflowState.blocks, finalWorkflowState.blocks)
|
||||
layoutedBlocks = applyTargetedLayout(finalWorkflowState.blocks, finalWorkflowState.edges, {
|
||||
changedBlockIds: layoutBlockIds,
|
||||
resizedBlockIds,
|
||||
shiftSourceBlockIds,
|
||||
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
|
||||
verticalSpacing: DEFAULT_VERTICAL_SPACING,
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getTargetedLayoutChangeSet,
|
||||
getTargetedLayoutImpact,
|
||||
} from '@/lib/workflows/autolayout/change-set'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const { mockGetBlock } = vi.hoisted(() => ({
|
||||
mockGetBlock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/blocks', () => ({
|
||||
getBlock: mockGetBlock,
|
||||
}))
|
||||
|
||||
const JIRA_TEST_BLOCK_CONFIG = {
|
||||
category: 'tools',
|
||||
subBlocks: [
|
||||
{ id: 'operation', type: 'dropdown' },
|
||||
{ id: 'domain', type: 'short-input' },
|
||||
{ id: 'credential', type: 'oauth-input', mode: 'basic' },
|
||||
{ id: 'issueKey', type: 'short-input', condition: { field: 'operation', value: 'read' } },
|
||||
{ id: 'projectId', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'summary', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'description', type: 'long-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'priority', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'labels', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'issueType', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'parentIssue', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'assignee', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'reporter', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'environment', type: 'long-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'components', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'fixVersions', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
],
|
||||
} as const
|
||||
|
||||
function createBlock(
|
||||
id: string,
|
||||
overrides: Partial<BlockState> = {},
|
||||
@@ -39,7 +69,44 @@ function createWorkflowState({
|
||||
}
|
||||
}
|
||||
|
||||
function createJiraBlock(
|
||||
id: string,
|
||||
operation: 'read' | 'write',
|
||||
overrides: Partial<BlockState> = {}
|
||||
): BlockState {
|
||||
return createBlock(id, {
|
||||
type: 'jira',
|
||||
position: { x: 100, y: 100 },
|
||||
height: 100,
|
||||
layout: { measuredWidth: 250, measuredHeight: 100 },
|
||||
subBlocks: {
|
||||
operation: {
|
||||
id: 'operation',
|
||||
type: 'dropdown',
|
||||
value: operation,
|
||||
},
|
||||
domain: {
|
||||
id: 'domain',
|
||||
type: 'short-input',
|
||||
value: 'company.atlassian.net',
|
||||
},
|
||||
credential: {
|
||||
id: 'credential',
|
||||
type: 'oauth-input',
|
||||
value: 'credential-1',
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('getTargetedLayoutChangeSet', () => {
|
||||
beforeEach(() => {
|
||||
mockGetBlock.mockImplementation((type: string) =>
|
||||
type === 'jira' ? JIRA_TEST_BLOCK_CONFIG : undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('does not relayout newly added blocks that already have valid positions', () => {
|
||||
const before = createWorkflowState({
|
||||
blocks: {
|
||||
@@ -77,7 +144,15 @@ describe('getTargetedLayoutChangeSet', () => {
|
||||
it('keeps subblock-only edits anchored', () => {
|
||||
const before = createWorkflowState({
|
||||
blocks: {
|
||||
start: createBlock('start'),
|
||||
start: createBlock('start', {
|
||||
subBlocks: {
|
||||
prompt: {
|
||||
id: 'prompt',
|
||||
type: 'long-input',
|
||||
value: 'old value',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -98,10 +173,40 @@ describe('getTargetedLayoutChangeSet', () => {
|
||||
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
|
||||
})
|
||||
|
||||
it('reopens edited blocks when an operation change increases their visible height', () => {
|
||||
const before = createWorkflowState({
|
||||
blocks: {
|
||||
jira: createJiraBlock('jira', 'read'),
|
||||
},
|
||||
})
|
||||
|
||||
const after = createWorkflowState({
|
||||
blocks: {
|
||||
jira: createJiraBlock('jira', 'write'),
|
||||
},
|
||||
})
|
||||
|
||||
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
|
||||
expect(getTargetedLayoutImpact({ before, after })).toEqual({
|
||||
layoutBlockIds: [],
|
||||
resizedBlockIds: ['jira'],
|
||||
shiftSourceBlockIds: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not relayout a pre-existing block legitimately placed at the origin', () => {
|
||||
const before = createWorkflowState({
|
||||
blocks: {
|
||||
start: createBlock('start', { position: { x: 0, y: 0 } }),
|
||||
start: createBlock('start', {
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
prompt: {
|
||||
id: 'prompt',
|
||||
type: 'long-input',
|
||||
value: 'old value',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -167,6 +272,7 @@ describe('getTargetedLayoutChangeSet', () => {
|
||||
|
||||
expect(getTargetedLayoutImpact({ before, after })).toEqual({
|
||||
layoutBlockIds: ['function1'],
|
||||
resizedBlockIds: [],
|
||||
shiftSourceBlockIds: [],
|
||||
})
|
||||
})
|
||||
@@ -231,6 +337,7 @@ describe('getTargetedLayoutChangeSet', () => {
|
||||
|
||||
expect(getTargetedLayoutImpact({ before, after })).toEqual({
|
||||
layoutBlockIds: [],
|
||||
resizedBlockIds: [],
|
||||
shiftSourceBlockIds: ['source'],
|
||||
})
|
||||
})
|
||||
@@ -279,6 +386,7 @@ describe('getTargetedLayoutChangeSet', () => {
|
||||
|
||||
expect(getTargetedLayoutImpact({ before, after })).toEqual({
|
||||
layoutBlockIds: [],
|
||||
resizedBlockIds: [],
|
||||
shiftSourceBlockIds: ['a-b'],
|
||||
})
|
||||
})
|
||||
@@ -327,6 +435,7 @@ describe('getTargetedLayoutChangeSet', () => {
|
||||
|
||||
expect(getTargetedLayoutImpact({ before, after })).toEqual({
|
||||
layoutBlockIds: ['inserted'],
|
||||
resizedBlockIds: [],
|
||||
shiftSourceBlockIds: ['inserted'],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import { getBlockMetrics } from '@/lib/workflows/autolayout/utils'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
interface TargetedLayoutChangeSetOptions {
|
||||
@@ -8,18 +9,21 @@ interface TargetedLayoutChangeSetOptions {
|
||||
|
||||
export interface TargetedLayoutImpact {
|
||||
layoutBlockIds: string[]
|
||||
resizedBlockIds: string[]
|
||||
shiftSourceBlockIds: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the minimal structural change set that should be reopened for
|
||||
* targeted layout after a workflow edit.
|
||||
* Computes the minimal change set that should be reopened for targeted layout
|
||||
* after a workflow edit. `layoutBlockIds` are fully repositioned, while
|
||||
* `resizedBlockIds` keep their existing position and only shift neighbors.
|
||||
*/
|
||||
export function getTargetedLayoutImpact({
|
||||
before,
|
||||
after,
|
||||
}: TargetedLayoutChangeSetOptions): TargetedLayoutImpact {
|
||||
const layoutBlockIds = new Set<string>()
|
||||
const resizedBlockIds = new Set<string>()
|
||||
const afterBlockIds = new Set(Object.keys(after.blocks || {}))
|
||||
const beforeBlockIds = new Set(Object.keys(before.blocks || {}))
|
||||
|
||||
@@ -40,6 +44,9 @@ export function getTargetedLayoutImpact({
|
||||
const previousParentId = before.blocks[blockId]?.data?.parentId ?? null
|
||||
const currentParentId = after.blocks[blockId]?.data?.parentId ?? null
|
||||
if (previousParentId === currentParentId) {
|
||||
if (hasLayoutRelevantSizeChange(before.blocks[blockId], after.blocks[blockId])) {
|
||||
resizedBlockIds.add(blockId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -57,6 +64,7 @@ export function getTargetedLayoutImpact({
|
||||
if (addedEdges.length === 0) {
|
||||
return {
|
||||
layoutBlockIds: Array.from(layoutBlockIds),
|
||||
resizedBlockIds: Array.from(resizedBlockIds),
|
||||
shiftSourceBlockIds: [],
|
||||
}
|
||||
}
|
||||
@@ -94,6 +102,7 @@ export function getTargetedLayoutImpact({
|
||||
|
||||
return {
|
||||
layoutBlockIds: Array.from(layoutBlockIds),
|
||||
resizedBlockIds: Array.from(resizedBlockIds),
|
||||
shiftSourceBlockIds: Array.from(shiftSourceBlockIds),
|
||||
}
|
||||
}
|
||||
@@ -123,6 +132,24 @@ function getBlocksWithInvalidPositions(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a persisted block changed size enough that anchored layout
|
||||
* should reopen its column and shift affected siblings.
|
||||
*/
|
||||
function hasLayoutRelevantSizeChange(
|
||||
beforeBlock: WorkflowState['blocks'][string] | undefined,
|
||||
afterBlock: WorkflowState['blocks'][string] | undefined
|
||||
): boolean {
|
||||
if (!beforeBlock || !afterBlock) {
|
||||
return false
|
||||
}
|
||||
|
||||
const beforeMetrics = getBlockMetrics(beforeBlock)
|
||||
const afterMetrics = getBlockMetrics(afterBlock)
|
||||
|
||||
return beforeMetrics.height !== afterMetrics.height || beforeMetrics.width !== afterMetrics.width
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns added edges that participate in layout within a shared parent scope.
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DEFAULT_VERTICAL_SPACING } from '@/lib/workflows/autolayout/constants'
|
||||
import { applyTargetedLayout } from '@/lib/workflows/autolayout/targeted'
|
||||
import type { Edge } from '@/lib/workflows/autolayout/types'
|
||||
import { getBlockMetrics } from '@/lib/workflows/autolayout/utils'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const { mockGetBlock } = vi.hoisted(() => ({
|
||||
mockGetBlock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/blocks', () => ({
|
||||
getBlock: mockGetBlock,
|
||||
}))
|
||||
|
||||
const JIRA_TEST_BLOCK_CONFIG = {
|
||||
category: 'tools',
|
||||
subBlocks: [
|
||||
{ id: 'operation', type: 'dropdown' },
|
||||
{ id: 'domain', type: 'short-input' },
|
||||
{ id: 'credential', type: 'oauth-input', mode: 'basic' },
|
||||
{ id: 'issueKey', type: 'short-input', condition: { field: 'operation', value: 'read' } },
|
||||
{ id: 'projectId', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'summary', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'description', type: 'long-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'priority', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'labels', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'issueType', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'parentIssue', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'assignee', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'reporter', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'environment', type: 'long-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'components', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
{ id: 'fixVersions', type: 'short-input', condition: { field: 'operation', value: 'write' } },
|
||||
],
|
||||
} as const
|
||||
|
||||
function createBlock(id: string, overrides: Partial<BlockState> = {}): BlockState {
|
||||
return {
|
||||
id,
|
||||
@@ -20,7 +51,51 @@ function createBlock(id: string, overrides: Partial<BlockState> = {}): BlockStat
|
||||
}
|
||||
}
|
||||
|
||||
function expectVerticalSeparation(upper: BlockState, lower: BlockState): void {
|
||||
const upperMetrics = getBlockMetrics(upper)
|
||||
expect(lower.position.y).toBeGreaterThanOrEqual(
|
||||
upper.position.y + upperMetrics.height + DEFAULT_VERTICAL_SPACING
|
||||
)
|
||||
}
|
||||
|
||||
function createJiraBlock(
|
||||
id: string,
|
||||
operation: 'read' | 'write',
|
||||
overrides: Partial<BlockState> = {}
|
||||
): BlockState {
|
||||
return createBlock(id, {
|
||||
type: 'jira',
|
||||
position: { x: 100, y: 100 },
|
||||
height: 100,
|
||||
layout: { measuredWidth: 250, measuredHeight: 100 },
|
||||
subBlocks: {
|
||||
operation: {
|
||||
id: 'operation',
|
||||
type: 'dropdown',
|
||||
value: operation,
|
||||
},
|
||||
domain: {
|
||||
id: 'domain',
|
||||
type: 'short-input',
|
||||
value: 'company.atlassian.net',
|
||||
},
|
||||
credential: {
|
||||
id: 'credential',
|
||||
type: 'oauth-input',
|
||||
value: 'credential-1',
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('applyTargetedLayout', () => {
|
||||
beforeEach(() => {
|
||||
mockGetBlock.mockImplementation((type: string) =>
|
||||
type === 'jira' ? JIRA_TEST_BLOCK_CONFIG : undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('shifts downstream frozen blocks when only shift sources are provided', () => {
|
||||
const blocks = {
|
||||
source: createBlock('source', {
|
||||
@@ -118,6 +193,128 @@ describe('applyTargetedLayout', () => {
|
||||
expect(result.changed.position.y).toBeLessThan(150)
|
||||
})
|
||||
|
||||
it('pushes frozen blocks below downstream nodes shifted into occupied columns', () => {
|
||||
const blocks = {
|
||||
start: createBlock('start', {
|
||||
position: { x: 100, y: 100 },
|
||||
}),
|
||||
inserted: createBlock('inserted', {
|
||||
position: { x: 0, y: 0 },
|
||||
}),
|
||||
end: createBlock('end', {
|
||||
position: { x: 400, y: 100 },
|
||||
}),
|
||||
branch: createBlock('branch', {
|
||||
position: { x: 990, y: 150 },
|
||||
}),
|
||||
}
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'start',
|
||||
target: 'inserted',
|
||||
},
|
||||
{
|
||||
id: 'edge-2',
|
||||
source: 'inserted',
|
||||
target: 'end',
|
||||
},
|
||||
]
|
||||
|
||||
const result = applyTargetedLayout(blocks, edges, {
|
||||
changedBlockIds: ['inserted'],
|
||||
})
|
||||
|
||||
expect(result.end.position).toEqual({ x: 960, y: 100 })
|
||||
expectVerticalSeparation(result.end, result.branch)
|
||||
})
|
||||
|
||||
it('repairs vertical overlaps during shift-only targeted layout passes', () => {
|
||||
const blocks = {
|
||||
source: createBlock('source', {
|
||||
position: { x: 100, y: 100 },
|
||||
}),
|
||||
target: createBlock('target', {
|
||||
position: { x: 400, y: 100 },
|
||||
}),
|
||||
sibling: createBlock('sibling', {
|
||||
position: { x: 560, y: 150 },
|
||||
}),
|
||||
}
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'source',
|
||||
target: 'target',
|
||||
},
|
||||
]
|
||||
|
||||
const result = applyTargetedLayout(blocks, edges, {
|
||||
changedBlockIds: [],
|
||||
shiftSourceBlockIds: ['source'],
|
||||
})
|
||||
|
||||
expect(result.target.position).toEqual({ x: 530, y: 100 })
|
||||
expectVerticalSeparation(result.target, result.sibling)
|
||||
})
|
||||
|
||||
it('resolves same-column overlaps even when another column is interleaved in Y order', () => {
|
||||
const blocks = {
|
||||
source: createBlock('source', {
|
||||
position: { x: 100, y: 100 },
|
||||
}),
|
||||
target: createBlock('target', {
|
||||
position: { x: 400, y: 100 },
|
||||
}),
|
||||
blocker: createBlock('blocker', {
|
||||
position: { x: 1500, y: 140 },
|
||||
}),
|
||||
sibling: createBlock('sibling', {
|
||||
position: { x: 560, y: 160 },
|
||||
}),
|
||||
}
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'source',
|
||||
target: 'target',
|
||||
},
|
||||
]
|
||||
|
||||
const result = applyTargetedLayout(blocks, edges, {
|
||||
changedBlockIds: [],
|
||||
shiftSourceBlockIds: ['source'],
|
||||
})
|
||||
|
||||
expect(result.blocker.position).toEqual({ x: 1500, y: 140 })
|
||||
expect(result.target.position).toEqual({ x: 530, y: 100 })
|
||||
expectVerticalSeparation(result.target, result.sibling)
|
||||
})
|
||||
|
||||
it('keeps resized integration blocks anchored while shifting frozen blocks below them', () => {
|
||||
const blocks = {
|
||||
above: createBlock('above', {
|
||||
position: { x: 430, y: 460 },
|
||||
}),
|
||||
jira: createJiraBlock('jira', 'write', {
|
||||
position: { x: 433, y: 690 },
|
||||
}),
|
||||
below: createBlock('below', {
|
||||
position: { x: 460, y: 1120 },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = applyTargetedLayout(blocks, [], {
|
||||
changedBlockIds: [],
|
||||
resizedBlockIds: ['jira'],
|
||||
})
|
||||
|
||||
expect(result.above.position).toEqual({ x: 430, y: 460 })
|
||||
expect(result.jira.position).toEqual({ x: 433, y: 690 })
|
||||
expect(getBlockMetrics(result.jira).height).toBeGreaterThan(100)
|
||||
expectVerticalSeparation(result.jira, result.below)
|
||||
})
|
||||
|
||||
it('places new parallel children below tall anchored siblings', () => {
|
||||
const blocks = {
|
||||
parallel: createBlock('parallel', {
|
||||
|
||||
@@ -18,8 +18,16 @@ import {
|
||||
import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
type TargetedBlockInfo = {
|
||||
id: string
|
||||
block: BlockState
|
||||
metrics: ReturnType<typeof getBlockMetrics>
|
||||
}
|
||||
|
||||
export interface TargetedLayoutOptions extends LayoutOptions {
|
||||
changedBlockIds: string[]
|
||||
/** Existing blocks whose size changed but whose position should remain anchored. */
|
||||
resizedBlockIds?: string[]
|
||||
shiftSourceBlockIds?: string[]
|
||||
verticalSpacing?: number
|
||||
horizontalSpacing?: number
|
||||
@@ -36,17 +44,23 @@ export function applyTargetedLayout(
|
||||
): Record<string, BlockState> {
|
||||
const {
|
||||
changedBlockIds,
|
||||
resizedBlockIds = [],
|
||||
shiftSourceBlockIds = [],
|
||||
verticalSpacing = DEFAULT_VERTICAL_SPACING,
|
||||
horizontalSpacing = DEFAULT_HORIZONTAL_SPACING,
|
||||
gridSize,
|
||||
} = options
|
||||
|
||||
if ((!changedBlockIds || changedBlockIds.length === 0) && shiftSourceBlockIds.length === 0) {
|
||||
if (
|
||||
(!changedBlockIds || changedBlockIds.length === 0) &&
|
||||
resizedBlockIds.length === 0 &&
|
||||
shiftSourceBlockIds.length === 0
|
||||
) {
|
||||
return blocks
|
||||
}
|
||||
|
||||
const changedSet = new Set(changedBlockIds)
|
||||
const resizedSet = new Set(resizedBlockIds)
|
||||
const shiftSourceSet = new Set(shiftSourceBlockIds)
|
||||
const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks))
|
||||
|
||||
@@ -69,6 +83,7 @@ export function applyTargetedLayout(
|
||||
blocksCopy,
|
||||
edges,
|
||||
changedSet,
|
||||
resizedSet,
|
||||
shiftSourceSet,
|
||||
verticalSpacing,
|
||||
horizontalSpacing,
|
||||
@@ -83,6 +98,7 @@ export function applyTargetedLayout(
|
||||
blocksCopy,
|
||||
edges,
|
||||
changedSet,
|
||||
resizedSet,
|
||||
shiftSourceSet,
|
||||
verticalSpacing,
|
||||
horizontalSpacing,
|
||||
@@ -130,8 +146,9 @@ function selectBestAnchor(
|
||||
|
||||
/**
|
||||
* Layouts a group of blocks (either root level or within a container).
|
||||
* Only repositions blocks in `changedSet` or those with invalid positions;
|
||||
* all other blocks act as anchors.
|
||||
* Only repositions blocks in `changedSet` or those with invalid positions.
|
||||
* Resized existing blocks remain anchored and instead drive shifts in nearby
|
||||
* frozen blocks when their new dimensions create overlap.
|
||||
*/
|
||||
function layoutGroup(
|
||||
parentId: string | null,
|
||||
@@ -139,6 +156,7 @@ function layoutGroup(
|
||||
blocks: Record<string, BlockState>,
|
||||
edges: Edge[],
|
||||
changedSet: Set<string>,
|
||||
resizedSet: Set<string>,
|
||||
shiftSourceSet: Set<string>,
|
||||
verticalSpacing: number,
|
||||
horizontalSpacing: number,
|
||||
@@ -170,8 +188,13 @@ function layoutGroup(
|
||||
})
|
||||
const needsLayoutSet = new Set([...requestedLayout, ...invalidPositions])
|
||||
const needsLayout = Array.from(needsLayoutSet)
|
||||
const resizedAnchorIds = layoutEligibleChildIds.filter((id) => resizedSet.has(id))
|
||||
const groupShiftSourceIds = layoutEligibleChildIds.filter((id) => shiftSourceSet.has(id))
|
||||
const activeShiftSourceSet = new Set([...needsLayoutSet, ...groupShiftSourceIds])
|
||||
const activeShiftSourceSet = new Set([
|
||||
...needsLayoutSet,
|
||||
...resizedAnchorIds,
|
||||
...groupShiftSourceIds,
|
||||
])
|
||||
|
||||
if (needsLayout.length === 0 && activeShiftSourceSet.size === 0) {
|
||||
if (parentBlock) {
|
||||
@@ -236,7 +259,7 @@ function layoutGroup(
|
||||
}
|
||||
}
|
||||
|
||||
shiftDownstreamFrozenBlocks(
|
||||
const shiftedFrozenIds = shiftDownstreamFrozenBlocks(
|
||||
activeShiftSourceSet,
|
||||
needsLayoutSet,
|
||||
layoutEligibleChildIds,
|
||||
@@ -246,9 +269,10 @@ function layoutGroup(
|
||||
gridSize
|
||||
)
|
||||
|
||||
if (needsLayout.length > 0) {
|
||||
const affectedBlockIds = new Set([...needsLayoutSet, ...resizedAnchorIds, ...shiftedFrozenIds])
|
||||
if (affectedBlockIds.size > 0) {
|
||||
resolveVerticalOverlapsWithFrozen(
|
||||
needsLayoutSet,
|
||||
affectedBlockIds,
|
||||
layoutEligibleChildIds,
|
||||
blocks,
|
||||
verticalSpacing,
|
||||
@@ -277,7 +301,7 @@ function shiftDownstreamFrozenBlocks(
|
||||
edges: Edge[],
|
||||
horizontalSpacing: number,
|
||||
gridSize?: number
|
||||
): void {
|
||||
): Set<string> {
|
||||
const eligibleSet = new Set(eligibleIds)
|
||||
|
||||
const downstreamMap = new Map<string, string[]>()
|
||||
@@ -317,6 +341,8 @@ function shiftDownstreamFrozenBlocks(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shifted
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,7 +353,7 @@ function shiftDownstreamFrozenBlocks(
|
||||
* through any further blocks below.
|
||||
*/
|
||||
function resolveVerticalOverlapsWithFrozen(
|
||||
needsLayoutSet: Set<string>,
|
||||
affectedBlockIds: Set<string>,
|
||||
eligibleIds: string[],
|
||||
blocks: Record<string, BlockState>,
|
||||
verticalSpacing: number,
|
||||
@@ -339,11 +365,11 @@ function resolveVerticalOverlapsWithFrozen(
|
||||
if (!block) return null
|
||||
return { id, block, metrics: getBlockMetrics(block) }
|
||||
})
|
||||
.filter((info): info is NonNullable<typeof info> => info !== null)
|
||||
.filter((info): info is TargetedBlockInfo => info !== null)
|
||||
|
||||
if (blockInfos.length < 2) return
|
||||
if (blockInfos.length < 2 || affectedBlockIds.size === 0) return
|
||||
|
||||
const movedSet = new Set(needsLayoutSet)
|
||||
const movedSet = new Set(affectedBlockIds)
|
||||
let hasOverlap = true
|
||||
let iteration = 0
|
||||
|
||||
@@ -355,27 +381,48 @@ function resolveVerticalOverlapsWithFrozen(
|
||||
|
||||
for (let i = 0; i < blockInfos.length - 1; i++) {
|
||||
const upper = blockInfos[i]
|
||||
const lower = blockInfos[i + 1]
|
||||
|
||||
if (!movedSet.has(upper.id) && !movedSet.has(lower.id)) continue
|
||||
for (let lowerIndex = i + 1; lowerIndex < blockInfos.length; lowerIndex++) {
|
||||
const lower = blockInfos[lowerIndex]
|
||||
|
||||
const upperRight = upper.block.position.x + upper.metrics.width
|
||||
const lowerRight = lower.block.position.x + lower.metrics.width
|
||||
if (upper.block.position.x >= lowerRight || lower.block.position.x >= upperRight) continue
|
||||
if (!movedSet.has(upper.id) && !movedSet.has(lower.id)) continue
|
||||
if (!blocksOverlapOnX(upper, lower)) continue
|
||||
|
||||
const requiredY = upper.block.position.y + upper.metrics.height + verticalSpacing
|
||||
if (lower.block.position.y >= requiredY) continue
|
||||
|
||||
const requiredY = upper.block.position.y + upper.metrics.height + verticalSpacing
|
||||
if (lower.block.position.y < requiredY) {
|
||||
lower.block.position = snapPositionToGrid(
|
||||
{ x: lower.block.position.x, y: requiredY },
|
||||
gridSize
|
||||
)
|
||||
movedSet.add(lower.id)
|
||||
reorderBlockInfoByY(blockInfos, lowerIndex)
|
||||
hasOverlap = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function blocksOverlapOnX(left: TargetedBlockInfo, right: TargetedBlockInfo): boolean {
|
||||
const leftRight = left.block.position.x + left.metrics.width
|
||||
const rightRight = right.block.position.x + right.metrics.width
|
||||
return left.block.position.x < rightRight && right.block.position.x < leftRight
|
||||
}
|
||||
|
||||
function reorderBlockInfoByY(blockInfos: TargetedBlockInfo[], fromIndex: number): void {
|
||||
const [movedInfo] = blockInfos.splice(fromIndex, 1)
|
||||
let insertIndex = fromIndex
|
||||
|
||||
while (
|
||||
insertIndex < blockInfos.length &&
|
||||
blockInfos[insertIndex].block.position.y < movedInfo.block.position.y
|
||||
) {
|
||||
insertIndex++
|
||||
}
|
||||
|
||||
blockInfos.splice(insertIndex, 0, movedInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes layout positions for a subset of blocks using the core layout function
|
||||
*/
|
||||
|
||||
@@ -4,14 +4,23 @@ import {
|
||||
CONTAINER_PADDING,
|
||||
CONTAINER_PADDING_X,
|
||||
CONTAINER_PADDING_Y,
|
||||
ESTIMATED_BLOCK_BOTTOM_PADDING,
|
||||
ESTIMATED_SUBBLOCK_HEIGHT,
|
||||
MAX_ESTIMATED_BLOCK_HEIGHT,
|
||||
ROOT_PADDING_X,
|
||||
ROOT_PADDING_Y,
|
||||
} from '@/lib/workflows/autolayout/constants'
|
||||
import type { BlockMetrics, BoundingBox, Edge, GraphNode } from '@/lib/workflows/autolayout/types'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
|
||||
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
buildSubBlockValues,
|
||||
type CanonicalModeOverrides,
|
||||
evaluateSubBlockCondition,
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockHidden,
|
||||
isSubBlockVisibleForMode,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
@@ -134,24 +143,98 @@ function getContainerMetrics(block: BlockState): BlockMetrics {
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates block height from subblock count when no measurement is available.
|
||||
* Only counts subblocks with non-null values to avoid over-counting conditional
|
||||
* fields (e.g. agent blocks define ~20 subblocks but only ~5 are typically visible).
|
||||
* The result is capped at MAX_ESTIMATED_BLOCK_HEIGHT to prevent massive layout gaps.
|
||||
* Counts visible preview subblocks using the same visibility rules as the
|
||||
* workflow block preview when no DOM measurements are available.
|
||||
*/
|
||||
function estimateBlockHeight(block: BlockState): number {
|
||||
const subBlocks = block.subBlocks || {}
|
||||
const visibleCount = Object.values(subBlocks).filter(
|
||||
(sb) => sb && sb.value !== null && sb.value !== undefined
|
||||
).length
|
||||
if (visibleCount === 0) return BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
function getVisiblePreviewSubBlockCount(block: BlockState): number {
|
||||
const blockConfig = getBlock(block.type)
|
||||
if (!blockConfig?.subBlocks?.length) {
|
||||
return Object.values(block.subBlocks || {}).filter((subBlock) => subBlock != null).length
|
||||
}
|
||||
|
||||
const estimated =
|
||||
BLOCK_DIMENSIONS.HEADER_HEIGHT +
|
||||
visibleCount * ESTIMATED_SUBBLOCK_HEIGHT +
|
||||
ESTIMATED_BLOCK_BOTTOM_PADDING
|
||||
const rawValues = buildSubBlockValues(block.subBlocks || {})
|
||||
const canonicalModeOverrides =
|
||||
typeof block.data?.canonicalModes === 'object' && block.data.canonicalModes !== null
|
||||
? (block.data.canonicalModes as CanonicalModeOverrides)
|
||||
: undefined
|
||||
|
||||
return Math.min(Math.max(estimated, BLOCK_DIMENSIONS.MIN_HEIGHT), MAX_ESTIMATED_BLOCK_HEIGHT)
|
||||
if (canonicalModeOverrides) {
|
||||
rawValues.__canonicalModes = canonicalModeOverrides
|
||||
}
|
||||
|
||||
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
|
||||
const effectiveAdvanced = Boolean(block.advancedMode)
|
||||
const effectiveTrigger = Boolean(block.triggerMode)
|
||||
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
|
||||
|
||||
return blockConfig.subBlocks.filter((subBlock) => {
|
||||
if (subBlock.hidden || subBlock.hideFromPreview) return false
|
||||
if (!isSubBlockFeatureEnabled(subBlock)) return false
|
||||
if (isSubBlockHidden(subBlock)) return false
|
||||
|
||||
if (effectiveTrigger) {
|
||||
const isValidTriggerSubblock = isPureTriggerBlock
|
||||
? subBlock.mode === 'trigger' || !subBlock.mode
|
||||
: subBlock.mode === 'trigger'
|
||||
|
||||
if (!isValidTriggerSubblock) {
|
||||
return false
|
||||
}
|
||||
} else if (subBlock.mode === 'trigger') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!isSubBlockVisibleForMode(
|
||||
subBlock,
|
||||
effectiveAdvanced,
|
||||
canonicalIndex,
|
||||
rawValues,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!subBlock.condition) return true
|
||||
return evaluateSubBlockCondition(subBlock.condition, rawValues)
|
||||
}).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates workflow block dimensions using the same deterministic row-based
|
||||
* formula used by the canvas block renderer.
|
||||
*/
|
||||
function estimateWorkflowBlockDimensions(block: BlockState): { width: number; height: number } {
|
||||
const blockConfig = getBlock(block.type)
|
||||
const visibleCount = getVisiblePreviewSubBlockCount(block)
|
||||
|
||||
if (!blockConfig) {
|
||||
return {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(
|
||||
BLOCK_DIMENSIONS.HEADER_HEIGHT +
|
||||
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
|
||||
visibleCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT,
|
||||
BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return calculateWorkflowBlockDimensions({
|
||||
blockType: block.type,
|
||||
category: blockConfig.category,
|
||||
displayTriggerMode: Boolean(block.triggerMode),
|
||||
visibleSubBlockCount: visibleCount,
|
||||
conditionRowCount:
|
||||
block.type === 'condition'
|
||||
? getConditionRows(block.id, block.subBlocks?.conditions?.value).length
|
||||
: 0,
|
||||
routerRowCount:
|
||||
block.type === 'router_v2'
|
||||
? getRouterRows(block.id, block.subBlocks?.routes?.value).length
|
||||
: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,10 +247,12 @@ function getRegularBlockMetrics(block: BlockState): BlockMetrics {
|
||||
const minHeight = BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
const measuredH = block.layout?.measuredHeight ?? block.height
|
||||
const measuredW = block.layout?.measuredWidth
|
||||
const estimatedDimensions = estimateWorkflowBlockDimensions(block)
|
||||
const estimatedHeight = estimatedDimensions.height
|
||||
|
||||
const hasMeasurement = typeof measuredH === 'number' && measuredH > 0
|
||||
const height = hasMeasurement ? Math.max(measuredH, minHeight) : estimateBlockHeight(block)
|
||||
const width = Math.max(measuredW ?? minWidth, minWidth)
|
||||
const height = hasMeasurement ? Math.max(measuredH, estimatedHeight, minHeight) : estimatedHeight
|
||||
const width = Math.max(measuredW ?? estimatedDimensions.width, minWidth)
|
||||
|
||||
return {
|
||||
width,
|
||||
|
||||
47
apps/sim/lib/workflows/blocks/deterministic-dimensions.ts
Normal file
47
apps/sim/lib/workflows/blocks/deterministic-dimensions.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
interface WorkflowBlockDimensionsInput {
|
||||
blockType: string
|
||||
category: BlockConfig['category']
|
||||
displayTriggerMode: boolean
|
||||
visibleSubBlockCount: number
|
||||
conditionRowCount?: number
|
||||
routerRowCount?: number
|
||||
}
|
||||
|
||||
export function calculateWorkflowBlockDimensions({
|
||||
blockType,
|
||||
category,
|
||||
displayTriggerMode,
|
||||
visibleSubBlockCount,
|
||||
conditionRowCount = 0,
|
||||
routerRowCount = 0,
|
||||
}: WorkflowBlockDimensionsInput): { width: number; height: number } {
|
||||
const shouldShowDefaultHandles =
|
||||
category !== 'triggers' && blockType !== 'starter' && !displayTriggerMode
|
||||
const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0
|
||||
|
||||
let rowsCount = 0
|
||||
if (blockType === 'condition') {
|
||||
rowsCount = conditionRowCount + defaultHandlesRow
|
||||
} else if (blockType === 'router_v2') {
|
||||
rowsCount = 1 + routerRowCount + defaultHandlesRow
|
||||
} else {
|
||||
rowsCount = visibleSubBlockCount + defaultHandlesRow
|
||||
}
|
||||
|
||||
const hasContentBelowHeader = rowsCount > 0
|
||||
const contentHeight = hasContentBelowHeader
|
||||
? BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING + rowsCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
|
||||
: 0
|
||||
const height = Math.max(
|
||||
BLOCK_DIMENSIONS.HEADER_HEIGHT + contentHeight,
|
||||
BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
)
|
||||
|
||||
return {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height,
|
||||
}
|
||||
}
|
||||
@@ -522,14 +522,18 @@ export class WorkflowDiffEngine {
|
||||
// Apply autolayout to the proposed state
|
||||
logger.info('Applying autolayout to proposed workflow state')
|
||||
try {
|
||||
const { layoutBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
|
||||
const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({
|
||||
before: mergedBaseline,
|
||||
after: fullyCleanedState,
|
||||
})
|
||||
|
||||
const totalBlocks = Object.keys(finalBlocks).length
|
||||
|
||||
if (layoutBlockIds.length === 0 && shiftSourceBlockIds.length === 0) {
|
||||
if (
|
||||
layoutBlockIds.length === 0 &&
|
||||
resizedBlockIds.length === 0 &&
|
||||
shiftSourceBlockIds.length === 0
|
||||
) {
|
||||
logger.info('No blocks need layout; skipping autolayout', {
|
||||
totalBlocks,
|
||||
})
|
||||
@@ -541,6 +545,7 @@ export class WorkflowDiffEngine {
|
||||
// as applyAutoLayout but with one unified code path.
|
||||
logger.info('Using targeted layout for copilot edits', {
|
||||
blocksNeedingLayout: layoutBlockIds.length,
|
||||
resizedAnchorBlocks: resizedBlockIds.length,
|
||||
shiftSourceBlocks: shiftSourceBlockIds.length,
|
||||
anchors: totalBlocks - layoutBlockIds.length,
|
||||
totalBlocks,
|
||||
@@ -553,6 +558,7 @@ export class WorkflowDiffEngine {
|
||||
|
||||
const layoutedBlocks = applyTargetedLayout(finalBlocks, fullyCleanedState.edges, {
|
||||
changedBlockIds: layoutBlockIds,
|
||||
resizedBlockIds,
|
||||
shiftSourceBlockIds,
|
||||
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
|
||||
verticalSpacing: DEFAULT_VERTICAL_SPACING,
|
||||
|
||||
Reference in New Issue
Block a user