Skip to content

Commit c5fc2eb

Browse files
committed
feat(search): workflow search and replace
1 parent 9eeb1b2 commit c5fc2eb

38 files changed

Lines changed: 3136 additions & 21 deletions

apps/realtime/src/database/operations.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
EDGE_OPERATIONS,
99
EDGES_OPERATIONS,
1010
OPERATION_TARGETS,
11+
SUBBLOCK_OPERATIONS,
1112
SUBFLOW_OPERATIONS,
1213
VARIABLE_OPERATIONS,
1314
WORKFLOW_OPERATIONS,
@@ -251,6 +252,9 @@ export async function persistWorkflowOperation(workflowId: string, operation: an
251252
case OPERATION_TARGETS.SUBFLOW:
252253
await handleSubflowOperationTx(tx, workflowId, op, payload)
253254
break
255+
case OPERATION_TARGETS.SUBBLOCK:
256+
await handleSubblockOperationTx(tx, workflowId, op, payload)
257+
break
254258
case OPERATION_TARGETS.VARIABLE:
255259
await handleVariableOperationTx(tx, workflowId, op, payload)
256260
break
@@ -1734,6 +1738,93 @@ async function handleSubflowOperationTx(
17341738
}
17351739
}
17361740

1741+
function valuesEqual(left: unknown, right: unknown): boolean {
1742+
return JSON.stringify(left) === JSON.stringify(right)
1743+
}
1744+
1745+
// Subblock operations - targeted value updates without replacing workflow state
1746+
async function handleSubblockOperationTx(
1747+
tx: any,
1748+
workflowId: string,
1749+
operation: string,
1750+
payload: any
1751+
) {
1752+
switch (operation) {
1753+
case SUBBLOCK_OPERATIONS.BATCH_UPDATE: {
1754+
const updates = payload.updates
1755+
if (!Array.isArray(updates) || updates.length === 0) {
1756+
return
1757+
}
1758+
1759+
for (const update of updates) {
1760+
const { blockId, subblockId, value, expectedValue } = update
1761+
if (!blockId || !subblockId) {
1762+
throw new Error('Missing required fields for subblock batch update')
1763+
}
1764+
1765+
const [block] = await tx
1766+
.select({
1767+
subBlocks: workflowBlocks.subBlocks,
1768+
locked: workflowBlocks.locked,
1769+
data: workflowBlocks.data,
1770+
})
1771+
.from(workflowBlocks)
1772+
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
1773+
.limit(1)
1774+
1775+
if (!block) {
1776+
throw new Error(`Block ${blockId} not found`)
1777+
}
1778+
1779+
if (block.locked) {
1780+
throw new Error(`Block ${blockId} is locked`)
1781+
}
1782+
1783+
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
1784+
| string
1785+
| undefined
1786+
if (parentId) {
1787+
const [parentBlock] = await tx
1788+
.select({ locked: workflowBlocks.locked })
1789+
.from(workflowBlocks)
1790+
.where(and(eq(workflowBlocks.id, parentId), eq(workflowBlocks.workflowId, workflowId)))
1791+
.limit(1)
1792+
1793+
if (parentBlock?.locked) {
1794+
throw new Error(`Parent block ${parentId} is locked`)
1795+
}
1796+
}
1797+
1798+
const subBlocks = { ...((block.subBlocks as Record<string, any>) || {}) }
1799+
const currentSubBlock = subBlocks[subblockId]
1800+
const currentValue = currentSubBlock?.value
1801+
if (expectedValue !== undefined && !valuesEqual(currentValue, expectedValue)) {
1802+
throw new Error(`Subblock ${blockId}.${subblockId} changed since replacement was planned`)
1803+
}
1804+
1805+
subBlocks[subblockId] = currentSubBlock
1806+
? { ...currentSubBlock, value }
1807+
: { id: subblockId, type: 'unknown', value }
1808+
1809+
await tx
1810+
.update(workflowBlocks)
1811+
.set({
1812+
subBlocks,
1813+
updatedAt: new Date(),
1814+
})
1815+
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
1816+
}
1817+
1818+
logger.debug(`Batch updated ${updates.length} subblocks for workflow ${workflowId}`)
1819+
break
1820+
}
1821+
1822+
default:
1823+
logger.warn(`Unknown subblock operation: ${operation}`)
1824+
throw new Error(`Unsupported subblock operation: ${operation}`)
1825+
}
1826+
}
1827+
17371828
// Variable operations - updates workflow.variables JSON field
17381829
async function handleVariableOperationTx(
17391830
tx: any,

apps/realtime/src/middleware/permissions.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ describe('checkRolePermission', () => {
5151
const result = checkRolePermission('admin', 'replace-state')
5252
expectPermissionAllowed(result)
5353
})
54+
55+
it('should allow subblock-batch-update operation', () => {
56+
const result = checkRolePermission('admin', 'subblock-batch-update')
57+
expectPermissionAllowed(result)
58+
})
5459
})
5560

5661
describe('write role', () => {
@@ -77,6 +82,11 @@ describe('checkRolePermission', () => {
7782
const result = checkRolePermission('write', 'update-position')
7883
expectPermissionAllowed(result)
7984
})
85+
86+
it('should allow subblock-batch-update operation', () => {
87+
const result = checkRolePermission('write', 'subblock-batch-update')
88+
expectPermissionAllowed(result)
89+
})
8090
})
8191

8292
describe('read role', () => {
@@ -111,6 +121,11 @@ describe('checkRolePermission', () => {
111121
expectPermissionDenied(result, 'read')
112122
})
113123

124+
it('should deny subblock-batch-update operation for read role', () => {
125+
const result = checkRolePermission('read', 'subblock-batch-update')
126+
expectPermissionDenied(result, 'read')
127+
})
128+
114129
it('should deny toggle-enabled operation for read role', () => {
115130
const result = checkRolePermission('read', 'toggle-enabled')
116131
expectPermissionDenied(result, 'read')

apps/realtime/src/middleware/permissions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const WRITE_OPERATIONS: string[] = [
4646
SUBFLOW_OPERATIONS.UPDATE,
4747
// Subblock operations
4848
SUBBLOCK_OPERATIONS.UPDATE,
49+
SUBBLOCK_OPERATIONS.BATCH_UPDATE,
4950
// Variable operations
5051
VARIABLE_OPERATIONS.UPDATE,
5152
// Workflow operations

apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type CommandId =
1414
// | 'goto-templates'
1515
| 'goto-logs'
1616
| 'open-search'
17+
| 'open-workflow-search-replace'
1718
| 'run-workflow'
1819
| 'clear-terminal-console'
1920
| 'focus-toolbar-search'
@@ -79,6 +80,11 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
7980
shortcut: 'Mod+K',
8081
allowInEditable: true,
8182
},
83+
'open-workflow-search-replace': {
84+
id: 'open-workflow-search-replace',
85+
shortcut: 'Mod+F',
86+
allowInEditable: true,
87+
},
8288
'run-workflow': {
8389
id: 'run-workflow',
8490
shortcut: 'Mod+Enter',
@@ -91,7 +97,7 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
9197
},
9298
'focus-toolbar-search': {
9399
id: 'focus-toolbar-search',
94-
shortcut: 'Mod+F',
100+
shortcut: 'Mod+Alt+F',
95101
allowInEditable: false,
96102
},
97103
'clear-notifications': {

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ interface SubBlockProps {
103103
labelSuffix?: React.ReactNode
104104
/** Provides sibling values for dependency resolution in non-preview contexts (e.g. tool-input) */
105105
dependencyContext?: Record<string, unknown>
106+
isSearchHighlighted?: boolean
106107
}
107108

108109
/**
@@ -436,6 +437,7 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
436437
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
437438
canonicalToggleEqual &&
438439
prevProps.labelSuffix === nextProps.labelSuffix &&
440+
prevProps.isSearchHighlighted === nextProps.isSearchHighlighted &&
439441
prevProps.dependencyContext === nextProps.dependencyContext
440442
)
441443
}
@@ -452,6 +454,7 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
452454
* @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
453455
* @param labelSuffix - Additional content rendered after the label text
454456
* @param dependencyContext - Sibling values for dependency resolution in non-preview contexts (e.g. tool-input)
457+
* @param isSearchHighlighted - Whether workflow search should highlight this field
455458
*/
456459
function SubBlockComponent({
457460
blockId,
@@ -463,6 +466,7 @@ function SubBlockComponent({
463466
canonicalToggle,
464467
labelSuffix,
465468
dependencyContext,
469+
isSearchHighlighted,
466470
}: SubBlockProps): JSX.Element {
467471
const params = useParams()
468472
const workspaceId = params.workspaceId as string
@@ -1165,7 +1169,15 @@ function SubBlockComponent({
11651169
}
11661170

11671171
return (
1168-
<div onMouseDown={handleMouseDown} className='subblock-content flex flex-col gap-2.5'>
1172+
<div
1173+
onMouseDown={handleMouseDown}
1174+
data-workflow-search-subblock-id={config.id}
1175+
data-workflow-search-canonical-id={config.canonicalParamId ?? config.id}
1176+
className={cn(
1177+
'subblock-content flex flex-col gap-2.5 rounded-md transition-colors',
1178+
isSearchHighlighted && 'bg-[var(--surface-3)] p-2 ring-1 ring-[var(--border-1)]'
1179+
)}
1180+
>
11691181
{renderLabel(
11701182
config,
11711183
isValidJson,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,21 @@ const IconComponent = ({ icon: Icon, className }: { icon: any; className?: strin
8585
* @returns Editor panel content
8686
*/
8787
export function Editor() {
88-
const { currentBlockId, connectionsHeight, toggleConnectionsCollapsed, registerRenameCallback } =
89-
usePanelEditorStore(
90-
useShallow((state) => ({
91-
currentBlockId: state.currentBlockId,
92-
connectionsHeight: state.connectionsHeight,
93-
toggleConnectionsCollapsed: state.toggleConnectionsCollapsed,
94-
registerRenameCallback: state.registerRenameCallback,
95-
}))
96-
)
88+
const {
89+
currentBlockId,
90+
activeSearchTarget,
91+
connectionsHeight,
92+
toggleConnectionsCollapsed,
93+
registerRenameCallback,
94+
} = usePanelEditorStore(
95+
useShallow((state) => ({
96+
currentBlockId: state.currentBlockId,
97+
activeSearchTarget: state.activeSearchTarget,
98+
connectionsHeight: state.connectionsHeight,
99+
toggleConnectionsCollapsed: state.toggleConnectionsCollapsed,
100+
registerRenameCallback: state.registerRenameCallback,
101+
}))
102+
)
97103
const currentWorkflow = useCurrentWorkflow()
98104
const currentBlock = currentBlockId ? currentWorkflow.getBlockById(currentBlockId) : null
99105
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
@@ -238,6 +244,22 @@ export function Editor() {
238244
const [editedName, setEditedName] = useState('')
239245
const renamingBlockIdRef = useRef<string | null>(null)
240246

247+
useEffect(() => {
248+
if (!activeSearchTarget || activeSearchTarget.blockId !== currentBlockId) return
249+
const container = subBlocksRef.current
250+
if (!container) return
251+
252+
const directTarget = container.querySelector<HTMLElement>(
253+
`[data-workflow-search-subblock-id="${activeSearchTarget.subBlockId}"]`
254+
)
255+
const target =
256+
directTarget ??
257+
container.querySelector<HTMLElement>(
258+
`[data-workflow-search-canonical-id="${activeSearchTarget.canonicalSubBlockId}"]`
259+
)
260+
target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
261+
}, [activeSearchTarget, currentBlockId, subBlocks])
262+
241263
/**
242264
* Ref callback that auto-selects the input text when mounted.
243265
*/
@@ -586,6 +608,12 @@ export function Editor() {
586608
subBlockValues={subBlockState}
587609
disabled={!canEditBlock}
588610
allowExpandInPreview={false}
611+
isSearchHighlighted={
612+
activeSearchTarget?.blockId === currentBlockId &&
613+
(activeSearchTarget.subBlockId === subBlock.id ||
614+
activeSearchTarget.canonicalSubBlockId ===
615+
(subBlock.canonicalParamId ?? subBlock.id))
616+
}
589617
canonicalToggle={
590618
isCanonicalSwap && canonicalMode && canonicalId
591619
? {
@@ -658,6 +686,12 @@ export function Editor() {
658686
subBlockValues={subBlockState}
659687
disabled={!canEditBlock}
660688
allowExpandInPreview={false}
689+
isSearchHighlighted={
690+
activeSearchTarget?.blockId === currentBlockId &&
691+
(activeSearchTarget.subBlockId === subBlock.id ||
692+
activeSearchTarget.canonicalSubBlockId ===
693+
(subBlock.canonicalParamId ?? subBlock.id))
694+
}
661695
/>
662696
{index < advancedOnlySubBlocks.length - 1 && (
663697
<div className='subblock-divider px-0.5 pt-4 pb-[13px]'>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ export const Toolbar = memo(
474474
*
475475
* If the search query is empty, deactivate search mode to show the search icon again.
476476
* If there's a query, keep search mode active so ArrowUp/Down navigation continues
477-
* to work after focus moves into the triggers/blocks list (e.g. when initiated via Mod+F).
477+
* to work after focus moves into the triggers/blocks list (e.g. when initiated via toolbar search shortcut).
478478
*/
479479
const handleSearchBlur = () => {
480480
if (!searchQuery.trim()) {
@@ -581,8 +581,8 @@ export const Toolbar = memo(
581581
* - Within blocks: linear navigation
582582
* - ArrowUp from first trigger: moves focus back to search input
583583
*
584-
* This is designed to work seamlessly when the toolbar is opened via the
585-
* Mod+F shortcut, and to take precedence over other global ArrowUp/Down
584+
* This is designed to work seamlessly when the toolbar search shortcut opens it,
585+
* and to take precedence over other global ArrowUp/Down
586586
* handlers (e.g. terminal navigation) while the toolbar tab is active.
587587
*/
588588
useEffect(() => {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { toError } from '@sim/utils/errors'
66
import { useQueryClient } from '@tanstack/react-query'
7-
import { History, Plus, Square } from 'lucide-react'
7+
import { History, Plus, Search, Square } from 'lucide-react'
88
import { useParams, useRouter } from 'next/navigation'
99
import { usePostHog } from 'posthog-js/react'
1010
import { useShallow } from 'zustand/react/shallow'
@@ -86,6 +86,7 @@ import { useVariablesModalStore } from '@/stores/variables/modal'
8686
import { useVariablesStore } from '@/stores/variables/store'
8787
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
8888
import { captureBaselineSnapshot } from '@/stores/workflow-diff/utils'
89+
import { useWorkflowSearchReplaceStore } from '@/stores/workflow-search-replace/store'
8990
import { getWorkflowWithValues } from '@/stores/workflows'
9091
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
9192
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -598,12 +599,13 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
598599
const hasValidationErrors = false // TODO: Add validation logic if needed
599600
const isWorkflowBlocked = isExecuting || hasValidationErrors
600601
const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions))
602+
const openWorkflowSearchReplace = useWorkflowSearchReplaceStore((state) => state.open)
601603

602604
/**
603605
* Register global keyboard shortcuts using the central commands registry.
604606
*
605607
* - Mod+Enter: Run / cancel workflow (matches the Run button behavior)
606-
* - Mod+F: Focus Toolbar tab and search input
608+
* - Mod+Alt+F: Focus Toolbar tab and search input
607609
*/
608610
useRegisterGlobalCommands(() =>
609611
createCommands([
@@ -666,6 +668,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
666668
<VariableIcon />
667669
Variables
668670
</DropdownMenuItem>
671+
<DropdownMenuItem onSelect={openWorkflowSearchReplace}>
672+
<Search />
673+
Search and replace
674+
</DropdownMenuItem>
669675
{userPermissions.canAdmin && !isSnapshotView && (
670676
<DropdownMenuItem
671677
onSelect={handleToggleWorkflowLock}

0 commit comments

Comments
 (0)