From c5fc2eb0e3c4efb3fcf6f215d30698a17542a09a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 4 May 2026 18:22:28 -0700 Subject: [PATCH 01/11] feat(search): workflow search and replace --- apps/realtime/src/database/operations.ts | 91 ++++ .../src/middleware/permissions.test.ts | 15 + apps/realtime/src/middleware/permissions.ts | 1 + .../[workspaceId]/utils/commands-utils.ts | 8 +- .../editor/components/sub-block/sub-block.tsx | 14 +- .../panel/components/editor/editor.tsx | 52 +- .../panel/components/toolbar/toolbar.tsx | 6 +- .../w/[workflowId]/components/panel/panel.tsx | 10 +- ...e-workflow-resource-replacement-options.ts | 51 ++ ...use-workflow-search-reference-hydration.ts | 106 ++++ .../search-replace/replacement-controls.tsx | 85 ++++ .../workflow-search-replace.tsx | 467 ++++++++++++++++++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 2 + .../queries/workflow-search-replace.test.ts | 53 ++ .../hooks/queries/workflow-search-replace.ts | 290 +++++++++++ apps/sim/hooks/use-collaborative-workflow.ts | 78 +++ apps/sim/hooks/use-undo-redo.ts | 86 +++- .../tools/handlers/platform-actions.ts | 3 +- .../workflows/search-replace/dependencies.ts | 17 + .../workflows/search-replace/indexer.test.ts | 158 ++++++ .../lib/workflows/search-replace/indexer.ts | 264 ++++++++++ .../search-replace/reference-registry.ts | 152 ++++++ .../search-replace/replacement-validation.ts | 97 ++++ .../search-replace/replacements.test.ts | 190 +++++++ .../workflows/search-replace/replacements.ts | 190 +++++++ .../search-replace/resource-resolvers.ts | 114 +++++ .../search-replace/search-replace.fixtures.ts | 142 ++++++ .../workflows/search-replace/state.test.ts | 51 ++ .../sim/lib/workflows/search-replace/state.ts | 17 + .../sim/lib/workflows/search-replace/types.ts | 117 +++++ .../workflows/search-replace/value-walker.ts | 76 +++ apps/sim/stores/panel/editor/store.ts | 23 +- apps/sim/stores/undo-redo/types.ts | 13 + apps/sim/stores/undo-redo/utils.test.ts | 39 +- apps/sim/stores/undo-redo/utils.ts | 16 + .../stores/workflow-search-replace/store.ts | 42 ++ packages/realtime-protocol/src/constants.ts | 2 + packages/realtime-protocol/src/schemas.ts | 19 + 38 files changed, 3136 insertions(+), 21 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-resource-replacement-options.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-search-reference-hydration.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx create mode 100644 apps/sim/hooks/queries/workflow-search-replace.test.ts create mode 100644 apps/sim/hooks/queries/workflow-search-replace.ts create mode 100644 apps/sim/lib/workflows/search-replace/dependencies.ts create mode 100644 apps/sim/lib/workflows/search-replace/indexer.test.ts create mode 100644 apps/sim/lib/workflows/search-replace/indexer.ts create mode 100644 apps/sim/lib/workflows/search-replace/reference-registry.ts create mode 100644 apps/sim/lib/workflows/search-replace/replacement-validation.ts create mode 100644 apps/sim/lib/workflows/search-replace/replacements.test.ts create mode 100644 apps/sim/lib/workflows/search-replace/replacements.ts create mode 100644 apps/sim/lib/workflows/search-replace/resource-resolvers.ts create mode 100644 apps/sim/lib/workflows/search-replace/search-replace.fixtures.ts create mode 100644 apps/sim/lib/workflows/search-replace/state.test.ts create mode 100644 apps/sim/lib/workflows/search-replace/state.ts create mode 100644 apps/sim/lib/workflows/search-replace/types.ts create mode 100644 apps/sim/lib/workflows/search-replace/value-walker.ts create mode 100644 apps/sim/stores/workflow-search-replace/store.ts diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index 279b56a9b03..b9e1c8b74a5 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -8,6 +8,7 @@ import { EDGE_OPERATIONS, EDGES_OPERATIONS, OPERATION_TARGETS, + SUBBLOCK_OPERATIONS, SUBFLOW_OPERATIONS, VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, @@ -251,6 +252,9 @@ export async function persistWorkflowOperation(workflowId: string, operation: an case OPERATION_TARGETS.SUBFLOW: await handleSubflowOperationTx(tx, workflowId, op, payload) break + case OPERATION_TARGETS.SUBBLOCK: + await handleSubblockOperationTx(tx, workflowId, op, payload) + break case OPERATION_TARGETS.VARIABLE: await handleVariableOperationTx(tx, workflowId, op, payload) break @@ -1734,6 +1738,93 @@ async function handleSubflowOperationTx( } } +function valuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +// Subblock operations - targeted value updates without replacing workflow state +async function handleSubblockOperationTx( + tx: any, + workflowId: string, + operation: string, + payload: any +) { + switch (operation) { + case SUBBLOCK_OPERATIONS.BATCH_UPDATE: { + const updates = payload.updates + if (!Array.isArray(updates) || updates.length === 0) { + return + } + + for (const update of updates) { + const { blockId, subblockId, value, expectedValue } = update + if (!blockId || !subblockId) { + throw new Error('Missing required fields for subblock batch update') + } + + const [block] = await tx + .select({ + subBlocks: workflowBlocks.subBlocks, + locked: workflowBlocks.locked, + data: workflowBlocks.data, + }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (!block) { + throw new Error(`Block ${blockId} not found`) + } + + if (block.locked) { + throw new Error(`Block ${blockId} is locked`) + } + + const parentId = (block.data as Record | null)?.parentId as + | string + | undefined + if (parentId) { + const [parentBlock] = await tx + .select({ locked: workflowBlocks.locked }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, parentId), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (parentBlock?.locked) { + throw new Error(`Parent block ${parentId} is locked`) + } + } + + const subBlocks = { ...((block.subBlocks as Record) || {}) } + const currentSubBlock = subBlocks[subblockId] + const currentValue = currentSubBlock?.value + if (expectedValue !== undefined && !valuesEqual(currentValue, expectedValue)) { + throw new Error(`Subblock ${blockId}.${subblockId} changed since replacement was planned`) + } + + subBlocks[subblockId] = currentSubBlock + ? { ...currentSubBlock, value } + : { id: subblockId, type: 'unknown', value } + + await tx + .update(workflowBlocks) + .set({ + subBlocks, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + } + + logger.debug(`Batch updated ${updates.length} subblocks for workflow ${workflowId}`) + break + } + + default: + logger.warn(`Unknown subblock operation: ${operation}`) + throw new Error(`Unsupported subblock operation: ${operation}`) + } +} + // Variable operations - updates workflow.variables JSON field async function handleVariableOperationTx( tx: any, diff --git a/apps/realtime/src/middleware/permissions.test.ts b/apps/realtime/src/middleware/permissions.test.ts index 2d8cd12999c..0aa9cada905 100644 --- a/apps/realtime/src/middleware/permissions.test.ts +++ b/apps/realtime/src/middleware/permissions.test.ts @@ -51,6 +51,11 @@ describe('checkRolePermission', () => { const result = checkRolePermission('admin', 'replace-state') expectPermissionAllowed(result) }) + + it('should allow subblock-batch-update operation', () => { + const result = checkRolePermission('admin', 'subblock-batch-update') + expectPermissionAllowed(result) + }) }) describe('write role', () => { @@ -77,6 +82,11 @@ describe('checkRolePermission', () => { const result = checkRolePermission('write', 'update-position') expectPermissionAllowed(result) }) + + it('should allow subblock-batch-update operation', () => { + const result = checkRolePermission('write', 'subblock-batch-update') + expectPermissionAllowed(result) + }) }) describe('read role', () => { @@ -111,6 +121,11 @@ describe('checkRolePermission', () => { expectPermissionDenied(result, 'read') }) + it('should deny subblock-batch-update operation for read role', () => { + const result = checkRolePermission('read', 'subblock-batch-update') + expectPermissionDenied(result, 'read') + }) + it('should deny toggle-enabled operation for read role', () => { const result = checkRolePermission('read', 'toggle-enabled') expectPermissionDenied(result, 'read') diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index db97b16f8a2..88949184581 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -46,6 +46,7 @@ const WRITE_OPERATIONS: string[] = [ SUBFLOW_OPERATIONS.UPDATE, // Subblock operations SUBBLOCK_OPERATIONS.UPDATE, + SUBBLOCK_OPERATIONS.BATCH_UPDATE, // Variable operations VARIABLE_OPERATIONS.UPDATE, // Workflow operations diff --git a/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts b/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts index 8bdbeac2577..bb237c157c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts @@ -14,6 +14,7 @@ export type CommandId = // | 'goto-templates' | 'goto-logs' | 'open-search' + | 'open-workflow-search-replace' | 'run-workflow' | 'clear-terminal-console' | 'focus-toolbar-search' @@ -79,6 +80,11 @@ export const COMMAND_DEFINITIONS: Record = { shortcut: 'Mod+K', allowInEditable: true, }, + 'open-workflow-search-replace': { + id: 'open-workflow-search-replace', + shortcut: 'Mod+F', + allowInEditable: true, + }, 'run-workflow': { id: 'run-workflow', shortcut: 'Mod+Enter', @@ -91,7 +97,7 @@ export const COMMAND_DEFINITIONS: Record = { }, 'focus-toolbar-search': { id: 'focus-toolbar-search', - shortcut: 'Mod+F', + shortcut: 'Mod+Alt+F', allowInEditable: false, }, 'clear-notifications': { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index a354f177e08..ef0ed5a6100 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -103,6 +103,7 @@ interface SubBlockProps { labelSuffix?: React.ReactNode /** Provides sibling values for dependency resolution in non-preview contexts (e.g. tool-input) */ dependencyContext?: Record + isSearchHighlighted?: boolean } /** @@ -436,6 +437,7 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool prevProps.allowExpandInPreview === nextProps.allowExpandInPreview && canonicalToggleEqual && prevProps.labelSuffix === nextProps.labelSuffix && + prevProps.isSearchHighlighted === nextProps.isSearchHighlighted && prevProps.dependencyContext === nextProps.dependencyContext ) } @@ -452,6 +454,7 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool * @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle * @param labelSuffix - Additional content rendered after the label text * @param dependencyContext - Sibling values for dependency resolution in non-preview contexts (e.g. tool-input) + * @param isSearchHighlighted - Whether workflow search should highlight this field */ function SubBlockComponent({ blockId, @@ -463,6 +466,7 @@ function SubBlockComponent({ canonicalToggle, labelSuffix, dependencyContext, + isSearchHighlighted, }: SubBlockProps): JSX.Element { const params = useParams() const workspaceId = params.workspaceId as string @@ -1165,7 +1169,15 @@ function SubBlockComponent({ } return ( -
+
{renderLabel( config, isValidJson, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index 1753bc2da46..53d99381d9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -85,15 +85,21 @@ const IconComponent = ({ icon: Icon, className }: { icon: any; className?: strin * @returns Editor panel content */ export function Editor() { - const { currentBlockId, connectionsHeight, toggleConnectionsCollapsed, registerRenameCallback } = - usePanelEditorStore( - useShallow((state) => ({ - currentBlockId: state.currentBlockId, - connectionsHeight: state.connectionsHeight, - toggleConnectionsCollapsed: state.toggleConnectionsCollapsed, - registerRenameCallback: state.registerRenameCallback, - })) - ) + const { + currentBlockId, + activeSearchTarget, + connectionsHeight, + toggleConnectionsCollapsed, + registerRenameCallback, + } = usePanelEditorStore( + useShallow((state) => ({ + currentBlockId: state.currentBlockId, + activeSearchTarget: state.activeSearchTarget, + connectionsHeight: state.connectionsHeight, + toggleConnectionsCollapsed: state.toggleConnectionsCollapsed, + registerRenameCallback: state.registerRenameCallback, + })) + ) const currentWorkflow = useCurrentWorkflow() const currentBlock = currentBlockId ? currentWorkflow.getBlockById(currentBlockId) : null const blockConfig = currentBlock ? getBlock(currentBlock.type) : null @@ -238,6 +244,22 @@ export function Editor() { const [editedName, setEditedName] = useState('') const renamingBlockIdRef = useRef(null) + useEffect(() => { + if (!activeSearchTarget || activeSearchTarget.blockId !== currentBlockId) return + const container = subBlocksRef.current + if (!container) return + + const directTarget = container.querySelector( + `[data-workflow-search-subblock-id="${activeSearchTarget.subBlockId}"]` + ) + const target = + directTarget ?? + container.querySelector( + `[data-workflow-search-canonical-id="${activeSearchTarget.canonicalSubBlockId}"]` + ) + target?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, [activeSearchTarget, currentBlockId, subBlocks]) + /** * Ref callback that auto-selects the input text when mounted. */ @@ -586,6 +608,12 @@ export function Editor() { subBlockValues={subBlockState} disabled={!canEditBlock} allowExpandInPreview={false} + isSearchHighlighted={ + activeSearchTarget?.blockId === currentBlockId && + (activeSearchTarget.subBlockId === subBlock.id || + activeSearchTarget.canonicalSubBlockId === + (subBlock.canonicalParamId ?? subBlock.id)) + } canonicalToggle={ isCanonicalSwap && canonicalMode && canonicalId ? { @@ -658,6 +686,12 @@ export function Editor() { subBlockValues={subBlockState} disabled={!canEditBlock} allowExpandInPreview={false} + isSearchHighlighted={ + activeSearchTarget?.blockId === currentBlockId && + (activeSearchTarget.subBlockId === subBlock.id || + activeSearchTarget.canonicalSubBlockId === + (subBlock.canonicalParamId ?? subBlock.id)) + } /> {index < advancedOnlySubBlocks.length - 1 && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index 5ceaed98cb9..0f6961e760c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -474,7 +474,7 @@ export const Toolbar = memo( * * If the search query is empty, deactivate search mode to show the search icon again. * If there's a query, keep search mode active so ArrowUp/Down navigation continues - * to work after focus moves into the triggers/blocks list (e.g. when initiated via Mod+F). + * to work after focus moves into the triggers/blocks list (e.g. when initiated via toolbar search shortcut). */ const handleSearchBlur = () => { if (!searchQuery.trim()) { @@ -581,8 +581,8 @@ export const Toolbar = memo( * - Within blocks: linear navigation * - ArrowUp from first trigger: moves focus back to search input * - * This is designed to work seamlessly when the toolbar is opened via the - * Mod+F shortcut, and to take precedence over other global ArrowUp/Down + * This is designed to work seamlessly when the toolbar search shortcut opens it, + * and to take precedence over other global ArrowUp/Down * handlers (e.g. terminal navigation) while the toolbar tab is active. */ useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 64acc3d2a27..016f32ca67b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,7 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' -import { History, Plus, Square } from 'lucide-react' +import { History, Plus, Search, Square } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' @@ -86,6 +86,7 @@ import { useVariablesModalStore } from '@/stores/variables/modal' import { useVariablesStore } from '@/stores/variables/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { captureBaselineSnapshot } from '@/stores/workflow-diff/utils' +import { useWorkflowSearchReplaceStore } from '@/stores/workflow-search-replace/store' import { getWorkflowWithValues } from '@/stores/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -598,12 +599,13 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const hasValidationErrors = false // TODO: Add validation logic if needed const isWorkflowBlocked = isExecuting || hasValidationErrors const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions)) + const openWorkflowSearchReplace = useWorkflowSearchReplaceStore((state) => state.open) /** * Register global keyboard shortcuts using the central commands registry. * * - Mod+Enter: Run / cancel workflow (matches the Run button behavior) - * - Mod+F: Focus Toolbar tab and search input + * - Mod+Alt+F: Focus Toolbar tab and search input */ useRegisterGlobalCommands(() => createCommands([ @@ -666,6 +668,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel Variables + + + Search and replace + {userPermissions.canAdmin && !isSnapshotView && ( { + const environmentKeys = new Set([ + ...Object.keys(personalEnvironment ?? {}), + ...Object.keys(workspaceEnvironment?.workspace ?? {}), + ]) + const environmentOptions: WorkflowSearchReplacementOption[] = [...environmentKeys] + .sort() + .map((key) => ({ + kind: 'environment', + value: `{{${key}}}`, + label: `{{${key}}}`, + })) + + return [ + ...environmentOptions, + ...flattenWorkflowSearchReplacementOptions(oauthOptions), + ...flattenWorkflowSearchReplacementOptions(knowledgeOptions), + ...flattenWorkflowSearchReplacementOptions(selectorOptions), + ] + }, [knowledgeOptions, oauthOptions, personalEnvironment, selectorOptions, workspaceEnvironment]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-search-reference-hydration.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-search-reference-hydration.ts new file mode 100644 index 00000000000..00e23ba3f38 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-search-reference-hydration.ts @@ -0,0 +1,106 @@ +import { useMemo } from 'react' +import { getWorkflowSearchMatchResourceGroupKey } from '@/lib/workflows/search-replace/resource-resolvers' +import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types' +import { usePersonalEnvironment, useWorkspaceEnvironment } from '@/hooks/queries/environment' +import { + useWorkflowSearchKnowledgeBaseDetails, + useWorkflowSearchOAuthCredentialDetails, + useWorkflowSearchSelectorDetails, +} from '@/hooks/queries/workflow-search-replace' + +export interface HydratedWorkflowSearchMatch extends WorkflowSearchMatch { + displayLabel: string + resolved: boolean + inaccessible: boolean +} + +interface UseWorkflowSearchReferenceHydrationOptions { + matches: WorkflowSearchMatch[] + workspaceId?: string + workflowId?: string +} + +export function useWorkflowSearchReferenceHydration({ + matches, + workspaceId, + workflowId, +}: UseWorkflowSearchReferenceHydrationOptions) { + const oauthDetails = useWorkflowSearchOAuthCredentialDetails(matches, workflowId) + const knowledgeDetails = useWorkflowSearchKnowledgeBaseDetails(matches) + const selectorDetails = useWorkflowSearchSelectorDetails(matches) + const { data: personalEnvironment } = usePersonalEnvironment() + const { data: workspaceEnvironment } = useWorkspaceEnvironment(workspaceId ?? '') + + return useMemo(() => { + const labelByRawValue = new Map< + string, + { label: string; resolved: boolean; inaccessible: boolean } + >() + const labelByResourceValue = new Map< + string, + { label: string; resolved: boolean; inaccessible: boolean } + >() + + const setResolvedLabel = (query: (typeof oauthDetails)[number]) => { + if (!query.data) return + const value = { + label: query.data.label, + resolved: query.data.resolved, + inaccessible: query.data.inaccessible, + } + labelByRawValue.set(query.data.matchRawValue, value) + if (query.data.resourceGroupKey) { + labelByResourceValue.set( + `${query.data.resourceGroupKey}:${query.data.matchRawValue}`, + value + ) + } + } + + oauthDetails.forEach(setResolvedLabel) + knowledgeDetails.forEach(setResolvedLabel) + selectorDetails.forEach(setResolvedLabel) + + const personalKeys = new Set(Object.keys(personalEnvironment ?? {})) + const workspaceKeys = new Set(Object.keys(workspaceEnvironment?.workspace ?? {})) + + return matches.map((match) => { + if (match.kind === 'text') { + return { + ...match, + displayLabel: match.rawValue, + resolved: true, + inaccessible: false, + } + } + + if (match.kind === 'environment') { + const key = match.resource?.key ?? match.searchText + return { + ...match, + displayLabel: `{{${key}}}`, + resolved: personalKeys.has(key) || workspaceKeys.has(key), + inaccessible: false, + } + } + + const resourceValueKey = `${getWorkflowSearchMatchResourceGroupKey(match)}:${match.rawValue}` + const resolved = + labelByResourceValue.get(resourceValueKey) ?? + (match.resource?.selectorKey ? undefined : labelByRawValue.get(match.rawValue)) + return { + ...match, + displayLabel: resolved?.label ?? match.rawValue, + resolved: resolved?.resolved ?? false, + inaccessible: resolved?.inaccessible ?? false, + } + }) + }, [ + knowledgeDetails, + matches, + oauthDetails, + personalEnvironment, + selectorDetails, + workspaceEnvironment, + ]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls.tsx new file mode 100644 index 00000000000..ffbed9d3c74 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls.tsx @@ -0,0 +1,85 @@ +'use client' + +import { Button, Combobox, Input } from '@/components/emcn' +import type { WorkflowSearchReplacementOption } from '@/lib/workflows/search-replace/types' + +interface ReplacementControlsProps { + replacement: string + compatibleResourceOptions: WorkflowSearchReplacementOption[] + usesResourceReplacement: boolean + eligibleCount: number + disabled?: boolean + isApplying?: boolean + canReplaceActive: boolean + canReplaceAll: boolean + onReplacementChange: (replacement: string) => void + onReplaceActive: () => void + onReplaceAll: () => void +} + +export function ReplacementControls({ + replacement, + compatibleResourceOptions, + usesResourceReplacement, + eligibleCount, + disabled, + isApplying, + canReplaceActive, + canReplaceAll, + onReplacementChange, + onReplaceActive, + onReplaceAll, +}: ReplacementControlsProps) { + return ( +
+
+ {usesResourceReplacement ? ( + ({ + label: option.label, + value: option.value, + }))} + value={replacement} + onChange={onReplacementChange} + placeholder='Choose replacement resource...' + searchable + searchPlaceholder='Search resources...' + emptyMessage='No valid replacements available' + disabled={disabled || compatibleResourceOptions.length === 0} + /> + ) : ( + onReplacementChange(event.target.value)} + /> + )} +
+ +
+ + {eligibleCount} replaceable match{eligibleCount === 1 ? '' : 'es'} + +
+ + +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx new file mode 100644 index 00000000000..5bab9d126f2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx @@ -0,0 +1,467 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ChevronDown, ChevronRight, ChevronUp, Search, X } from 'lucide-react' +import { useParams } from 'next/navigation' +import { Button, Input } from '@/components/emcn' +import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies' +import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer' +import { + getCompatibleResourceReplacementOptions, + getWorkflowSearchReplacementIssue, + isConstrainedResourceMatch, +} from '@/lib/workflows/search-replace/replacement-validation' +import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements' +import { + getWorkflowSearchCompatibleResourceMatches, + workflowSearchMatchMatchesQuery, +} from '@/lib/workflows/search-replace/resource-resolvers' +import { getWorkflowSearchBlocks } from '@/lib/workflows/search-replace/state' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils' +import { useWorkflowResourceReplacementOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-resource-replacement-options' +import { useWorkflowSearchReferenceHydration } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-search-reference-hydration' +import { ReplacementControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls' +import { + useFloatBoundarySync, + useFloatDrag, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float' +import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' +import { getBlock } from '@/blocks' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' +import { useNotificationStore } from '@/stores/notifications/store' +import { usePanelEditorStore } from '@/stores/panel' +import { useWorkflowSearchReplaceStore } from '@/stores/workflow-search-replace/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' + +const SEARCH_PANEL_WIDTH = 360 +const SEARCH_PANEL_COLLAPSED_HEIGHT = 104 +const SEARCH_PANEL_EXPANDED_HEIGHT = 190 + +function getDefaultSearchPanelPosition() { + if (typeof window === 'undefined') return { x: 100, y: 100 } + + const panelWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' + ) + const x = window.innerWidth - 8 - panelWidth - 32 - SEARCH_PANEL_WIDTH + const y = 40 + return { x, y } +} + +function constrainSearchPanelPosition(position: { x: number; y: number }, height: number) { + if (typeof window === 'undefined') return position + + const sidebarWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0' + ) + const panelWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' + ) + const terminalHeight = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0' + ) + + return { + x: Math.max( + sidebarWidth, + Math.min(window.innerWidth - panelWidth - SEARCH_PANEL_WIDTH - 8, position.x) + ), + y: Math.max(8, Math.min(window.innerHeight - terminalHeight - height - 8, position.y)), + } +} + +export function WorkflowSearchReplace() { + const params = useParams() + const workspaceId = params.workspaceId as string | undefined + const routeWorkflowId = params.workflowId as string | undefined + const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + const workflowId = activeWorkflowId ?? routeWorkflowId + const currentWorkflow = useCurrentWorkflow() + const workflowSubblockValues = useSubBlockStore((state) => + workflowId ? state.workflowValues[workflowId] : undefined + ) + const userPermissions = useUserPermissionsContext() + const addNotification = useNotificationStore((state) => state.addNotification) + const { collaborativeBatchSetSubblockValues } = useCollaborativeWorkflow() + const searchInputRef = useRef(null) + const [isApplying, setIsApplying] = useState(false) + const [isReplaceExpanded, setIsReplaceExpanded] = useState(false) + + const { + isOpen, + query, + replacement, + activeMatchId, + position, + close, + open, + setPosition, + setQuery, + setReplacement, + setActiveMatchId, + } = useWorkflowSearchReplaceStore() + + useRegisterGlobalCommands([ + createCommand({ + id: 'open-workflow-search-replace', + handler: () => { + open() + requestAnimationFrame(() => { + searchInputRef.current?.focus() + searchInputRef.current?.select() + }) + }, + }), + ]) + + const searchBlocks = useMemo( + () => + getWorkflowSearchBlocks({ + blocks: currentWorkflow.blocks, + workflowId, + isSnapshotView: currentWorkflow.isSnapshotView, + }), + [currentWorkflow.blocks, currentWorkflow.isSnapshotView, workflowId, workflowSubblockValues] + ) + + const matches = useMemo( + () => + indexWorkflowSearchMatches({ + workflow: { blocks: searchBlocks }, + query, + mode: 'all', + includeResourceMatchesWithoutQuery: true, + isSnapshotView: currentWorkflow.isSnapshotView, + workspaceId, + workflowId, + }), + [currentWorkflow.isSnapshotView, query, searchBlocks, workspaceId, workflowId] + ) + + const allHydratedMatches = useWorkflowSearchReferenceHydration({ + matches, + workspaceId, + workflowId, + }) + const resourceOptions = useWorkflowResourceReplacementOptions({ + matches, + workspaceId, + workflowId, + }) + + const hydratedMatches = useMemo( + () => allHydratedMatches.filter((match) => workflowSearchMatchMatchesQuery(match, query)), + [allHydratedMatches, query] + ) + + useEffect(() => { + if (!isOpen) return + searchInputRef.current?.focus() + searchInputRef.current?.select() + }, [isOpen]) + + const panelHeight = isReplaceExpanded + ? SEARCH_PANEL_EXPANDED_HEIGHT + : SEARCH_PANEL_COLLAPSED_HEIGHT + const actualPosition = useMemo( + () => constrainSearchPanelPosition(position ?? getDefaultSearchPanelPosition(), panelHeight), + [panelHeight, position] + ) + + const { handleMouseDown } = useFloatDrag({ + position: actualPosition, + width: SEARCH_PANEL_WIDTH, + height: panelHeight, + onPositionChange: setPosition, + }) + + useFloatBoundarySync({ + isOpen, + position: actualPosition, + width: SEARCH_PANEL_WIDTH, + height: panelHeight, + onPositionChange: setPosition, + }) + + const handleSelectMatch = useCallback( + (matchId: string) => { + setActiveMatchId(matchId) + const match = hydratedMatches.find((candidate) => candidate.id === matchId) + if (!match) return + usePanelEditorStore.getState().setCurrentBlockId(match.blockId) + usePanelEditorStore.getState().setActiveSearchTarget({ + matchId: match.id, + blockId: match.blockId, + subBlockId: match.subBlockId, + canonicalSubBlockId: match.canonicalSubBlockId, + valuePath: match.valuePath, + kind: match.kind, + resourceGroupKey: match.resource?.resourceGroupKey, + }) + }, + [hydratedMatches, setActiveMatchId] + ) + + const activeMatchIndex = hydratedMatches.findIndex((match) => match.id === activeMatchId) + const activeMatch = activeMatchIndex >= 0 ? hydratedMatches[activeMatchIndex] : null + const replaceAllTargetMatches = useMemo(() => { + if (!activeMatch) return [] + if (isConstrainedResourceMatch(activeMatch)) { + return getWorkflowSearchCompatibleResourceMatches(activeMatch, hydratedMatches) + } + + return hydratedMatches.filter((match) => match.kind === 'text' && match.editable) + }, [activeMatch, hydratedMatches]) + const eligibleMatchIds = useMemo( + () => replaceAllTargetMatches.map((match) => match.id), + [replaceAllTargetMatches] + ) + const controlTargetMatches = activeMatch ? [activeMatch] : [] + const usesResourceReplacement = controlTargetMatches.some(isConstrainedResourceMatch) + const compatibleResourceOptions = useMemo( + () => getCompatibleResourceReplacementOptions(controlTargetMatches, resourceOptions), + [controlTargetMatches, resourceOptions] + ) + const hasReplacement = replacement.trim().length > 0 + const activeReplacementIssue = activeMatch + ? getWorkflowSearchReplacementIssue({ + matches: [activeMatch], + replacement, + resourceOptions, + }) + : 'No current match.' + const allReplacementIssue = + replaceAllTargetMatches.length > 0 + ? getWorkflowSearchReplacementIssue({ + matches: replaceAllTargetMatches, + replacement, + resourceOptions, + }) + : 'No replaceable matches.' + useEffect(() => { + if (!isOpen) return + + if (hydratedMatches.length === 0) { + if (activeMatchId) setActiveMatchId(null) + usePanelEditorStore.getState().setActiveSearchTarget(null) + return + } + + if (!activeMatchId || !hydratedMatches.some((match) => match.id === activeMatchId)) { + handleSelectMatch(hydratedMatches[0].id) + } + }, [activeMatchId, handleSelectMatch, hydratedMatches, isOpen, setActiveMatchId]) + + if (!isOpen) return null + + const handleMoveActiveMatch = (delta: number) => { + if (hydratedMatches.length === 0) return + const currentIndex = activeMatchIndex >= 0 ? activeMatchIndex : 0 + const nextIndex = (currentIndex + delta + hydratedMatches.length) % hydratedMatches.length + handleSelectMatch(hydratedMatches[nextIndex].id) + } + + const handleApply = (matchIds: string[]) => { + if (!workflowId || isApplying) return + setIsApplying(true) + + try { + const selectedIds = new Set(matchIds) + const plan = buildWorkflowSearchReplacePlan({ + blocks: searchBlocks, + matches: hydratedMatches, + selectedMatchIds: selectedIds, + defaultReplacement: replacement, + resourceReplacementOptions: resourceOptions, + }) + + if (plan.conflicts.length > 0) { + const [firstConflict] = plan.conflicts + addNotification({ + level: 'error', + message: firstConflict?.reason + ? `Replacement stopped: ${firstConflict.reason}` + : `Replacement stopped: ${plan.conflicts.length} match changed. Re-run search and try again.`, + workflowId, + }) + return + } + + const batchUpdates = plan.updates.map((update) => ({ + blockId: update.blockId, + subblockId: update.subBlockId, + value: update.nextValue, + expectedValue: update.previousValue, + })) + + for (const update of plan.updates) { + const block = searchBlocks[update.blockId] + const blockConfig = block ? getBlock(block.type) : null + if (!blockConfig?.subBlocks) continue + + const dependentClears = getWorkflowSearchDependentClears( + blockConfig.subBlocks, + update.subBlockId + ) + for (const clear of dependentClears) { + const alreadyUpdated = batchUpdates.some( + (candidate) => + candidate.blockId === update.blockId && candidate.subblockId === clear.subBlockId + ) + if (alreadyUpdated) continue + + const currentValue = useSubBlockStore + .getState() + .getValue(update.blockId, clear.subBlockId) + if (currentValue === '' || currentValue === null || currentValue === undefined) continue + batchUpdates.push({ + blockId: update.blockId, + subblockId: clear.subBlockId, + value: '', + expectedValue: currentValue, + }) + } + } + + if (batchUpdates.length === 0) { + addNotification({ + level: 'info', + message: 'No eligible matches to replace.', + workflowId, + }) + return + } + + const applied = collaborativeBatchSetSubblockValues(batchUpdates) + if (!applied) { + addNotification({ + level: 'error', + message: 'Replacement could not be applied in the current workflow state.', + workflowId, + }) + return + } + + addNotification({ + level: 'info', + message: `Replaced ${plan.updates.length} field${plan.updates.length === 1 ? '' : 's'}.`, + workflowId, + }) + } finally { + setIsApplying(false) + } + } + + const handleReplaceActive = () => { + if (!activeMatch) return + handleApply([activeMatch.id]) + } + + const handleReplaceAll = () => { + handleApply(eligibleMatchIds) + } + + const matchCountLabel = + hydratedMatches.length === 0 + ? 'No results' + : `${activeMatchIndex >= 0 ? activeMatchIndex + 1 : 1} of ${hydratedMatches.length}` + return ( +
+
+
+ + + Search and replace + +
+
event.stopPropagation()} + > + {matchCountLabel} + +
+
+ +
+
+ + { + if (event.key !== 'Enter') return + event.preventDefault() + handleMoveActiveMatch(event.shiftKey ? -1 : 1) + }} + onChange={(event) => setQuery(event.target.value)} + /> + + +
+ + {isReplaceExpanded && ( +
+ 0 && hasReplacement && !allReplacementIssue + )} + onReplacementChange={setReplacement} + onReplaceActive={handleReplaceActive} + onReplaceAll={handleReplaceAll} + /> +
+ )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index a2019e537eb..a3b63f2fa76 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -35,6 +35,7 @@ import { BlockMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/componen import { CanvasMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu' import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors' import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' +import { WorkflowSearchReplace } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace' import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node' import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls' import { @@ -4233,6 +4234,7 @@ const WorkflowContent = React.memo( )} + {!embedded && } {!embedded && isWorkflowReady && isWorkflowEmpty && effectivePermissions.canEdit && ( diff --git a/apps/sim/hooks/queries/workflow-search-replace.test.ts b/apps/sim/hooks/queries/workflow-search-replace.test.ts new file mode 100644 index 00000000000..2ab1993ad61 --- /dev/null +++ b/apps/sim/hooks/queries/workflow-search-replace.test.ts @@ -0,0 +1,53 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + flattenWorkflowSearchReplacementOptions, + workflowSearchReplaceKeys, +} from '@/hooks/queries/workflow-search-replace' + +describe('workflowSearchReplaceKeys', () => { + it('builds stable hierarchical keys for credential candidates', () => { + expect( + workflowSearchReplaceKeys.oauthReplacementOptions('gmail', 'workspace-1', 'workflow-1') + ).toEqual([ + 'workflow-search-replace', + 'replacement-options', + 'oauth', + 'gmail', + 'workspace-1', + 'workflow-1', + ]) + }) + + it('builds scoped selector replacement option keys', () => { + expect( + workflowSearchReplaceKeys.selectorReplacementOptions( + 'gmail.labels', + '{"oauthCredential":"credential-1","workspaceId":"workspace-1"}' + ) + ).toEqual([ + 'workflow-search-replace', + 'replacement-options', + 'selector', + 'gmail.labels', + '{"oauthCredential":"credential-1","workspaceId":"workspace-1"}', + ]) + }) +}) + +describe('flattenWorkflowSearchReplacementOptions', () => { + it('flattens loaded option groups while ignoring pending groups', () => { + expect( + flattenWorkflowSearchReplacementOptions([ + { data: [{ kind: 'environment', value: '{{A}}', label: '{{A}}' }] }, + {}, + { data: [{ kind: 'knowledge-base', value: 'kb-1', label: 'KB 1' }] }, + ]) + ).toEqual([ + { kind: 'environment', value: '{{A}}', label: '{{A}}' }, + { kind: 'knowledge-base', value: 'kb-1', label: 'KB 1' }, + ]) + }) +}) diff --git a/apps/sim/hooks/queries/workflow-search-replace.ts b/apps/sim/hooks/queries/workflow-search-replace.ts new file mode 100644 index 00000000000..99404644cd0 --- /dev/null +++ b/apps/sim/hooks/queries/workflow-search-replace.ts @@ -0,0 +1,290 @@ +import { useMemo } from 'react' +import { useQueries } from '@tanstack/react-query' +import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' +import type { Credential } from '@/lib/oauth' +import { stableStringifyWorkflowSearchValue } from '@/lib/workflows/search-replace/resource-resolvers' +import type { + WorkflowSearchMatch, + WorkflowSearchReplacementOption, +} from '@/lib/workflows/search-replace/types' +import { + fetchKnowledgeBase, + fetchKnowledgeBases, + knowledgeKeys, +} from '@/hooks/queries/kb/knowledge' +import { + fetchOAuthCredentialDetail, + fetchOAuthCredentials, +} from '@/hooks/queries/oauth/oauth-credentials' +import { getSelectorDefinition } from '@/hooks/selectors/registry' +import type { SelectorKey, SelectorOption } from '@/hooks/selectors/types' + +export interface WorkflowSearchResolvedResource { + matchRawValue: string + resourceGroupKey?: string + label: string + resolved: boolean + inaccessible: boolean +} + +export const workflowSearchReplaceKeys = { + all: ['workflow-search-replace'] as const, + resourceDetails: () => [...workflowSearchReplaceKeys.all, 'resource-detail'] as const, + oauthDetails: (workflowId?: string) => + [...workflowSearchReplaceKeys.resourceDetails(), 'oauth', workflowId ?? ''] as const, + oauthDetail: (credentialId?: string, workflowId?: string) => + [...workflowSearchReplaceKeys.oauthDetails(workflowId), credentialId ?? ''] as const, + replacementOptions: () => [...workflowSearchReplaceKeys.all, 'replacement-options'] as const, + oauthReplacementOptions: (providerId?: string, workspaceId?: string, workflowId?: string) => + [ + ...workflowSearchReplaceKeys.replacementOptions(), + 'oauth', + providerId ?? '', + workspaceId ?? '', + workflowId ?? '', + ] as const, + knowledgeDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'knowledge'] as const, + knowledgeDetail: (knowledgeBaseId?: string) => + [...workflowSearchReplaceKeys.knowledgeDetails(), knowledgeBaseId ?? ''] as const, + knowledgeReplacementOptions: (workspaceId?: string) => + [...workflowSearchReplaceKeys.replacementOptions(), 'knowledge', workspaceId ?? ''] as const, + selectorDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'selector'] as const, + selectorDetail: (selectorKey?: string, contextKey?: string, value?: string) => + [ + ...workflowSearchReplaceKeys.selectorDetails(), + selectorKey ?? '', + contextKey ?? '', + value ?? '', + ] as const, + selectorReplacementOptions: (selectorKey?: string, contextKey?: string) => + [ + ...workflowSearchReplaceKeys.replacementOptions(), + 'selector', + selectorKey ?? '', + contextKey ?? '', + ] as const, +} + +function uniqueMatches( + matches: WorkflowSearchMatch[], + kind: WorkflowSearchMatch['kind'] +): WorkflowSearchMatch[] { + const seen = new Set() + return matches.filter((match) => { + if (match.kind !== kind || !match.rawValue || seen.has(match.rawValue)) return false + seen.add(match.rawValue) + return true + }) +} + +function selectorContextKey(match: WorkflowSearchMatch): string { + return stableStringifyWorkflowSearchValue(match.resource?.selectorContext ?? {}) +} + +function uniqueSelectorDetailMatches(matches: WorkflowSearchMatch[]): WorkflowSearchMatch[] { + const seen = new Set() + return matches.filter((match) => { + const selectorKey = match.resource?.selectorKey + if (!selectorKey || !match.rawValue) return false + + const key = `${selectorKey}:${selectorContextKey(match)}:${match.rawValue}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function uniqueSelectorOptionGroups(matches: WorkflowSearchMatch[]): WorkflowSearchMatch[] { + const seen = new Set() + return matches.filter((match) => { + const selectorKey = match.resource?.selectorKey + if (!selectorKey) return false + + const key = `${match.kind}:${selectorKey}:${selectorContextKey(match)}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +export function useWorkflowSearchOAuthCredentialDetails( + matches: WorkflowSearchMatch[], + workflowId?: string +) { + const oauthMatches = useMemo(() => uniqueMatches(matches, 'oauth-credential'), [matches]) + + return useQueries({ + queries: oauthMatches.map((match) => ({ + queryKey: workflowSearchReplaceKeys.oauthDetail(match.rawValue, workflowId), + queryFn: ({ signal }: { signal: AbortSignal }) => + fetchOAuthCredentialDetail(match.rawValue, workflowId, signal), + enabled: Boolean(match.rawValue), + staleTime: 60 * 1000, + select: (credentials: Credential[]): WorkflowSearchResolvedResource => { + const credential = credentials[0] + return { + matchRawValue: match.rawValue, + resourceGroupKey: match.resource?.resourceGroupKey, + label: credential?.name ?? `OAuth credential ${match.rawValue.slice(0, 8)}`, + resolved: Boolean(credential?.name), + inaccessible: credentials.length === 0, + } + }, + })), + }) +} + +export function useWorkflowSearchKnowledgeBaseDetails(matches: WorkflowSearchMatch[]) { + const knowledgeMatches = useMemo(() => uniqueMatches(matches, 'knowledge-base'), [matches]) + + return useQueries({ + queries: knowledgeMatches.map((match) => ({ + queryKey: workflowSearchReplaceKeys.knowledgeDetail(match.rawValue), + queryFn: ({ signal }: { signal: AbortSignal }) => fetchKnowledgeBase(match.rawValue, signal), + enabled: Boolean(match.rawValue), + staleTime: 60 * 1000, + select: (knowledgeBase: KnowledgeBaseData): WorkflowSearchResolvedResource => ({ + matchRawValue: match.rawValue, + resourceGroupKey: match.resource?.resourceGroupKey, + label: knowledgeBase.name, + resolved: true, + inaccessible: false, + }), + })), + }) +} + +export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[]) { + const selectorMatches = useMemo(() => uniqueSelectorDetailMatches(matches), [matches]) + + return useQueries({ + queries: selectorMatches.map((match) => { + const selectorKey = match.resource?.selectorKey as SelectorKey + const context = match.resource?.selectorContext ?? {} + const contextKey = selectorContextKey(match) + const definition = getSelectorDefinition(selectorKey) + const queryArgs = { key: selectorKey, context, detailId: match.rawValue } + const baseEnabled = definition.enabled ? definition.enabled(queryArgs) : true + + return { + queryKey: workflowSearchReplaceKeys.selectorDetail(selectorKey, contextKey, match.rawValue), + queryFn: async ({ signal }: { signal: AbortSignal }): Promise => { + if (definition.fetchById) { + return definition.fetchById({ ...queryArgs, signal }) + } + + const options = await definition.fetchList({ key: selectorKey, context, signal }) + return options.find((option) => option.id === match.rawValue) ?? null + }, + enabled: Boolean(selectorKey && match.rawValue && baseEnabled), + staleTime: definition.staleTime ?? 60 * 1000, + select: (option: SelectorOption | null): WorkflowSearchResolvedResource => ({ + matchRawValue: match.rawValue, + resourceGroupKey: match.resource?.resourceGroupKey, + label: option?.label ?? match.rawValue, + resolved: Boolean(option), + inaccessible: false, + }), + } + }), + }) +} + +export function useWorkflowSearchOAuthReplacementOptions( + matches: WorkflowSearchMatch[], + workspaceId?: string, + workflowId?: string +) { + const providerIds = useMemo(() => { + const ids = new Set() + matches.forEach((match) => { + if (match.kind === 'oauth-credential' && match.resource?.providerId) { + ids.add(match.resource.providerId) + } + }) + return [...ids].sort() + }, [matches]) + + return useQueries({ + queries: providerIds.map((providerId) => ({ + queryKey: workflowSearchReplaceKeys.oauthReplacementOptions( + providerId, + workspaceId, + workflowId + ), + queryFn: ({ signal }: { signal: AbortSignal }) => + fetchOAuthCredentials({ providerId, workspaceId, workflowId }, signal), + enabled: Boolean(providerId && workspaceId), + staleTime: 60 * 1000, + select: (credentials: Credential[]): WorkflowSearchReplacementOption[] => + credentials.map((credential) => ({ + kind: 'oauth-credential', + value: credential.id, + label: credential.name, + providerId, + serviceId: providerId, + })), + })), + }) +} + +export function useWorkflowSearchKnowledgeReplacementOptions(workspaceId?: string) { + return useQueries({ + queries: [ + { + queryKey: workflowSearchReplaceKeys.knowledgeReplacementOptions(workspaceId), + queryFn: ({ signal }: { signal: AbortSignal }) => + fetchKnowledgeBases(workspaceId, 'active', signal), + enabled: Boolean(workspaceId), + staleTime: 60 * 1000, + placeholderData: (previous: KnowledgeBaseData[] | undefined) => previous, + select: (knowledgeBases: KnowledgeBaseData[]): WorkflowSearchReplacementOption[] => + knowledgeBases.map((knowledgeBase) => ({ + kind: 'knowledge-base', + value: knowledgeBase.id, + label: knowledgeBase.name, + })), + }, + ], + }) +} + +export function useWorkflowSearchSelectorReplacementOptions(matches: WorkflowSearchMatch[]) { + const selectorGroups = useMemo(() => uniqueSelectorOptionGroups(matches), [matches]) + + return useQueries({ + queries: selectorGroups.map((match) => { + const selectorKey = match.resource?.selectorKey as SelectorKey + const context = match.resource?.selectorContext ?? {} + const contextKey = selectorContextKey(match) + const definition = getSelectorDefinition(selectorKey) + const queryArgs = { key: selectorKey, context } + const baseEnabled = definition.enabled ? definition.enabled(queryArgs) : true + + return { + queryKey: workflowSearchReplaceKeys.selectorReplacementOptions(selectorKey, contextKey), + queryFn: ({ signal }: { signal: AbortSignal }) => + definition.fetchList({ ...queryArgs, signal }), + enabled: Boolean(selectorKey && baseEnabled), + staleTime: definition.staleTime ?? 60 * 1000, + select: (options: SelectorOption[]): WorkflowSearchReplacementOption[] => + options.map((option) => ({ + kind: match.kind, + value: option.id, + label: option.label, + selectorKey, + selectorContext: context, + resourceGroupKey: match.resource?.resourceGroupKey, + })), + } + }), + }) +} + +export function flattenWorkflowSearchReplacementOptions( + optionGroups: Array<{ data?: WorkflowSearchReplacementOption[] }> +): WorkflowSearchReplacementOption[] { + return optionGroups.flatMap((group) => group.data ?? []) +} + +export { knowledgeKeys } diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 5f3b16e56ee..97a386aecac 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -228,6 +228,29 @@ export function useCollaborativeWorkflow() { break } } + } else if (target === OPERATION_TARGETS.SUBBLOCK) { + switch (operation) { + case SUBBLOCK_OPERATIONS.BATCH_UPDATE: { + const { updates } = payload + if (Array.isArray(updates)) { + updates.forEach( + (update: { blockId: string; subblockId: string; value: unknown }) => { + useSubBlockStore + .getState() + .setValue(update.blockId, update.subblockId, update.value) + useWorkflowStore + .getState() + .syncDynamicHandleSubblockValue( + update.blockId, + update.subblockId, + update.value + ) + } + ) + } + break + } + } } else if (target === OPERATION_TARGETS.EDGES) { switch (operation) { case EDGES_OPERATIONS.BATCH_REMOVE_EDGES: { @@ -1344,6 +1367,60 @@ export function useCollaborativeWorkflow() { [activeWorkflowId, addToQueue, session?.user?.id, isBaselineDiffView] ) + const collaborativeBatchSetSubblockValues = useCallback( + ( + updates: Array<{ + blockId: string + subblockId: string + value: unknown + expectedValue?: unknown + }> + ) => { + if (isApplyingRemoteChange.current || updates.length === 0) return false + + if (isBaselineDiffView) { + logger.debug('Skipping collaborative batch subblock update while viewing baseline diff') + return false + } + + if (!activeWorkflowId) { + logger.debug('Skipping batch subblock update - no active workflow') + return false + } + + updates.forEach((update) => { + useSubBlockStore.getState().setValue(update.blockId, update.subblockId, update.value) + useWorkflowStore + .getState() + .syncDynamicHandleSubblockValue(update.blockId, update.subblockId, update.value) + }) + + const operationId = generateId() + addToQueue({ + id: operationId, + operation: { + operation: SUBBLOCK_OPERATIONS.BATCH_UPDATE, + target: OPERATION_TARGETS.SUBBLOCK, + payload: { updates }, + }, + workflowId: activeWorkflowId, + userId: session?.user?.id || 'unknown', + }) + + undoRedo.recordBatchUpdateSubblocks( + updates.map((update) => ({ + blockId: update.blockId, + subBlockId: update.subblockId, + before: update.expectedValue, + after: update.value, + })) + ) + + return true + }, + [activeWorkflowId, addToQueue, isBaselineDiffView, session?.user?.id, undoRedo] + ) + // Immediate tag selection (uses queue but processes immediately, no debouncing) const collaborativeSetTagSelection = useCallback( (blockId: string, subblockId: string, value: string) => { @@ -1833,6 +1910,7 @@ export function useCollaborativeWorkflow() { collaborativeBatchAddEdges, collaborativeBatchRemoveEdges, collaborativeSetSubblockValue, + collaborativeBatchSetSubblockValues, collaborativeSetTagSelection, // Collaborative variable operations diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 2140de378a9..dca49eaacf6 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -14,6 +14,7 @@ import { EDGE_OPERATIONS, EDGES_OPERATIONS, OPERATION_TARGETS, + SUBBLOCK_OPERATIONS, UNDO_REDO_OPERATIONS, } from '@sim/realtime-protocol/constants' import type { Edge } from 'reactflow' @@ -30,6 +31,7 @@ import { type BatchToggleHandlesOperation, type BatchToggleLockedOperation, type BatchUpdateParentOperation, + type BatchUpdateSubblocksOperation, captureLatestEdges, captureLatestSubBlockValues, createOperationEntry, @@ -38,6 +40,7 @@ import { useUndoRedoStore, } from '@/stores/undo-redo' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -454,6 +457,74 @@ export function useUndoRedo() { [activeWorkflowId, userId] ) + const recordBatchUpdateSubblocks = useCallback( + (updates: BatchUpdateSubblocksOperation['data']['updates']) => { + if (!activeWorkflowId || updates.length === 0) return + + const operation: BatchUpdateSubblocksOperation = { + id: generateId(), + type: UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS, + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { updates }, + } + + const inverse: BatchUpdateSubblocksOperation = { + id: generateId(), + type: UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS, + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { + updates: updates.map((update) => ({ + blockId: update.blockId, + subBlockId: update.subBlockId, + before: update.after, + after: update.before, + })), + }, + } + + const entry = createOperationEntry(operation, inverse) + useUndoRedoStore.getState().push(activeWorkflowId, userId, entry) + logger.debug('Recorded batch subblock update', { count: updates.length }) + }, + [activeWorkflowId, userId] + ) + + const applyBatchSubblockUndoRedo = useCallback( + (updates: BatchUpdateSubblocksOperation['data']['updates']) => { + if (!activeWorkflowId || updates.length === 0) return + + const socketUpdates = updates.map((update) => ({ + blockId: update.blockId, + subblockId: update.subBlockId, + value: update.after, + expectedValue: update.before, + })) + + addToQueue({ + id: generateId(), + operation: { + operation: SUBBLOCK_OPERATIONS.BATCH_UPDATE, + target: OPERATION_TARGETS.SUBBLOCK, + payload: { updates: socketUpdates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + socketUpdates.forEach((update) => { + useSubBlockStore.getState().setValue(update.blockId, update.subblockId, update.value) + useWorkflowStore + .getState() + .syncDynamicHandleSubblockValue(update.blockId, update.subblockId, update.value) + }) + }, + [activeWorkflowId, addToQueue, userId] + ) + const undo = useCallback(async () => { if (!activeWorkflowId) return @@ -903,6 +974,11 @@ export function useUndoRedo() { }) break } + case UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS: { + const subblockOp = entry.inverse as BatchUpdateSubblocksOperation + applyBatchSubblockUndoRedo(subblockOp.data.updates) + break + } case UNDO_REDO_OPERATIONS.APPLY_DIFF: { const applyDiffInverse = entry.inverse as any const { baselineSnapshot } = applyDiffInverse.data @@ -1076,7 +1152,7 @@ export function useUndoRedo() { logger.info('Undo operation', { type: entry.operation.type, workflowId: activeWorkflowId }) }) - }, [activeWorkflowId, userId, addToQueue]) + }, [activeWorkflowId, userId, addToQueue, applyBatchSubblockUndoRedo]) const redo = useCallback(async () => { if (!activeWorkflowId || !userId) return @@ -1530,6 +1606,11 @@ export function useUndoRedo() { }) break } + case UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS: { + const subblockOp = entry.operation as BatchUpdateSubblocksOperation + applyBatchSubblockUndoRedo(subblockOp.data.updates) + break + } case UNDO_REDO_OPERATIONS.APPLY_DIFF: { // Redo apply-diff means re-applying the proposed state with diff markers const applyDiffOp = entry.operation as any @@ -1701,7 +1782,7 @@ export function useUndoRedo() { userId, }) }) - }, [activeWorkflowId, userId, addToQueue]) + }, [activeWorkflowId, userId, addToQueue, applyBatchSubblockUndoRedo]) const getStackSizes = useCallback(() => { if (!activeWorkflowId) return { undoSize: 0, redoSize: 0 } @@ -1852,6 +1933,7 @@ export function useUndoRedo() { recordBatchToggleEnabled, recordBatchToggleHandles, recordBatchToggleLocked, + recordBatchUpdateSubblocks, recordApplyDiff, recordAcceptDiff, recordRejectDiff, diff --git a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts index 6465e74a26b..c21865246d7 100644 --- a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts +++ b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts @@ -26,7 +26,8 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa | C | Focus Copilot tab | | T | Focus Toolbar tab | | E | Focus Editor tab | -| Mod+F | Focus Toolbar search | +| Mod+F | Open workflow search and replace | +| Mod+Alt+F | Focus Toolbar search | ### Global Navigation | Shortcut | Action | diff --git a/apps/sim/lib/workflows/search-replace/dependencies.ts b/apps/sim/lib/workflows/search-replace/dependencies.ts new file mode 100644 index 00000000000..db28ba47717 --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/dependencies.ts @@ -0,0 +1,17 @@ +import type { SubBlockConfig } from '@/blocks/types' +import { getSubBlocksDependingOnChange } from '@/blocks/utils' + +export interface DependentClear { + subBlockId: string + reason: string +} + +export function getWorkflowSearchDependentClears( + allSubBlocks: SubBlockConfig[], + changedSubBlockId: string +): DependentClear[] { + return getSubBlocksDependingOnChange(allSubBlocks, changedSubBlockId).map((subBlock) => ({ + subBlockId: subBlock.id, + reason: `${subBlock.id} depends on ${changedSubBlockId}`, + })) +} diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts new file mode 100644 index 00000000000..9b6f7d3323c --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer' +import { workflowSearchMatchMatchesQuery } from '@/lib/workflows/search-replace/resource-resolvers' +import { + createSearchReplaceWorkflowFixture, + SEARCH_REPLACE_BLOCK_CONFIGS, +} from '@/lib/workflows/search-replace/search-replace.fixtures' + +describe('indexWorkflowSearchMatches', () => { + it('finds plain text matches across nested subblock values', () => { + const workflow = createSearchReplaceWorkflowFixture() + + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'email', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect(matches.map((match) => [match.blockId, match.subBlockId, match.valuePath])).toEqual([ + ['agent-1', 'systemPrompt', []], + ['agent-1', 'systemPrompt', []], + ['api-1', 'body', ['content']], + ['locked-1', 'systemPrompt', []], + ]) + expect(matches.at(-1)?.editable).toBe(false) + expect(matches.at(-1)?.reason).toBe('Block is locked') + }) + + it('indexes environment tokens and workflow references embedded in strings', () => { + const workflow = createSearchReplaceWorkflowFixture() + + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'OLD_SECRET', + mode: 'resource', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect(matches.filter((match) => match.kind === 'environment')).toHaveLength(2) + expect(matches.every((match) => match.rawValue === '{{OLD_SECRET}}')).toBe(true) + + const references = indexWorkflowSearchMatches({ + workflow, + query: 'start.output', + mode: 'resource', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + expect(references.map((match) => match.kind)).toEqual(['workflow-reference']) + }) + + it('classifies structured resources by subblock type instead of UUID shape', () => { + const workflow = createSearchReplaceWorkflowFixture() + + const matches = indexWorkflowSearchMatches({ + workflow, + mode: 'resource', + includeResourceMatchesWithoutQuery: true, + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect(matches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'oauth-credential', + rawValue: 'gmail-credential-old', + resource: expect.objectContaining({ providerId: 'gmail' }), + }), + expect.objectContaining({ kind: 'knowledge-base', rawValue: 'kb-old' }), + expect.objectContaining({ kind: 'knowledge-base', rawValue: 'kb-second' }), + expect.objectContaining({ kind: 'knowledge-document', rawValue: 'doc-old' }), + ]) + ) + }) + + it('can enumerate resource candidates before display-label filtering', () => { + const workflow = createSearchReplaceWorkflowFixture() + + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'Test LMFAO', + mode: 'all', + includeResourceMatchesWithoutQuery: true, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + const knowledgeMatch = matches.find( + (match) => match.kind === 'knowledge-base' && match.rawValue === 'kb-old' + ) + expect(knowledgeMatch).toBeDefined() + expect( + workflowSearchMatchMatchesQuery( + { ...knowledgeMatch!, displayLabel: 'Test LMFAO' }, + 'Test LMFAO' + ) + ).toBe(true) + }) + + it('captures selector context for selector-backed resources', () => { + const workflow = createSearchReplaceWorkflowFixture() + + const matches = indexWorkflowSearchMatches({ + workflow, + mode: 'resource', + includeResourceMatchesWithoutQuery: true, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect(matches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'selector-resource', + rawValue: 'INBOX', + resource: expect.objectContaining({ + selectorKey: 'gmail.labels', + selectorContext: expect.objectContaining({ + oauthCredential: 'gmail-credential-old', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }), + }), + }), + expect.objectContaining({ + kind: 'knowledge-document', + rawValue: 'doc-old', + resource: expect.objectContaining({ + selectorKey: 'knowledge.documents', + selectorContext: expect.objectContaining({ + knowledgeBaseId: 'kb-old,kb-second', + }), + }), + }), + ]) + ) + }) + + it('marks snapshot view matches as searchable but not editable', () => { + const workflow = createSearchReplaceWorkflowFixture() + + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'email', + mode: 'text', + isSnapshotView: true, + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect(matches.every((match) => !match.editable)).toBe(true) + expect(matches.every((match) => match.reason === 'Snapshot view is readonly')).toBe(true) + }) +}) diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts new file mode 100644 index 00000000000..6b7fd2c1391 --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -0,0 +1,264 @@ +import { + matchesSearchText, + parseInlineReferences, + parseStructuredResourceReferences, +} from '@/lib/workflows/search-replace/reference-registry' +import type { + WorkflowSearchIndexerOptions, + WorkflowSearchMatch, +} from '@/lib/workflows/search-replace/types' +import { pathToKey, walkStringValues } from '@/lib/workflows/search-replace/value-walker' +import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' +import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks/registry' +import type { SubBlockConfig } from '@/blocks/types' +import type { SelectorContext } from '@/hooks/selectors/types' +import type { BlockState } from '@/stores/workflows/workflow/types' + +function hasLockedAncestor(block: BlockState, blocks: Record): boolean { + let parentId = block.data?.parentId + const visited = new Set() + + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = blocks[parentId] + if (!parent) return false + if (parent.locked) return true + parentId = parent.data?.parentId + } + + return false +} + +function normalizeForSearch(value: string, caseSensitive: boolean): string { + return caseSensitive ? value : value.toLowerCase() +} + +function findTextRanges(value: string, query: string, caseSensitive: boolean) { + if (!query) return [] + const source = normalizeForSearch(value, caseSensitive) + const target = normalizeForSearch(query, caseSensitive) + const ranges: Array<{ start: number; end: number }> = [] + + let index = source.indexOf(target) + while (index !== -1) { + ranges.push({ start: index, end: index + target.length }) + index = source.indexOf(target, index + Math.max(target.length, 1)) + } + + return ranges +} + +function createMatchId(parts: Array): string { + return parts + .filter((part) => part !== undefined && part !== '') + .map((part) => String(part).replaceAll(':', '_')) + .join(':') +} + +function buildSearchSelectorContext({ + block, + subBlockConfigs, + workspaceId, + workflowId, +}: { + block: BlockState + subBlockConfigs: SubBlockConfig[] + workspaceId?: string + workflowId?: string +}): SelectorContext { + const context: SelectorContext = {} + if (workspaceId) context.workspaceId = workspaceId + if (workflowId) context.workflowId = workflowId + + const canonicalIndex = buildCanonicalIndex(subBlockConfigs) + for (const [subBlockId, subBlock] of Object.entries(block.subBlocks ?? {})) { + const value = subBlock?.value + if (value === null || value === undefined) continue + const stringValue = typeof value === 'string' ? value : String(value) + if (!stringValue) continue + + const canonicalKey = canonicalIndex.canonicalIdBySubBlockId[subBlockId] ?? subBlockId + if (SELECTOR_CONTEXT_FIELDS.has(canonicalKey as keyof SelectorContext)) { + context[canonicalKey as keyof SelectorContext] = stringValue + } + } + + return context +} + +export function indexWorkflowSearchMatches( + options: WorkflowSearchIndexerOptions +): WorkflowSearchMatch[] { + const { + workflow, + query, + mode = 'all', + caseSensitive = false, + includeResourceMatchesWithoutQuery = false, + isSnapshotView = false, + workspaceId, + workflowId, + blockConfigs = {}, + } = options + + const matches: WorkflowSearchMatch[] = [] + const resourceQueryEnabled = includeResourceMatchesWithoutQuery || Boolean(query) + + for (const block of Object.values(workflow.blocks)) { + const blockConfig = blockConfigs[block.type] ?? getBlock(block.type) + const subBlockConfigs = blockConfig?.subBlocks ?? [] + const configsById = new Map(subBlockConfigs.map((subBlock) => [subBlock.id, subBlock])) + const canonicalIndex = buildCanonicalIndex(subBlockConfigs) + const selectorContext = buildSearchSelectorContext({ + block, + subBlockConfigs, + workspaceId, + workflowId, + }) + const protectedByLock = Boolean(block.locked || hasLockedAncestor(block, workflow.blocks)) + const editable = !protectedByLock && !isSnapshotView + + for (const [subBlockId, subBlockState] of Object.entries(block.subBlocks ?? {})) { + const subBlockConfig = configsById.get(subBlockId) + const canonicalSubBlockId = + canonicalIndex.canonicalIdBySubBlockId[subBlockId] ?? + subBlockConfig?.canonicalParamId ?? + subBlockId + const value = subBlockState?.value + const stringLeaves = walkStringValues(value) + + if (mode !== 'resource') { + for (const leaf of stringLeaves) { + const ranges = query ? findTextRanges(leaf.value, query, caseSensitive) : [] + ranges.forEach((range, occurrenceIndex) => { + matches.push({ + id: createMatchId([ + 'text', + block.id, + subBlockId, + pathToKey(leaf.path), + range.start, + occurrenceIndex, + ]), + blockId: block.id, + blockName: block.name, + blockType: block.type, + subBlockId, + canonicalSubBlockId, + subBlockType: subBlockConfig?.type ?? subBlockState.type, + fieldTitle: subBlockConfig?.title, + valuePath: leaf.path, + kind: 'text', + rawValue: leaf.value.slice(range.start, range.end), + searchText: leaf.value, + range, + editable, + navigable: true, + protected: protectedByLock, + reason: editable + ? undefined + : isSnapshotView + ? 'Snapshot view is readonly' + : 'Block is locked', + }) + }) + } + } + + if (mode === 'text' || !resourceQueryEnabled) continue + + for (const leaf of stringLeaves) { + const inlineReferences = parseInlineReferences(leaf.value) + inlineReferences.forEach((reference, referenceIndex) => { + const searchable = `${reference.rawValue} ${reference.searchText}` + if ( + !includeResourceMatchesWithoutQuery && + !matchesSearchText(searchable, query, caseSensitive) + ) { + return + } + + matches.push({ + id: createMatchId([ + reference.kind, + block.id, + subBlockId, + pathToKey(leaf.path), + reference.range.start, + referenceIndex, + ]), + blockId: block.id, + blockName: block.name, + blockType: block.type, + subBlockId, + canonicalSubBlockId, + subBlockType: subBlockConfig?.type ?? subBlockState.type, + fieldTitle: subBlockConfig?.title, + valuePath: leaf.path, + kind: reference.kind, + rawValue: reference.rawValue, + searchText: reference.searchText, + range: reference.range, + resource: reference.resource, + editable, + navigable: true, + protected: protectedByLock, + reason: editable + ? undefined + : isSnapshotView + ? 'Snapshot view is readonly' + : 'Block is locked', + }) + }) + } + + const structuredReferences = parseStructuredResourceReferences( + value, + subBlockConfig, + selectorContext + ) + structuredReferences.forEach((reference, referenceIndex) => { + const searchable = `${reference.rawValue} ${reference.searchText} ${reference.kind}` + if ( + !includeResourceMatchesWithoutQuery && + !matchesSearchText(searchable, query, caseSensitive) + ) { + return + } + + matches.push({ + id: createMatchId([ + reference.kind, + block.id, + subBlockId, + reference.rawValue, + referenceIndex, + ]), + blockId: block.id, + blockName: block.name, + blockType: block.type, + subBlockId, + canonicalSubBlockId, + subBlockType: subBlockConfig?.type ?? subBlockState.type, + fieldTitle: subBlockConfig?.title, + valuePath: [], + kind: reference.kind, + rawValue: reference.rawValue, + searchText: reference.searchText, + resource: reference.resource, + editable, + navigable: true, + protected: protectedByLock, + reason: editable + ? undefined + : isSnapshotView + ? 'Snapshot view is readonly' + : 'Block is locked', + }) + }) + } + } + + return matches +} diff --git a/apps/sim/lib/workflows/search-replace/reference-registry.ts b/apps/sim/lib/workflows/search-replace/reference-registry.ts new file mode 100644 index 00000000000..a484107370b --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/reference-registry.ts @@ -0,0 +1,152 @@ +import type { SubBlockType } from '@sim/workflow-types/blocks' +import { buildWorkflowSearchResourceGroupKey } from '@/lib/workflows/search-replace/resource-resolvers' +import type { + WorkflowSearchMatchKind, + WorkflowSearchRange, + WorkflowSearchResourceMeta, +} from '@/lib/workflows/search-replace/types' +import type { SubBlockConfig } from '@/blocks/types' +import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' +import type { SelectorContext } from '@/hooks/selectors/types' + +export interface ParsedInlineReference { + kind: 'environment' | 'workflow-reference' + rawValue: string + searchText: string + range: WorkflowSearchRange + resource: WorkflowSearchResourceMeta +} + +export interface StructuredResourceReference { + kind: Exclude + rawValue: string + searchText: string + resource: WorkflowSearchResourceMeta +} + +const RESOURCE_KIND_BY_SUBBLOCK_TYPE: Partial< + Record< + SubBlockType, + Exclude + > +> = { + 'oauth-input': 'oauth-credential', + 'knowledge-base-selector': 'knowledge-base', + 'document-selector': 'knowledge-document', + 'workflow-selector': 'workflow', + 'mcp-server-selector': 'mcp-server', + 'mcp-tool-selector': 'mcp-tool', + 'table-selector': 'table', + 'file-selector': 'file', + 'channel-selector': 'selector-resource', + 'user-selector': 'selector-resource', + 'sheet-selector': 'selector-resource', + 'folder-selector': 'selector-resource', + 'project-selector': 'selector-resource', + 'variables-input': 'selector-resource', +} + +export function getResourceKindForSubBlock( + subBlockConfig?: Pick +): StructuredResourceReference['kind'] | null { + if (!subBlockConfig) return null + return RESOURCE_KIND_BY_SUBBLOCK_TYPE[subBlockConfig.type] ?? null +} + +export function parseInlineReferences(value: string): ParsedInlineReference[] { + const references: ParsedInlineReference[] = [] + + const envPattern = createEnvVarPattern() + for (const match of value.matchAll(envPattern)) { + const rawValue = match[0] + const key = String(match[1] ?? '').trim() + const start = match.index ?? 0 + references.push({ + kind: 'environment', + rawValue, + searchText: key, + range: { start, end: start + rawValue.length }, + resource: { + kind: 'environment', + token: rawValue, + key, + }, + }) + } + + const referencePattern = createReferencePattern() + for (const match of value.matchAll(referencePattern)) { + const rawValue = match[0] + const reference = String(match[1] ?? '').trim() + const start = match.index ?? 0 + references.push({ + kind: 'workflow-reference', + rawValue, + searchText: reference, + range: { start, end: start + rawValue.length }, + resource: { + kind: 'workflow-reference', + token: rawValue, + key: reference, + }, + }) + } + + return references.sort((a, b) => a.range.start - b.range.start) +} + +function splitStructuredValue(value: unknown): string[] { + if (typeof value === 'string') { + return value + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + } + + if (Array.isArray(value)) { + return value.flatMap((item) => splitStructuredValue(item)) + } + + return [] +} + +export function parseStructuredResourceReferences( + value: unknown, + subBlockConfig?: SubBlockConfig, + selectorContext?: SelectorContext +): StructuredResourceReference[] { + const kind = getResourceKindForSubBlock(subBlockConfig) + if (!kind) return [] + + const values = splitStructuredValue(value) + return values.map((rawValue) => { + const resource: WorkflowSearchResourceMeta = { + kind, + providerId: subBlockConfig?.serviceId, + serviceId: subBlockConfig?.serviceId, + selectorKey: subBlockConfig?.selectorKey, + selectorContext: subBlockConfig?.selectorKey ? selectorContext : undefined, + requiredScopes: subBlockConfig?.requiredScopes, + key: rawValue, + } + resource.resourceGroupKey = buildWorkflowSearchResourceGroupKey(resource) + + return { + kind, + rawValue, + searchText: rawValue, + resource, + } + }) +} + +export function matchesSearchText( + candidate: string, + query: string | undefined, + caseSensitive = false +): boolean { + if (!query) return true + const source = caseSensitive ? candidate : candidate.toLowerCase() + const target = caseSensitive ? query : query.toLowerCase() + return source.includes(target) +} diff --git a/apps/sim/lib/workflows/search-replace/replacement-validation.ts b/apps/sim/lib/workflows/search-replace/replacement-validation.ts new file mode 100644 index 00000000000..ddec51daf54 --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/replacement-validation.ts @@ -0,0 +1,97 @@ +import { replacementOptionMatchesResourceMatch } from '@/lib/workflows/search-replace/resource-resolvers' +import type { + WorkflowSearchMatch, + WorkflowSearchMatchKind, + WorkflowSearchReplacementOption, +} from '@/lib/workflows/search-replace/types' + +const CONSTRAINED_RESOURCE_KINDS = new Set([ + 'environment', + 'oauth-credential', + 'knowledge-base', + 'knowledge-document', + 'workflow', + 'mcp-server', + 'mcp-tool', + 'table', + 'file', + 'selector-resource', +]) + +const RESOURCE_KIND_LABELS: Partial> = { + environment: 'environment variable', + 'oauth-credential': 'OAuth credential', + 'knowledge-base': 'knowledge base', + 'knowledge-document': 'knowledge document', + workflow: 'workflow', + 'mcp-server': 'MCP server', + 'mcp-tool': 'MCP tool', + table: 'table', + file: 'file', + 'selector-resource': 'selector resource', +} + +export function isConstrainedResourceMatch(match: WorkflowSearchMatch): boolean { + return CONSTRAINED_RESOURCE_KINDS.has(match.kind) +} + +function normalizeResourceReplacement(match: WorkflowSearchMatch, replacement: string): string { + if (match.kind !== 'environment') return replacement + + const trimmed = replacement.trim() + if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) return trimmed + return `{{${trimmed}}}` +} + +export function getCompatibleResourceReplacementOptions( + matches: WorkflowSearchMatch[], + resourceOptions: WorkflowSearchReplacementOption[] +): WorkflowSearchReplacementOption[] { + const constrainedMatches = matches.filter(isConstrainedResourceMatch) + if (constrainedMatches.length === 0) return [] + + const kinds = new Set(constrainedMatches.map((match) => match.kind)) + if (kinds.size !== 1) return [] + + return resourceOptions.filter((option) => + constrainedMatches.every((match) => replacementOptionMatchesResourceMatch(option, match)) + ) +} + +export function getWorkflowSearchReplacementIssue({ + matches, + replacement, + resourceOptions = [], +}: { + matches: WorkflowSearchMatch[] + replacement: string + resourceOptions?: WorkflowSearchReplacementOption[] +}): string | null { + const editableMatches = matches.filter((match) => match.editable) + const constrainedMatches = editableMatches.filter(isConstrainedResourceMatch) + if (constrainedMatches.length === 0) return null + + if (editableMatches.length !== constrainedMatches.length) { + return 'Replace references separately from text matches.' + } + + const kinds = new Set(constrainedMatches.map((match) => match.kind)) + if (kinds.size !== 1) { + return 'Replace one reference type at a time.' + } + + const [firstMatch] = constrainedMatches + const normalizedReplacement = normalizeResourceReplacement(firstMatch, replacement) + const compatibleOptions = getCompatibleResourceReplacementOptions( + constrainedMatches, + resourceOptions + ) + const hasResolvableReplacement = compatibleOptions.some( + (option) => option.value === normalizedReplacement + ) + + if (hasResolvableReplacement) return null + + const label = RESOURCE_KIND_LABELS[firstMatch.kind] ?? 'resource' + return `Choose a valid ${label} replacement.` +} diff --git a/apps/sim/lib/workflows/search-replace/replacements.test.ts b/apps/sim/lib/workflows/search-replace/replacements.test.ts new file mode 100644 index 00000000000..6e840c6cfd9 --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/replacements.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer' +import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements' +import { + createSearchReplaceWorkflowFixture, + SEARCH_REPLACE_BLOCK_CONFIGS, +} from '@/lib/workflows/search-replace/search-replace.fixtures' + +describe('buildWorkflowSearchReplacePlan', () => { + it('replaces selected text ranges across blocks without touching unselected matches', () => { + const workflow = createSearchReplaceWorkflowFixture() + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'email', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + const selectedMatchIds = new Set(matches.slice(0, 2).map((match) => match.id)) + + const plan = buildWorkflowSearchReplacePlan({ + blocks: workflow.blocks, + matches, + selectedMatchIds, + defaultReplacement: 'message', + }) + + expect(plan.conflicts).toEqual([]) + expect(plan.updates).toHaveLength(1) + expect(plan.updates[0]).toMatchObject({ + blockId: 'agent-1', + subBlockId: 'systemPrompt', + nextValue: 'message {{OLD_SECRET}} and then message again. Use .', + }) + }) + + it('replaces environment tokens while preserving surrounding text', () => { + const workflow = createSearchReplaceWorkflowFixture() + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'OLD_SECRET', + mode: 'resource', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + const selectedMatchIds = new Set(matches.map((match) => match.id)) + + const plan = buildWorkflowSearchReplacePlan({ + blocks: workflow.blocks, + matches, + selectedMatchIds, + defaultReplacement: 'NEW_SECRET', + resourceReplacementOptions: [ + { kind: 'environment', value: '{{NEW_SECRET}}', label: '{{NEW_SECRET}}' }, + ], + }) + + expect(plan.conflicts).toEqual([]) + expect(plan.updates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + blockId: 'agent-1', + subBlockId: 'systemPrompt', + nextValue: 'Email {{NEW_SECRET}} and then email again. Use .', + }), + expect.objectContaining({ + blockId: 'api-1', + subBlockId: 'headers', + nextValue: [ + { id: 'row-1', cells: { Key: 'Authorization', Value: 'Bearer {{NEW_SECRET}}' } }, + ], + }), + ]) + ) + }) + + it('replaces exact structured resources in comma-separated values', () => { + const workflow = createSearchReplaceWorkflowFixture() + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'kb-old', + mode: 'resource', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + const plan = buildWorkflowSearchReplacePlan({ + blocks: workflow.blocks, + matches, + selectedMatchIds: new Set(matches.map((match) => match.id)), + defaultReplacement: 'kb-new', + resourceReplacementOptions: [ + { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' }, + ], + }) + + expect(plan.updates).toHaveLength(1) + expect(plan.updates[0].nextValue).toBe('kb-new,kb-second') + }) + + it('replaces all compatible knowledge base references across blocks', () => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['knowledge-2'] = { + ...workflow.blocks['knowledge-1'], + id: 'knowledge-2', + name: 'Knowledge 2', + subBlocks: { + ...workflow.blocks['knowledge-1'].subBlocks, + knowledgeBaseIds: { + id: 'knowledgeBaseIds', + type: 'knowledge-base-selector', + value: 'kb-old', + }, + }, + } + + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'kb-old', + mode: 'resource', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }).filter((match) => match.kind === 'knowledge-base') + + const plan = buildWorkflowSearchReplacePlan({ + blocks: workflow.blocks, + matches, + selectedMatchIds: new Set(matches.map((match) => match.id)), + defaultReplacement: 'kb-new', + resourceReplacementOptions: [ + { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' }, + ], + }) + + expect(plan.conflicts).toEqual([]) + expect(plan.updates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ blockId: 'knowledge-1', nextValue: 'kb-new,kb-second' }), + expect.objectContaining({ blockId: 'knowledge-2', nextValue: 'kb-new' }), + ]) + ) + }) + + it('rejects structured resource replacements that are not resolvable options', () => { + const workflow = createSearchReplaceWorkflowFixture() + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'kb-old', + mode: 'resource', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + const plan = buildWorkflowSearchReplacePlan({ + blocks: workflow.blocks, + matches, + selectedMatchIds: new Set(matches.map((match) => match.id)), + defaultReplacement: 'missing-kb', + resourceReplacementOptions: [ + { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' }, + ], + }) + + expect(plan.updates).toEqual([]) + expect(plan.conflicts).toEqual([ + { matchId: matches[0].id, reason: 'Choose a valid knowledge base replacement.' }, + ]) + }) + + it('rejects stale matches without partial writes', () => { + const workflow = createSearchReplaceWorkflowFixture() + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'email', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + workflow.blocks['agent-1'].subBlocks.systemPrompt.value = 'changed' + + const plan = buildWorkflowSearchReplacePlan({ + blocks: workflow.blocks, + matches, + selectedMatchIds: new Set([matches[0].id]), + defaultReplacement: 'message', + }) + + expect(plan.updates).toEqual([]) + expect(plan.conflicts).toEqual([ + { matchId: matches[0].id, reason: 'Target text changed since search' }, + ]) + }) +}) diff --git a/apps/sim/lib/workflows/search-replace/replacements.ts b/apps/sim/lib/workflows/search-replace/replacements.ts new file mode 100644 index 00000000000..8dd747045c2 --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/replacements.ts @@ -0,0 +1,190 @@ +import { getWorkflowSearchReplacementIssue } from '@/lib/workflows/search-replace/replacement-validation' +import type { + WorkflowSearchMatch, + WorkflowSearchReplacementOption, + WorkflowSearchReplacePlan, + WorkflowSearchReplaceUpdate, +} from '@/lib/workflows/search-replace/types' +import { + getValueAtPath, + pathToKey, + setValueAtPath, +} from '@/lib/workflows/search-replace/value-walker' +import type { BlockState } from '@/stores/workflows/workflow/types' + +interface BuildWorkflowSearchReplacePlanParams { + blocks: Record + matches: WorkflowSearchMatch[] + selectedMatchIds: Set + replacementByMatchId?: Record + defaultReplacement?: string + resourceReplacementOptions?: WorkflowSearchReplacementOption[] +} + +function normalizeReplacement(match: WorkflowSearchMatch, replacement: string): string { + if (match.kind === 'environment') { + const trimmed = replacement.trim() + if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) return trimmed + return `{{${trimmed}}}` + } + return replacement +} + +function replaceRange(value: string, start: number, end: number, replacement: string): string { + return `${value.slice(0, start)}${replacement}${value.slice(end)}` +} + +function replaceStructuredValue(value: unknown, rawValue: string, replacement: string): unknown { + if (typeof value === 'string') { + const parts = value.split(',').map((part) => part.trim()) + if (parts.length > 1) { + return parts.map((part) => (part === rawValue ? replacement : part)).join(',') + } + return value === rawValue ? replacement : value + } + + if (Array.isArray(value)) { + return value.map((item) => + typeof item === 'string' && item === rawValue + ? replacement + : replaceStructuredValue(item, rawValue, replacement) + ) + } + + return value +} + +function structuredValueContains(value: unknown, rawValue: string): boolean { + if (typeof value === 'string') { + return value + .split(',') + .map((part) => part.trim()) + .includes(rawValue) + } + if (Array.isArray(value)) { + return value.some((item) => structuredValueContains(item, rawValue)) + } + return false +} + +function getReplacement( + match: WorkflowSearchMatch, + replacementByMatchId: Record | undefined, + defaultReplacement: string | undefined +): string | undefined { + const replacement = replacementByMatchId?.[match.id] ?? defaultReplacement + if (replacement === undefined) return undefined + return normalizeReplacement(match, replacement) +} + +export function buildWorkflowSearchReplacePlan({ + blocks, + matches, + selectedMatchIds, + replacementByMatchId, + defaultReplacement, + resourceReplacementOptions, +}: BuildWorkflowSearchReplacePlanParams): WorkflowSearchReplacePlan { + const skipped: WorkflowSearchReplacePlan['skipped'] = [] + const conflicts: WorkflowSearchReplacePlan['conflicts'] = [] + const updatesByField = new Map() + + const selectedMatches = matches.filter((match) => selectedMatchIds.has(match.id)) + const orderedMatches = [...selectedMatches].sort((a, b) => { + const blockCompare = a.blockId.localeCompare(b.blockId) + if (blockCompare !== 0) return blockCompare + const subBlockCompare = a.subBlockId.localeCompare(b.subBlockId) + if (subBlockCompare !== 0) return subBlockCompare + const pathCompare = pathToKey(a.valuePath).localeCompare(pathToKey(b.valuePath)) + if (pathCompare !== 0) return pathCompare + return (b.range?.start ?? 0) - (a.range?.start ?? 0) + }) + + for (const match of orderedMatches) { + const replacement = getReplacement(match, replacementByMatchId, defaultReplacement) + if (replacement === undefined || replacement === match.rawValue) { + skipped.push({ matchId: match.id, reason: 'No replacement value provided' }) + continue + } + + if (!match.editable) { + skipped.push({ matchId: match.id, reason: match.reason ?? 'Match is not editable' }) + continue + } + + const replacementIssue = getWorkflowSearchReplacementIssue({ + matches: [match], + replacement, + resourceOptions: resourceReplacementOptions, + }) + if (replacementIssue) { + conflicts.push({ matchId: match.id, reason: replacementIssue }) + continue + } + + const block = blocks[match.blockId] + const subBlock = block?.subBlocks?.[match.subBlockId] + if (!block || !subBlock) { + conflicts.push({ matchId: match.id, reason: 'Block or subblock no longer exists' }) + continue + } + + const updateKey = `${match.blockId}:${match.subBlockId}` + const existingUpdate = updatesByField.get(updateKey) + const previousValue: unknown = existingUpdate?.previousValue ?? subBlock.value + let nextValue: unknown = existingUpdate?.nextValue ?? subBlock.value + + if (match.range) { + const currentLeaf = getValueAtPath(nextValue, match.valuePath) + if (typeof currentLeaf !== 'string') { + conflicts.push({ matchId: match.id, reason: 'Target value is no longer text' }) + continue + } + + const currentRawValue = currentLeaf.slice(match.range.start, match.range.end) + if (currentRawValue !== match.rawValue) { + conflicts.push({ matchId: match.id, reason: 'Target text changed since search' }) + continue + } + + nextValue = setValueAtPath( + nextValue, + match.valuePath, + replaceRange(currentLeaf, match.range.start, match.range.end, replacement) + ) + } else { + const currentValue = getValueAtPath(nextValue, match.valuePath) + const valueForReplacement = match.valuePath.length === 0 ? nextValue : currentValue + if (!structuredValueContains(valueForReplacement, match.rawValue)) { + conflicts.push({ matchId: match.id, reason: 'Target resource changed since search' }) + continue + } + + const replacedValue = replaceStructuredValue(valueForReplacement, match.rawValue, replacement) + nextValue = + match.valuePath.length === 0 + ? replacedValue + : setValueAtPath(nextValue, match.valuePath, replacedValue) + } + + updatesByField.set(updateKey, { + blockId: match.blockId, + subBlockId: match.subBlockId, + previousValue, + nextValue, + matchIds: [...(existingUpdate?.matchIds ?? []), match.id], + }) + } + + if (conflicts.length > 0) { + return { updates: [], skipped, conflicts } + } + + return { + updates: [...updatesByField.values()].filter( + (update) => update.previousValue !== update.nextValue + ), + skipped, + conflicts, + } +} diff --git a/apps/sim/lib/workflows/search-replace/resource-resolvers.ts b/apps/sim/lib/workflows/search-replace/resource-resolvers.ts new file mode 100644 index 00000000000..22b30dab34f --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/resource-resolvers.ts @@ -0,0 +1,114 @@ +import type { + WorkflowSearchMatch, + WorkflowSearchReplacementOption, + WorkflowSearchResourceMeta, +} from '@/lib/workflows/search-replace/types' +import type { SelectorContext } from '@/hooks/selectors/types' + +export function stableStringifyWorkflowSearchValue(value: unknown): string { + if (!value || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringifyWorkflowSearchValue(item)).join(',')}]` + } + + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringifyWorkflowSearchValue(item)}`) + .join(',')}}` +} + +export function buildWorkflowSearchResourceGroupKey( + resource: Pick< + WorkflowSearchResourceMeta, + 'kind' | 'providerId' | 'serviceId' | 'selectorKey' | 'selectorContext' + > +): string { + const provider = resource.providerId ?? resource.serviceId ?? '' + const selectorKey = resource.selectorKey ?? '' + const selectorContext = resource.selectorContext + ? stableStringifyWorkflowSearchValue(resource.selectorContext) + : '' + + return [resource.kind, provider, selectorKey, selectorContext].join(':') +} + +export function getWorkflowSearchMatchResourceGroupKey(match: WorkflowSearchMatch): string { + return ( + match.resource?.resourceGroupKey ?? + buildWorkflowSearchResourceGroupKey({ + kind: match.kind as WorkflowSearchResourceMeta['kind'], + providerId: match.resource?.providerId, + serviceId: match.resource?.serviceId, + selectorKey: match.resource?.selectorKey, + selectorContext: match.resource?.selectorContext, + }) + ) +} + +export function selectorContextMatches( + left: SelectorContext | undefined, + right: SelectorContext | undefined +): boolean { + return ( + stableStringifyWorkflowSearchValue(left ?? {}) === + stableStringifyWorkflowSearchValue(right ?? {}) + ) +} + +export function replacementOptionMatchesResourceMatch( + option: WorkflowSearchReplacementOption, + match: WorkflowSearchMatch +): boolean { + if (option.kind !== match.kind) return false + + const optionGroupKey = + option.resourceGroupKey ?? + buildWorkflowSearchResourceGroupKey({ + kind: option.kind as WorkflowSearchResourceMeta['kind'], + providerId: option.providerId, + serviceId: option.serviceId, + selectorKey: option.selectorKey, + selectorContext: option.selectorContext, + }) + + return optionGroupKey === getWorkflowSearchMatchResourceGroupKey(match) +} + +export function getWorkflowSearchCompatibleResourceMatches( + activeMatch: WorkflowSearchMatch | null, + matches: WorkflowSearchMatch[] +): WorkflowSearchMatch[] { + if (!activeMatch?.resource) return [] + const activeGroupKey = getWorkflowSearchMatchResourceGroupKey(activeMatch) + return matches.filter( + (match) => + match.editable && + Boolean(match.resource) && + getWorkflowSearchMatchResourceGroupKey(match) === activeGroupKey + ) +} + +export function workflowSearchMatchMatchesQuery( + match: WorkflowSearchMatch & { displayLabel?: string }, + query: string, + caseSensitive = false +): boolean { + const trimmedQuery = query.trim() + if (!trimmedQuery) return false + if (match.kind === 'text') return true + + const normalize = (value: string) => (caseSensitive ? value : value.toLowerCase()) + const searchable = [ + match.displayLabel, + match.rawValue, + match.searchText, + match.fieldTitle, + match.blockName, + match.resource?.kind, + match.resource?.selectorKey, + ] + .filter(Boolean) + .join(' ') + + return normalize(searchable).includes(normalize(trimmedQuery)) +} diff --git a/apps/sim/lib/workflows/search-replace/search-replace.fixtures.ts b/apps/sim/lib/workflows/search-replace/search-replace.fixtures.ts new file mode 100644 index 00000000000..7c3b1de6ad2 --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/search-replace.fixtures.ts @@ -0,0 +1,142 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +export const SEARCH_REPLACE_BLOCK_CONFIGS: Record = { + agent: { + subBlocks: [ + { id: 'systemPrompt', title: 'System Prompt', type: 'long-input' }, + { + id: 'credential', + title: 'Credential', + type: 'oauth-input', + serviceId: 'gmail', + canonicalParamId: 'oauthCredential', + }, + { + id: 'label', + title: 'Label', + type: 'folder-selector', + selectorKey: 'gmail.labels', + dependsOn: ['credential'], + }, + ], + }, + knowledge: { + subBlocks: [ + { + id: 'knowledgeBaseIds', + title: 'Knowledge Bases', + type: 'knowledge-base-selector', + canonicalParamId: 'knowledgeBaseId', + }, + { + id: 'documentId', + title: 'Document', + type: 'document-selector', + serviceId: 'knowledge', + selectorKey: 'knowledge.documents', + dependsOn: ['knowledgeBaseIds'], + }, + ], + }, + api: { + subBlocks: [ + { id: 'body', title: 'Body', type: 'code' }, + { id: 'headers', title: 'Headers', type: 'table' }, + ], + }, +} + +export function createSearchReplaceWorkflowFixture(): WorkflowState { + return { + currentWorkflowId: 'workflow-1', + blocks: { + 'agent-1': { + id: 'agent-1', + type: 'agent', + name: 'Agent 1', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: { + systemPrompt: { + id: 'systemPrompt', + type: 'long-input', + value: 'Email {{OLD_SECRET}} and then email again. Use .', + }, + credential: { + id: 'credential', + type: 'oauth-input', + value: 'gmail-credential-old', + }, + label: { + id: 'label', + type: 'folder-selector', + value: 'INBOX', + }, + }, + }, + 'knowledge-1': { + id: 'knowledge-1', + type: 'knowledge', + name: 'Knowledge 1', + position: { x: 200, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: { + knowledgeBaseIds: { + id: 'knowledgeBaseIds', + type: 'knowledge-base-selector', + value: 'kb-old,kb-second', + }, + documentId: { + id: 'documentId', + type: 'document-selector', + value: 'doc-old', + }, + }, + }, + 'api-1': { + id: 'api-1', + type: 'api', + name: 'API 1', + position: { x: 400, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: { + body: { + id: 'body', + type: 'code', + value: { content: 'email in nested body' } as unknown as string, + }, + headers: { + id: 'headers', + type: 'table', + value: [ + { id: 'row-1', cells: { Key: 'Authorization', Value: 'Bearer {{OLD_SECRET}}' } }, + ] as unknown as string[][], + }, + }, + }, + 'locked-1': { + id: 'locked-1', + type: 'agent', + name: 'Locked Agent', + position: { x: 600, y: 0 }, + enabled: true, + locked: true, + outputs: {}, + subBlocks: { + systemPrompt: { + id: 'systemPrompt', + type: 'long-input', + value: 'email from locked block', + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } +} diff --git a/apps/sim/lib/workflows/search-replace/state.test.ts b/apps/sim/lib/workflows/search-replace/state.test.ts new file mode 100644 index 00000000000..ed35e0140ee --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/state.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it } from 'vitest' +import { getWorkflowSearchBlocks } from '@/lib/workflows/search-replace/state' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import type { BlockState } from '@/stores/workflows/workflow/types' + +describe('getWorkflowSearchBlocks', () => { + const blocks = { + block1: { + id: 'block1', + type: 'function', + name: 'Function 1', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: { + code: { id: 'code', type: 'code', value: '' }, + }, + }, + } as Record + + beforeEach(() => { + useSubBlockStore.setState({ workflowValues: {} }) + }) + + it('uses merged live subblock values for normal workflow search', () => { + useSubBlockStore.getState().setWorkflowValues('workflow-1', { + block1: { code: 'Hello' }, + }) + + const result = getWorkflowSearchBlocks({ + blocks, + workflowId: 'workflow-1', + isSnapshotView: false, + }) + + expect(result.block1.subBlocks.code.value).toBe('Hello') + }) + + it('does not merge snapshot blocks', () => { + const result = getWorkflowSearchBlocks({ + blocks, + workflowId: 'workflow-1', + isSnapshotView: true, + }) + + expect(result).toBe(blocks) + }) +}) diff --git a/apps/sim/lib/workflows/search-replace/state.ts b/apps/sim/lib/workflows/search-replace/state.ts new file mode 100644 index 00000000000..bd4648ce6bb --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/state.ts @@ -0,0 +1,17 @@ +import { mergeSubblockState } from '@/stores/workflows/utils' +import type { BlockState } from '@/stores/workflows/workflow/types' + +interface GetWorkflowSearchBlocksOptions { + blocks: Record + workflowId?: string + isSnapshotView?: boolean +} + +export function getWorkflowSearchBlocks({ + blocks, + workflowId, + isSnapshotView, +}: GetWorkflowSearchBlocksOptions): Record { + if (isSnapshotView || !workflowId) return blocks + return mergeSubblockState(blocks, workflowId) +} diff --git a/apps/sim/lib/workflows/search-replace/types.ts b/apps/sim/lib/workflows/search-replace/types.ts new file mode 100644 index 00000000000..c51c1d5197a --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/types.ts @@ -0,0 +1,117 @@ +import type { SubBlockType } from '@sim/workflow-types/blocks' +import type { SubBlockConfig } from '@/blocks/types' +import type { SelectorContext } from '@/hooks/selectors/types' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' + +export type WorkflowSearchMode = 'text' | 'resource' | 'all' + +export type WorkflowSearchMatchKind = + | 'text' + | 'environment' + | 'workflow-reference' + | 'oauth-credential' + | 'knowledge-base' + | 'knowledge-document' + | 'workflow' + | 'mcp-server' + | 'mcp-tool' + | 'table' + | 'file' + | 'selector-resource' + +export type WorkflowSearchValuePath = Array + +export interface WorkflowSearchRange { + start: number + end: number +} + +export interface WorkflowSearchResourceMeta { + kind: Exclude + providerId?: string + serviceId?: string + selectorKey?: string + selectorContext?: SelectorContext + resourceGroupKey?: string + requiredScopes?: string[] + token?: string + key?: string +} + +export interface WorkflowSearchMatch { + id: string + blockId: string + blockName: string + blockType: string + subBlockId: string + canonicalSubBlockId: string + subBlockType: SubBlockType + fieldTitle?: string + valuePath: WorkflowSearchValuePath + kind: WorkflowSearchMatchKind + rawValue: string + searchText: string + range?: WorkflowSearchRange + resource?: WorkflowSearchResourceMeta + editable: boolean + navigable: boolean + protected: boolean + reason?: string +} + +export interface WorkflowSearchIndexerOptions { + workflow: Pick + query?: string + mode?: WorkflowSearchMode + caseSensitive?: boolean + includeResourceMatchesWithoutQuery?: boolean + isSnapshotView?: boolean + workspaceId?: string + workflowId?: string + blockConfigs?: Record +} + +export interface IndexedSubBlockContext { + block: BlockState + blockConfig?: { subBlocks?: SubBlockConfig[] } + subBlockConfig?: SubBlockConfig + subBlockId: string + canonicalSubBlockId: string + protected: boolean + isSnapshotView?: boolean +} + +export interface WorkflowSearchReplacementTarget { + matchId: string + replacement: string +} + +export interface WorkflowSearchReplacementOption { + kind: WorkflowSearchMatchKind + value: string + label: string + providerId?: string + serviceId?: string + selectorKey?: string + selectorContext?: SelectorContext + resourceGroupKey?: string +} + +export interface WorkflowSearchReplaceUpdate { + blockId: string + subBlockId: string + previousValue: unknown + nextValue: unknown + matchIds: string[] +} + +export interface WorkflowSearchReplaceSkipped { + matchId: string + reason: string +} + +export interface WorkflowSearchReplacePlan { + updates: WorkflowSearchReplaceUpdate[] + skipped: WorkflowSearchReplaceSkipped[] + conflicts: WorkflowSearchReplaceSkipped[] +} diff --git a/apps/sim/lib/workflows/search-replace/value-walker.ts b/apps/sim/lib/workflows/search-replace/value-walker.ts new file mode 100644 index 00000000000..d8466de65e7 --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/value-walker.ts @@ -0,0 +1,76 @@ +import type { WorkflowSearchValuePath } from '@/lib/workflows/search-replace/types' + +export interface WalkedStringValue { + path: WorkflowSearchValuePath + value: string +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function walkStringValues( + value: unknown, + path: WorkflowSearchValuePath = [] +): WalkedStringValue[] { + if (typeof value === 'string') { + return value.length > 0 ? [{ path, value }] : [] + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return [{ path, value: String(value) }] + } + + if (Array.isArray(value)) { + return value.flatMap((item, index) => walkStringValues(item, [...path, index])) + } + + if (isRecord(value)) { + return Object.entries(value).flatMap(([key, item]) => walkStringValues(item, [...path, key])) + } + + return [] +} + +export function getValueAtPath(value: unknown, path: WorkflowSearchValuePath): unknown { + return path.reduce((current, segment) => { + if (Array.isArray(current) && typeof segment === 'number') { + return current[segment] + } + if (isRecord(current) && typeof segment === 'string') { + return current[segment] + } + return undefined + }, value) +} + +export function setValueAtPath( + value: unknown, + path: WorkflowSearchValuePath, + nextValue: unknown +): unknown { + if (path.length === 0) return nextValue + + const [segment, ...remaining] = path + + if (Array.isArray(value)) { + const copy = [...value] + if (typeof segment !== 'number') return value + copy[segment] = setValueAtPath(copy[segment], remaining, nextValue) + return copy + } + + if (isRecord(value)) { + if (typeof segment !== 'string') return value + return { + ...value, + [segment]: setValueAtPath(value[segment], remaining, nextValue), + } + } + + return value +} + +export function pathToKey(path: WorkflowSearchValuePath): string { + return path.map((segment) => String(segment).replaceAll('.', '\\.')).join('.') +} diff --git a/apps/sim/stores/panel/editor/store.ts b/apps/sim/stores/panel/editor/store.ts index f78c35b73f3..a72fdfe2a93 100644 --- a/apps/sim/stores/panel/editor/store.ts +++ b/apps/sim/stores/panel/editor/store.ts @@ -7,6 +7,16 @@ import { usePanelStore } from '../store' let renameCallback: (() => void) | null = null +interface ActiveSearchTarget { + matchId: string + blockId: string + subBlockId: string + canonicalSubBlockId: string + valuePath: Array + kind: string + resourceGroupKey?: string +} + /** * State for the Editor panel. * Tracks the currently selected block to edit its subblocks/values and connections panel height. @@ -14,8 +24,12 @@ let renameCallback: (() => void) | null = null interface PanelEditorState { /** Currently selected block identifier, or null when nothing is selected */ currentBlockId: string | null + /** Ephemeral workflow search target used for scrolling/highlighting editor fields */ + activeSearchTarget: ActiveSearchTarget | null /** Sets the current selected block identifier (use null to clear) */ setCurrentBlockId: (blockId: string | null) => void + /** Sets an active search target to highlight in the editor */ + setActiveSearchTarget: (target: ActiveSearchTarget | null) => void /** Clears the current selection */ clearCurrentBlock: () => void /** Height of the connections section in pixels */ @@ -38,6 +52,7 @@ export const usePanelEditorStore = create()( persist( (set, get) => ({ currentBlockId: null, + activeSearchTarget: null, connectionsHeight: EDITOR_CONNECTIONS_HEIGHT.DEFAULT, registerRenameCallback: (callback) => { renameCallback = callback @@ -51,8 +66,14 @@ export const usePanelEditorStore = create()( usePanelStore.getState().setActiveTab('editor') } }, + setActiveSearchTarget: (target) => { + set({ activeSearchTarget: target }) + if (target) { + usePanelStore.getState().setActiveTab('editor') + } + }, clearCurrentBlock: () => { - set({ currentBlockId: null }) + set({ currentBlockId: null, activeSearchTarget: null }) }, setConnectionsHeight: (height) => { const clampedHeight = Math.max( diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index 672383ad47d..c63504782d5 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -105,6 +105,18 @@ export interface BatchToggleLockedOperation extends BaseOperation { } } +export interface BatchUpdateSubblocksOperation extends BaseOperation { + type: typeof UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS + data: { + updates: Array<{ + blockId: string + subBlockId: string + before: unknown + after: unknown + }> + } +} + export interface ApplyDiffOperation extends BaseOperation { type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF data: { @@ -145,6 +157,7 @@ export type Operation = | BatchToggleEnabledOperation | BatchToggleHandlesOperation | BatchToggleLockedOperation + | BatchUpdateSubblocksOperation | ApplyDiffOperation | AcceptDiffOperation | RejectDiffOperation diff --git a/apps/sim/stores/undo-redo/utils.test.ts b/apps/sim/stores/undo-redo/utils.test.ts index d75f95ffa7e..ac91d3d014e 100644 --- a/apps/sim/stores/undo-redo/utils.test.ts +++ b/apps/sim/stores/undo-redo/utils.test.ts @@ -10,8 +10,9 @@ vi.mock('@/stores/workflows/utils', () => ({ mergeSubblockState: vi.fn(), })) +import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants' import { mergeSubblockState } from '@/stores/workflows/utils' -import { captureLatestEdges, captureLatestSubBlockValues } from './utils' +import { captureLatestEdges, captureLatestSubBlockValues, createInverseOperation } from './utils' const mockMergeSubblockState = mergeSubblockState as Mock @@ -392,3 +393,39 @@ describe('captureLatestSubBlockValues', () => { }) }) }) + +describe('createInverseOperation', () => { + it('inverts batch subblock updates', () => { + const operation = { + id: 'op-1', + type: UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS, + timestamp: 1, + workflowId: 'workflow-1', + userId: 'user-1', + data: { + updates: [ + { + blockId: 'block-1', + subBlockId: 'prompt', + before: 'old', + after: 'new', + }, + ], + }, + } + + expect(createInverseOperation(operation)).toEqual({ + ...operation, + data: { + updates: [ + { + blockId: 'block-1', + subBlockId: 'prompt', + before: 'new', + after: 'old', + }, + ], + }, + }) + }) +}) diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index 1803970cd68..1ac962160f6 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -8,6 +8,7 @@ import type { BatchRemoveBlocksOperation, BatchRemoveEdgesOperation, BatchUpdateParentOperation, + BatchUpdateSubblocksOperation, Operation, OperationEntry, } from '@/stores/undo-redo/types' @@ -177,6 +178,21 @@ export function createInverseOperation(operation: Operation): Operation { }, } + case UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS: { + const op = operation as BatchUpdateSubblocksOperation + return { + ...operation, + data: { + updates: op.data.updates.map((update) => ({ + blockId: update.blockId, + subBlockId: update.subBlockId, + before: update.after, + after: update.before, + })), + }, + } as BatchUpdateSubblocksOperation + } + default: { const exhaustiveCheck: never = operation throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) diff --git a/apps/sim/stores/workflow-search-replace/store.ts b/apps/sim/stores/workflow-search-replace/store.ts new file mode 100644 index 00000000000..6066d927e55 --- /dev/null +++ b/apps/sim/stores/workflow-search-replace/store.ts @@ -0,0 +1,42 @@ +'use client' + +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +interface WorkflowSearchReplacePosition { + x: number + y: number +} + +interface WorkflowSearchReplaceState { + isOpen: boolean + position: WorkflowSearchReplacePosition | null + query: string + replacement: string + activeMatchId: string | null + open: () => void + close: () => void + setPosition: (position: WorkflowSearchReplacePosition) => void + setQuery: (query: string) => void + setReplacement: (replacement: string) => void + setActiveMatchId: (matchId: string | null) => void +} + +export const useWorkflowSearchReplaceStore = create()( + devtools( + (set) => ({ + isOpen: false, + position: null, + query: '', + replacement: '', + activeMatchId: null, + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false, activeMatchId: null }), + setPosition: (position) => set({ position }), + setQuery: (query) => set({ query }), + setReplacement: (replacement) => set({ replacement }), + setActiveMatchId: (activeMatchId) => set({ activeMatchId }), + }), + { name: 'workflow-search-replace-store' } + ) +) diff --git a/packages/realtime-protocol/src/constants.ts b/packages/realtime-protocol/src/constants.ts index b3afff2e673..7ba3751a34f 100644 --- a/packages/realtime-protocol/src/constants.ts +++ b/packages/realtime-protocol/src/constants.ts @@ -58,6 +58,7 @@ export type WorkflowOperation = (typeof WORKFLOW_OPERATIONS)[keyof typeof WORKFL export const SUBBLOCK_OPERATIONS = { UPDATE: 'subblock-update', + BATCH_UPDATE: 'subblock-batch-update', } as const export type SubblockOperation = (typeof SUBBLOCK_OPERATIONS)[keyof typeof SUBBLOCK_OPERATIONS] @@ -87,6 +88,7 @@ export const UNDO_REDO_OPERATIONS = { BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled', BATCH_TOGGLE_HANDLES: 'batch-toggle-handles', BATCH_TOGGLE_LOCKED: 'batch-toggle-locked', + BATCH_UPDATE_SUBBLOCKS: 'batch-update-subblocks', APPLY_DIFF: 'apply-diff', ACCEPT_DIFF: 'accept-diff', REJECT_DIFF: 'reject-diff', diff --git a/packages/realtime-protocol/src/schemas.ts b/packages/realtime-protocol/src/schemas.ts index 77f228efe8c..030155a402d 100644 --- a/packages/realtime-protocol/src/schemas.ts +++ b/packages/realtime-protocol/src/schemas.ts @@ -5,6 +5,7 @@ import { EDGE_OPERATIONS, EDGES_OPERATIONS, OPERATION_TARGETS, + SUBBLOCK_OPERATIONS, SUBFLOW_OPERATIONS, VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, @@ -134,6 +135,23 @@ export const WorkflowStateOperationSchema = z.object({ operationId: z.string().optional(), }) +export const SubblockOperationSchema = z.object({ + operation: z.literal(SUBBLOCK_OPERATIONS.BATCH_UPDATE), + target: z.literal(OPERATION_TARGETS.SUBBLOCK), + payload: z.object({ + updates: z.array( + z.object({ + blockId: z.string(), + subblockId: z.string(), + value: z.any(), + expectedValue: z.any().optional(), + }) + ), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + export const BatchAddBlocksSchema = z.object({ operation: z.literal(BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS), target: z.literal(OPERATION_TARGETS.BLOCKS), @@ -250,4 +268,5 @@ export const WorkflowOperationSchema = z.union([ SubflowOperationSchema, VariableOperationSchema, WorkflowStateOperationSchema, + SubblockOperationSchema, ]) From b57c1e62486882995a5b63170c07984f75ec1082 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 4 May 2026 18:23:44 -0700 Subject: [PATCH 02/11] fix alignment --- .../workflow-search-replace.tsx | 74 +++++++++---------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx index 5bab9d126f2..1373d8b2119 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx @@ -400,45 +400,43 @@ export function WorkflowSearchReplace() {
-
- - { - if (event.key !== 'Enter') return - event.preventDefault() - handleMoveActiveMatch(event.shiftKey ? -1 : 1) - }} - onChange={(event) => setQuery(event.target.value)} + - -
+ + { + if (event.key !== 'Enter') return + event.preventDefault() + handleMoveActiveMatch(event.shiftKey ? -1 : 1) + }} + onChange={(event) => setQuery(event.target.value)} + /> + + {isReplaceExpanded && (
From 1167f321487e3a99b5440f415c1748884a1db07d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 5 May 2026 10:26:17 -0700 Subject: [PATCH 03/11] fix hidden fields bug --- .../subflow-editor/subflow-editor.tsx | 45 ++- .../panel/components/editor/editor.tsx | 1 + .../workflows/search-replace/indexer.test.ts | 80 ++++++ .../lib/workflows/search-replace/indexer.ts | 256 +++++++++++++++--- .../workflows/search-replace/replacements.ts | 4 +- .../search-replace/search-replace.fixtures.ts | 12 +- .../search-replace/subflow-fields.ts | 9 + .../sim/lib/workflows/search-replace/types.ts | 18 +- apps/sim/stores/panel/editor/store.ts | 2 +- 9 files changed, 366 insertions(+), 61 deletions(-) create mode 100644 apps/sim/lib/workflows/search-replace/subflow-fields.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/subflow-editor/subflow-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/subflow-editor/subflow-editor.tsx index 4fd5c7b77cf..71876223bf3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/subflow-editor/subflow-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/subflow-editor/subflow-editor.tsx @@ -3,7 +3,10 @@ import { ChevronUp } from 'lucide-react' import SimpleCodeEditor from 'react-simple-code-editor' import { Code as CodeEditor, Combobox, getCodeEditorProps, Input, Label } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' +import type { ActiveSearchTarget } from '@/stores/panel/editor/store' import type { BlockState } from '@/stores/workflows/workflow/types' import type { ConnectedBlock } from '../../hooks/use-block-connections' import { useSubflowEditor } from '../../hooks/use-subflow-editor' @@ -21,6 +24,7 @@ interface SubflowEditorProps { toggleConnectionsCollapsed: () => void userCanEdit: boolean isConnectionsAtMinHeight: boolean + activeSearchTarget?: ActiveSearchTarget | null } /** @@ -41,6 +45,7 @@ export function SubflowEditor({ toggleConnectionsCollapsed, userCanEdit, isConnectionsAtMinHeight, + activeSearchTarget, }: SubflowEditorProps) { const { subflowConfig, @@ -64,13 +69,30 @@ export function SubflowEditor({ if (!subflowConfig) return null + const configSearchFieldId = isCountMode + ? WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations + : isConditionMode + ? WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.condition + : WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items + const isSearchHighlighted = (fieldId: string) => + activeSearchTarget?.blockId === currentBlockId && + (activeSearchTarget.subBlockId === fieldId || + activeSearchTarget.canonicalSubBlockId === fieldId) + const isTypeHighlighted = isSearchHighlighted(WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.type) + const isConfigHighlighted = isSearchHighlighted(configSearchFieldId) + return (
- {/* Subflow Editor Section */}
- {/* Type Selection */} -
+
@@ -83,7 +105,6 @@ export function SubflowEditor({ />
- {/* Dashed Line Separator */}
- {/* Configuration */} -
+
- {/* Connections Section - Only show when there are connections */} {hasIncomingConnections && (
- {/* Resize Handle */}
- {/* Connections Header with Chevron */}
Connections
- {/* Connections Content - Always visible */}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index 53d99381d9f..ee4c3ccce85 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -506,6 +506,7 @@ export function Editor() { toggleConnectionsCollapsed={toggleConnectionsCollapsed} userCanEdit={canEditBlock} isConnectionsAtMinHeight={isConnectionsAtMinHeight} + activeSearchTarget={activeSearchTarget} /> ) : (
diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index 9b6f7d3323c..957da81b20c 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -8,6 +8,7 @@ import { createSearchReplaceWorkflowFixture, SEARCH_REPLACE_BLOCK_CONFIGS, } from '@/lib/workflows/search-replace/search-replace.fixtures' +import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields' describe('indexWorkflowSearchMatches', () => { it('finds plain text matches across nested subblock values', () => { @@ -30,6 +31,85 @@ describe('indexWorkflowSearchMatches', () => { expect(matches.at(-1)?.reason).toBe('Block is locked') }) + it('does not index internal row metadata in structured subblock values', () => { + const workflow = createSearchReplaceWorkflowFixture() + + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'row-1', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect(matches).toEqual([]) + }) + + it('indexes loop and parallel editor settings for navigation', () => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['parallel-1'] = { + id: 'parallel-1', + type: 'parallel', + name: 'Parallel 1', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: {}, + data: { + parallelType: 'count', + count: 20, + }, + } + workflow.blocks['loop-1'] = { + id: 'loop-1', + type: 'loop', + name: 'Loop 1', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: {}, + data: { + loopType: 'forEach', + collection: "['item-2']", + }, + } + + const countMatches = indexWorkflowSearchMatches({ + workflow, + query: '20', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + const collectionMatches = indexWorkflowSearchMatches({ + workflow, + query: 'item-2', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect(countMatches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + blockId: 'parallel-1', + subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations, + canonicalSubBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations, + fieldTitle: 'Parallel Iterations', + editable: false, + }), + ]) + ) + expect(collectionMatches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + blockId: 'loop-1', + subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, + canonicalSubBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, + fieldTitle: 'Collection Items', + editable: false, + }), + ]) + ) + }) + it('indexes environment tokens and workflow references embedded in strings', () => { const workflow = createSearchReplaceWorkflowFixture() diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 6b7fd2c1391..8da158678c2 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -1,11 +1,18 @@ +import type { SubBlockType } from '@sim/workflow-types/blocks' import { matchesSearchText, parseInlineReferences, parseStructuredResourceReferences, } from '@/lib/workflows/search-replace/reference-registry' +import { + WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS, + type WorkflowSearchSubflowFieldId, +} from '@/lib/workflows/search-replace/subflow-fields' import type { + WorkflowSearchBlockState, WorkflowSearchIndexerOptions, WorkflowSearchMatch, + WorkflowSearchValuePath, } from '@/lib/workflows/search-replace/types' import { pathToKey, walkStringValues } from '@/lib/workflows/search-replace/value-walker' import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' @@ -13,9 +20,11 @@ import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import type { SelectorContext } from '@/hooks/selectors/types' -import type { BlockState } from '@/stores/workflows/workflow/types' -function hasLockedAncestor(block: BlockState, blocks: Record): boolean { +function hasLockedAncestor( + block: WorkflowSearchBlockState, + blocks: Record +): boolean { let parentId = block.data?.parentId const visited = new Set() @@ -56,13 +65,181 @@ function createMatchId(parts: Array): string { .join(':') } +const STRUCTURED_METADATA_LEAF_KEYS = new Set(['id', 'collapsed']) + +function isSearchableLeafPath(path: Array): boolean { + const lastSegment = path.at(-1) + const parentSegment = path.at(-2) + if (typeof parentSegment !== 'number' || typeof lastSegment !== 'string') return true + return !STRUCTURED_METADATA_LEAF_KEYS.has(lastSegment) +} + +function getSearchableStringLeaves(value: unknown) { + return walkStringValues(value).filter((leaf) => isSearchableLeafPath(leaf.path)) +} + +interface SubflowSearchField { + subBlockId: WorkflowSearchSubflowFieldId + title: string + type: SubBlockType + value: string +} + +interface AddTextMatchesOptions { + matches: WorkflowSearchMatch[] + idPrefix: string + block: WorkflowSearchBlockState + subBlockId: string + canonicalSubBlockId: string + subBlockType: SubBlockType + fieldTitle?: string + value: string + valuePath: WorkflowSearchValuePath + query?: string + caseSensitive: boolean + editable: boolean + protectedByLock: boolean + isSnapshotView: boolean + readonlyReason?: string +} + +function getReadonlyReason({ + editable, + isSnapshotView, + readonlyReason, +}: { + editable: boolean + isSnapshotView: boolean + readonlyReason?: string +}) { + if (editable) return undefined + return readonlyReason ?? (isSnapshotView ? 'Snapshot view is readonly' : 'Block is locked') +} + +function addTextMatches({ + matches, + idPrefix, + block, + subBlockId, + canonicalSubBlockId, + subBlockType, + fieldTitle, + value, + valuePath, + query, + caseSensitive, + editable, + protectedByLock, + isSnapshotView, + readonlyReason, +}: AddTextMatchesOptions) { + const ranges = query ? findTextRanges(value, query, caseSensitive) : [] + ranges.forEach((range, occurrenceIndex) => { + matches.push({ + id: createMatchId([ + idPrefix, + block.id, + subBlockId, + pathToKey(valuePath), + range.start, + occurrenceIndex, + ]), + blockId: block.id, + blockName: block.name, + blockType: block.type, + subBlockId, + canonicalSubBlockId, + subBlockType, + fieldTitle, + valuePath, + kind: 'text', + rawValue: value.slice(range.start, range.end), + searchText: value, + range, + editable, + navigable: true, + protected: protectedByLock, + reason: getReadonlyReason({ editable, isSnapshotView, readonlyReason }), + }) + }) +} + +function getSubflowSearchFields(block: WorkflowSearchBlockState): SubflowSearchField[] { + if (block.type === 'loop') { + const loopType = block.data?.loopType ?? 'for' + const fields: SubflowSearchField[] = [ + { + subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.type, + title: 'Loop Type', + type: 'combobox', + value: loopType, + }, + ] + + if (loopType === 'for') { + fields.push({ + subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations, + title: 'Loop Iterations', + type: 'short-input', + value: String(block.data?.count ?? 5), + }) + } else if (loopType === 'forEach') { + fields.push({ + subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, + title: 'Collection Items', + type: 'code', + value: String(block.data?.collection ?? ''), + }) + } else { + fields.push({ + subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.condition, + title: 'While Condition', + type: 'code', + value: String( + loopType === 'doWhile' + ? (block.data?.doWhileCondition ?? '') + : (block.data?.whileCondition ?? '') + ), + }) + } + + return fields + } + + if (block.type === 'parallel') { + const parallelType = block.data?.parallelType ?? 'count' + return [ + { + subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.type, + title: 'Parallel Type', + type: 'combobox', + value: parallelType, + }, + { + subBlockId: + parallelType === 'count' + ? WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations + : WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, + title: parallelType === 'count' ? 'Parallel Iterations' : 'Parallel Items', + type: parallelType === 'count' ? 'short-input' : 'code', + value: + parallelType === 'count' + ? String(block.data?.count ?? 5) + : String(block.data?.collection ?? ''), + }, + ] + } + + return [] +} + function buildSearchSelectorContext({ block, subBlockConfigs, workspaceId, workflowId, }: { - block: BlockState + block: WorkflowSearchBlockState subBlockConfigs: SubBlockConfig[] workspaceId?: string workflowId?: string @@ -119,6 +296,30 @@ export function indexWorkflowSearchMatches( const protectedByLock = Boolean(block.locked || hasLockedAncestor(block, workflow.blocks)) const editable = !protectedByLock && !isSnapshotView + if (mode !== 'resource') { + for (const field of getSubflowSearchFields(block)) { + addTextMatches({ + matches, + idPrefix: 'subflow-text', + block, + subBlockId: field.subBlockId, + canonicalSubBlockId: field.subBlockId, + subBlockType: field.type, + fieldTitle: field.title, + value: field.value, + valuePath: [], + query, + caseSensitive, + editable: false, + protectedByLock, + isSnapshotView, + readonlyReason: editable + ? 'Subflow settings are edited from the block sidebar' + : undefined, + }) + } + } + for (const [subBlockId, subBlockState] of Object.entries(block.subBlocks ?? {})) { const subBlockConfig = configsById.get(subBlockId) const canonicalSubBlockId = @@ -126,42 +327,25 @@ export function indexWorkflowSearchMatches( subBlockConfig?.canonicalParamId ?? subBlockId const value = subBlockState?.value - const stringLeaves = walkStringValues(value) + const stringLeaves = getSearchableStringLeaves(value) if (mode !== 'resource') { for (const leaf of stringLeaves) { - const ranges = query ? findTextRanges(leaf.value, query, caseSensitive) : [] - ranges.forEach((range, occurrenceIndex) => { - matches.push({ - id: createMatchId([ - 'text', - block.id, - subBlockId, - pathToKey(leaf.path), - range.start, - occurrenceIndex, - ]), - blockId: block.id, - blockName: block.name, - blockType: block.type, - subBlockId, - canonicalSubBlockId, - subBlockType: subBlockConfig?.type ?? subBlockState.type, - fieldTitle: subBlockConfig?.title, - valuePath: leaf.path, - kind: 'text', - rawValue: leaf.value.slice(range.start, range.end), - searchText: leaf.value, - range, - editable, - navigable: true, - protected: protectedByLock, - reason: editable - ? undefined - : isSnapshotView - ? 'Snapshot view is readonly' - : 'Block is locked', - }) + addTextMatches({ + matches, + idPrefix: 'text', + block, + subBlockId, + canonicalSubBlockId, + subBlockType: subBlockConfig?.type ?? subBlockState.type, + fieldTitle: subBlockConfig?.title, + value: leaf.value, + valuePath: leaf.path, + query, + caseSensitive, + editable, + protectedByLock, + isSnapshotView, }) } } diff --git a/apps/sim/lib/workflows/search-replace/replacements.ts b/apps/sim/lib/workflows/search-replace/replacements.ts index 8dd747045c2..1c9ada3f98c 100644 --- a/apps/sim/lib/workflows/search-replace/replacements.ts +++ b/apps/sim/lib/workflows/search-replace/replacements.ts @@ -1,5 +1,6 @@ import { getWorkflowSearchReplacementIssue } from '@/lib/workflows/search-replace/replacement-validation' import type { + WorkflowSearchBlockState, WorkflowSearchMatch, WorkflowSearchReplacementOption, WorkflowSearchReplacePlan, @@ -10,10 +11,9 @@ import { pathToKey, setValueAtPath, } from '@/lib/workflows/search-replace/value-walker' -import type { BlockState } from '@/stores/workflows/workflow/types' interface BuildWorkflowSearchReplacePlanParams { - blocks: Record + blocks: Record matches: WorkflowSearchMatch[] selectedMatchIds: Set replacementByMatchId?: Record diff --git a/apps/sim/lib/workflows/search-replace/search-replace.fixtures.ts b/apps/sim/lib/workflows/search-replace/search-replace.fixtures.ts index 7c3b1de6ad2..e76dcb3c096 100644 --- a/apps/sim/lib/workflows/search-replace/search-replace.fixtures.ts +++ b/apps/sim/lib/workflows/search-replace/search-replace.fixtures.ts @@ -1,5 +1,5 @@ +import type { WorkflowSearchWorkflow } from '@/lib/workflows/search-replace/types' import type { SubBlockConfig } from '@/blocks/types' -import type { WorkflowState } from '@/stores/workflows/workflow/types' export const SEARCH_REPLACE_BLOCK_CONFIGS: Record = { agent: { @@ -47,9 +47,8 @@ export const SEARCH_REPLACE_BLOCK_CONFIGS: Record { + value: unknown +} + +export interface WorkflowSearchBlockState extends Omit { + subBlocks: Record +} + +export interface WorkflowSearchWorkflow { + blocks: Record +} + export interface WorkflowSearchIndexerOptions { - workflow: Pick + workflow: WorkflowSearchWorkflow query?: string mode?: WorkflowSearchMode caseSensitive?: boolean @@ -72,7 +84,7 @@ export interface WorkflowSearchIndexerOptions { } export interface IndexedSubBlockContext { - block: BlockState + block: WorkflowSearchBlockState blockConfig?: { subBlocks?: SubBlockConfig[] } subBlockConfig?: SubBlockConfig subBlockId: string diff --git a/apps/sim/stores/panel/editor/store.ts b/apps/sim/stores/panel/editor/store.ts index a72fdfe2a93..d76019befea 100644 --- a/apps/sim/stores/panel/editor/store.ts +++ b/apps/sim/stores/panel/editor/store.ts @@ -7,7 +7,7 @@ import { usePanelStore } from '../store' let renameCallback: (() => void) | null = null -interface ActiveSearchTarget { +export interface ActiveSearchTarget { matchId: string blockId: string subBlockId: string From 3ada406b16a8e8ec7f12c7af44b0aa86916c0c00 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 7 May 2026 18:25:32 -0700 Subject: [PATCH 04/11] fix loops/parallel badge case --- .../panel/components/editor/editor.tsx | 14 +- .../search-replace/replacement-controls.tsx | 4 +- .../workflow-search-replace.tsx | 94 +++++++++-- .../workflows/search-replace/indexer.test.ts | 49 +++++- .../lib/workflows/search-replace/indexer.ts | 126 ++++----------- .../search-replace/replacements.test.ts | 143 +++++++++++++++++ .../workflows/search-replace/replacements.ts | 127 +++++++++++++-- .../search-replace/subflow-fields.ts | 150 ++++++++++++++++++ .../sim/lib/workflows/search-replace/types.ts | 22 +++ .../workflows/search-replace/value-walker.ts | 5 +- 10 files changed, 602 insertions(+), 132 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index 6e56f9e68d0..cb9b63761c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -167,11 +167,23 @@ export function Editor() { [subBlocksForCanonical] ) const canonicalModeOverrides = currentBlock?.data?.canonicalModes + const activeSearchTargetNeedsAdvanced = useMemo(() => { + if (!activeSearchTarget || activeSearchTarget.blockId !== currentBlockId) return false + + return subBlocksForCanonical.some( + (subBlock) => + subBlock.mode === 'advanced' && + (activeSearchTarget.subBlockId === subBlock.id || + activeSearchTarget.canonicalSubBlockId === (subBlock.canonicalParamId ?? subBlock.id)) + ) + }, [activeSearchTarget, currentBlockId, subBlocksForCanonical]) const advancedValuesPresent = useMemo( () => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex), [subBlocksForCanonical, blockSubBlockValues, canonicalIndex] ) - const displayAdvancedOptions = canEditBlock ? advancedMode : advancedMode || advancedValuesPresent + const displayAdvancedOptions = canEditBlock + ? advancedMode || activeSearchTargetNeedsAdvanced + : advancedMode || advancedValuesPresent || activeSearchTargetNeedsAdvanced const hasAdvancedOnlyFields = useMemo(() => { for (const subBlock of subBlocksForCanonical) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls.tsx index ffbed9d3c74..de1869f1d20 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/replacement-controls.tsx @@ -41,7 +41,7 @@ export function ReplacementControls({ }))} value={replacement} onChange={onReplacementChange} - placeholder='Choose replacement resource...' + placeholder='Choose replacement...' searchable searchPlaceholder='Search resources...' emptyMessage='No valid replacements available' @@ -58,7 +58,7 @@ export function ReplacementControls({
- + {eligibleCount} replaceable match{eligibleCount === 1 ? '' : 'es'}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx index 1373d8b2119..3a224cb133b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ChevronDown, ChevronRight, ChevronUp, Search, X } from 'lucide-react' +import { ChevronDown, ChevronRight, ChevronUp, X } from 'lucide-react' import { useParams } from 'next/navigation' import { Button, Input } from '@/components/emcn' import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies' @@ -17,6 +17,8 @@ import { workflowSearchMatchMatchesQuery, } from '@/lib/workflows/search-replace/resource-resolvers' import { getWorkflowSearchBlocks } from '@/lib/workflows/search-replace/state' +import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields' +import type { WorkflowSearchReplaceSubflowUpdate } from '@/lib/workflows/search-replace/types' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils' @@ -29,6 +31,9 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' import { getBlock } from '@/blocks' +import { useFolderMap } from '@/hooks/queries/folders' +import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useNotificationStore } from '@/stores/notifications/store' import { usePanelEditorStore } from '@/stores/panel' @@ -37,8 +42,8 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' const SEARCH_PANEL_WIDTH = 360 -const SEARCH_PANEL_COLLAPSED_HEIGHT = 104 -const SEARCH_PANEL_EXPANDED_HEIGHT = 190 +const SEARCH_PANEL_COLLAPSED_HEIGHT = 82 +const SEARCH_PANEL_EXPANDED_HEIGHT = 156 function getDefaultSearchPanelPosition() { if (typeof window === 'undefined') return { x: 100, y: 100 } @@ -83,9 +88,23 @@ export function WorkflowSearchReplace() { const workflowSubblockValues = useSubBlockStore((state) => workflowId ? state.workflowValues[workflowId] : undefined ) + const { data: workflows = {} } = useWorkflowMap(workspaceId) + const { data: folders = {} } = useFolderMap(workspaceId) + const workflowMetadata = workflowId ? workflows[workflowId] : undefined + const workflowLocked = isWorkflowEffectivelyLocked(workflowMetadata, folders) + const searchReadOnly = currentWorkflow.isSnapshotView || workflowLocked + const readonlyReason = currentWorkflow.isSnapshotView + ? 'Snapshot view is readonly' + : workflowLocked + ? 'Workflow is locked' + : undefined const userPermissions = useUserPermissionsContext() const addNotification = useNotificationStore((state) => state.addNotification) - const { collaborativeBatchSetSubblockValues } = useCollaborativeWorkflow() + const { + collaborativeBatchSetSubblockValues, + collaborativeUpdateIterationCollection, + collaborativeUpdateIterationCount, + } = useCollaborativeWorkflow() const searchInputRef = useRef(null) const [isApplying, setIsApplying] = useState(false) const [isReplaceExpanded, setIsReplaceExpanded] = useState(false) @@ -135,10 +154,20 @@ export function WorkflowSearchReplace() { mode: 'all', includeResourceMatchesWithoutQuery: true, isSnapshotView: currentWorkflow.isSnapshotView, + isReadOnly: searchReadOnly, + readonlyReason, workspaceId, workflowId, }), - [currentWorkflow.isSnapshotView, query, searchBlocks, workspaceId, workflowId] + [ + currentWorkflow.isSnapshotView, + query, + readonlyReason, + searchBlocks, + searchReadOnly, + workspaceId, + workflowId, + ] ) const allHydratedMatches = useWorkflowSearchReferenceHydration({ @@ -158,7 +187,10 @@ export function WorkflowSearchReplace() { ) useEffect(() => { - if (!isOpen) return + if (!isOpen) { + usePanelEditorStore.getState().setActiveSearchTarget(null) + return + } searchInputRef.current?.focus() searchInputRef.current?.select() }, [isOpen]) @@ -212,8 +244,17 @@ export function WorkflowSearchReplace() { if (isConstrainedResourceMatch(activeMatch)) { return getWorkflowSearchCompatibleResourceMatches(activeMatch, hydratedMatches) } + if (activeMatch.kind === 'workflow-reference') { + return hydratedMatches.filter( + (match) => match.kind === 'workflow-reference' && match.editable + ) + } + + if (activeMatch.kind === 'text') { + return hydratedMatches.filter((match) => match.kind === 'text' && match.editable) + } - return hydratedMatches.filter((match) => match.kind === 'text' && match.editable) + return [] }, [activeMatch, hydratedMatches]) const eligibleMatchIds = useMemo( () => replaceAllTargetMatches.map((match) => match.id), @@ -241,6 +282,24 @@ export function WorkflowSearchReplace() { resourceOptions, }) : 'No replaceable matches.' + + const applySubflowUpdate = useCallback( + (update: WorkflowSearchReplaceSubflowUpdate) => { + if (update.fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations) { + if (typeof update.nextValue !== 'number') return + collaborativeUpdateIterationCount(update.blockId, update.blockType, update.nextValue) + return + } + + collaborativeUpdateIterationCollection( + update.blockId, + update.blockType, + String(update.nextValue) + ) + }, + [collaborativeUpdateIterationCollection, collaborativeUpdateIterationCount] + ) + useEffect(() => { if (!isOpen) return @@ -265,7 +324,7 @@ export function WorkflowSearchReplace() { } const handleApply = (matchIds: string[]) => { - if (!workflowId || isApplying) return + if (!workflowId || isApplying || searchReadOnly) return setIsApplying(true) try { @@ -326,7 +385,7 @@ export function WorkflowSearchReplace() { } } - if (batchUpdates.length === 0) { + if (batchUpdates.length === 0 && plan.subflowUpdates.length === 0) { addNotification({ level: 'info', message: 'No eligible matches to replace.', @@ -335,7 +394,8 @@ export function WorkflowSearchReplace() { return } - const applied = collaborativeBatchSetSubblockValues(batchUpdates) + const applied = + batchUpdates.length === 0 ? true : collaborativeBatchSetSubblockValues(batchUpdates) if (!applied) { addNotification({ level: 'error', @@ -345,9 +405,14 @@ export function WorkflowSearchReplace() { return } + for (const update of plan.subflowUpdates) { + applySubflowUpdate(update) + } + + const replacedCount = plan.updates.length + plan.subflowUpdates.length addNotification({ level: 'info', - message: `Replaced ${plan.updates.length} field${plan.updates.length === 1 ? '' : 's'}.`, + message: `Replaced ${replacedCount} field${replacedCount === 1 ? '' : 's'}.`, workflowId, }) } finally { @@ -382,8 +447,7 @@ export function WorkflowSearchReplace() { className='flex h-[32px] flex-shrink-0 cursor-grab items-center justify-between gap-2.5 bg-[var(--surface-1)] p-0 active:cursor-grabbing' onMouseDown={handleMouseDown} > -
- +
Search and replace @@ -392,7 +456,7 @@ export function WorkflowSearchReplace() { className='flex shrink-0 items-center gap-2' onMouseDown={(event) => event.stopPropagation()} > - {matchCountLabel} + {matchCountLabel} @@ -445,7 +509,7 @@ export function WorkflowSearchReplace() { compatibleResourceOptions={compatibleResourceOptions} usesResourceReplacement={usesResourceReplacement} eligibleCount={eligibleMatchIds.length} - disabled={!userPermissions.canEdit || currentWorkflow.isSnapshotView} + disabled={!userPermissions.canEdit || searchReadOnly} isApplying={isApplying} canReplaceActive={Boolean( activeMatch?.editable && hasReplacement && !activeReplacementIssue diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index 957da81b20c..6229feec4c7 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -44,6 +44,27 @@ describe('indexWorkflowSearchMatches', () => { expect(matches).toEqual([]) }) + it('indexes non-string scalar values as searchable but not editable', () => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['api-1'].subBlocks.body.value = { count: 2, enabled: true } + + const matches = indexWorkflowSearchMatches({ + workflow, + query: '2', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }).filter((match) => match.blockId === 'api-1') + + expect(matches).toEqual([ + expect.objectContaining({ + valuePath: ['count'], + rawValue: '2', + editable: false, + reason: 'Only text values can be replaced', + }), + ]) + }) + it('indexes loop and parallel editor settings for navigation', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['parallel-1'] = { @@ -93,7 +114,11 @@ describe('indexWorkflowSearchMatches', () => { subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations, canonicalSubBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations, fieldTitle: 'Parallel Iterations', - editable: false, + editable: true, + target: { + kind: 'subflow', + fieldId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations, + }, }), ]) ) @@ -104,7 +129,11 @@ describe('indexWorkflowSearchMatches', () => { subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, canonicalSubBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, fieldTitle: 'Collection Items', - editable: false, + editable: true, + target: { + kind: 'subflow', + fieldId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, + }, }), ]) ) @@ -235,4 +264,20 @@ describe('indexWorkflowSearchMatches', () => { expect(matches.every((match) => !match.editable)).toBe(true) expect(matches.every((match) => match.reason === 'Snapshot view is readonly')).toBe(true) }) + + it('marks readonly workflow matches as searchable but not editable', () => { + const workflow = createSearchReplaceWorkflowFixture() + + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'email', + mode: 'text', + isReadOnly: true, + readonlyReason: 'Workflow is locked', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect(matches.every((match) => !match.editable)).toBe(true) + expect(matches.every((match) => match.reason === 'Workflow is locked')).toBe(true) + }) }) diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 8da158678c2..183a1718488 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -4,10 +4,7 @@ import { parseInlineReferences, parseStructuredResourceReferences, } from '@/lib/workflows/search-replace/reference-registry' -import { - WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS, - type WorkflowSearchSubflowFieldId, -} from '@/lib/workflows/search-replace/subflow-fields' +import { getWorkflowSearchSubflowFields } from '@/lib/workflows/search-replace/subflow-fields' import type { WorkflowSearchBlockState, WorkflowSearchIndexerOptions, @@ -78,13 +75,6 @@ function getSearchableStringLeaves(value: unknown) { return walkStringValues(value).filter((leaf) => isSearchableLeafPath(leaf.path)) } -interface SubflowSearchField { - subBlockId: WorkflowSearchSubflowFieldId - title: string - type: SubBlockType - value: string -} - interface AddTextMatchesOptions { matches: WorkflowSearchMatch[] idPrefix: string @@ -95,6 +85,7 @@ interface AddTextMatchesOptions { fieldTitle?: string value: string valuePath: WorkflowSearchValuePath + target: WorkflowSearchMatch['target'] query?: string caseSensitive: boolean editable: boolean @@ -126,6 +117,7 @@ function addTextMatches({ fieldTitle, value, valuePath, + target, query, caseSensitive, editable, @@ -152,6 +144,7 @@ function addTextMatches({ subBlockType, fieldTitle, valuePath, + target, kind: 'text', rawValue: value.slice(range.start, range.end), searchText: value, @@ -164,75 +157,6 @@ function addTextMatches({ }) } -function getSubflowSearchFields(block: WorkflowSearchBlockState): SubflowSearchField[] { - if (block.type === 'loop') { - const loopType = block.data?.loopType ?? 'for' - const fields: SubflowSearchField[] = [ - { - subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.type, - title: 'Loop Type', - type: 'combobox', - value: loopType, - }, - ] - - if (loopType === 'for') { - fields.push({ - subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations, - title: 'Loop Iterations', - type: 'short-input', - value: String(block.data?.count ?? 5), - }) - } else if (loopType === 'forEach') { - fields.push({ - subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, - title: 'Collection Items', - type: 'code', - value: String(block.data?.collection ?? ''), - }) - } else { - fields.push({ - subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.condition, - title: 'While Condition', - type: 'code', - value: String( - loopType === 'doWhile' - ? (block.data?.doWhileCondition ?? '') - : (block.data?.whileCondition ?? '') - ), - }) - } - - return fields - } - - if (block.type === 'parallel') { - const parallelType = block.data?.parallelType ?? 'count' - return [ - { - subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.type, - title: 'Parallel Type', - type: 'combobox', - value: parallelType, - }, - { - subBlockId: - parallelType === 'count' - ? WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations - : WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, - title: parallelType === 'count' ? 'Parallel Iterations' : 'Parallel Items', - type: parallelType === 'count' ? 'short-input' : 'code', - value: - parallelType === 'count' - ? String(block.data?.count ?? 5) - : String(block.data?.collection ?? ''), - }, - ] - } - - return [] -} - function buildSearchSelectorContext({ block, subBlockConfigs, @@ -274,6 +198,8 @@ export function indexWorkflowSearchMatches( caseSensitive = false, includeResourceMatchesWithoutQuery = false, isSnapshotView = false, + isReadOnly = isSnapshotView, + readonlyReason, workspaceId, workflowId, blockConfigs = {}, @@ -294,28 +220,28 @@ export function indexWorkflowSearchMatches( workflowId, }) const protectedByLock = Boolean(block.locked || hasLockedAncestor(block, workflow.blocks)) - const editable = !protectedByLock && !isSnapshotView + const editable = !protectedByLock && !isReadOnly if (mode !== 'resource') { - for (const field of getSubflowSearchFields(block)) { + for (const field of getWorkflowSearchSubflowFields(block)) { + const fieldEditable = editable && field.editable addTextMatches({ matches, idPrefix: 'subflow-text', block, - subBlockId: field.subBlockId, - canonicalSubBlockId: field.subBlockId, + subBlockId: field.id, + canonicalSubBlockId: field.id, subBlockType: field.type, fieldTitle: field.title, value: field.value, valuePath: [], + target: { kind: 'subflow', fieldId: field.id }, query, caseSensitive, - editable: false, + editable: fieldEditable, protectedByLock, isSnapshotView, - readonlyReason: editable - ? 'Subflow settings are edited from the block sidebar' - : undefined, + readonlyReason: fieldEditable ? undefined : !editable ? readonlyReason : field.reason, }) } } @@ -331,6 +257,7 @@ export function indexWorkflowSearchMatches( if (mode !== 'resource') { for (const leaf of stringLeaves) { + const leafEditable = editable && typeof leaf.originalValue === 'string' addTextMatches({ matches, idPrefix: 'text', @@ -341,11 +268,17 @@ export function indexWorkflowSearchMatches( fieldTitle: subBlockConfig?.title, value: leaf.value, valuePath: leaf.path, + target: { kind: 'subblock' }, query, caseSensitive, - editable, + editable: leafEditable, protectedByLock, isSnapshotView, + readonlyReason: leafEditable + ? undefined + : typeof leaf.originalValue === 'string' + ? readonlyReason + : 'Only text values can be replaced', }) } } @@ -380,6 +313,7 @@ export function indexWorkflowSearchMatches( subBlockType: subBlockConfig?.type ?? subBlockState.type, fieldTitle: subBlockConfig?.title, valuePath: leaf.path, + target: { kind: 'subblock' }, kind: reference.kind, rawValue: reference.rawValue, searchText: reference.searchText, @@ -388,11 +322,7 @@ export function indexWorkflowSearchMatches( editable, navigable: true, protected: protectedByLock, - reason: editable - ? undefined - : isSnapshotView - ? 'Snapshot view is readonly' - : 'Block is locked', + reason: getReadonlyReason({ editable, isSnapshotView, readonlyReason }), }) }) } @@ -427,18 +357,16 @@ export function indexWorkflowSearchMatches( subBlockType: subBlockConfig?.type ?? subBlockState.type, fieldTitle: subBlockConfig?.title, valuePath: [], + target: { kind: 'subblock' }, kind: reference.kind, rawValue: reference.rawValue, searchText: reference.searchText, + structuredOccurrenceIndex: referenceIndex, resource: reference.resource, editable, navigable: true, protected: protectedByLock, - reason: editable - ? undefined - : isSnapshotView - ? 'Snapshot view is readonly' - : 'Block is locked', + reason: getReadonlyReason({ editable, isSnapshotView, readonlyReason }), }) }) } diff --git a/apps/sim/lib/workflows/search-replace/replacements.test.ts b/apps/sim/lib/workflows/search-replace/replacements.test.ts index 6e840c6cfd9..3c71d6308ce 100644 --- a/apps/sim/lib/workflows/search-replace/replacements.test.ts +++ b/apps/sim/lib/workflows/search-replace/replacements.test.ts @@ -8,6 +8,7 @@ import { createSearchReplaceWorkflowFixture, SEARCH_REPLACE_BLOCK_CONFIGS, } from '@/lib/workflows/search-replace/search-replace.fixtures' +import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields' describe('buildWorkflowSearchReplacePlan', () => { it('replaces selected text ranges across blocks without touching unselected matches', () => { @@ -98,6 +99,32 @@ describe('buildWorkflowSearchReplacePlan', () => { expect(plan.updates[0].nextValue).toBe('kb-new,kb-second') }) + it('replaces only the selected duplicate structured resource occurrence', () => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,kb-old,kb-second' + + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'kb-old', + mode: 'resource', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }).filter((match) => match.kind === 'knowledge-base') + + const plan = buildWorkflowSearchReplacePlan({ + blocks: workflow.blocks, + matches, + selectedMatchIds: new Set([matches[0].id]), + defaultReplacement: 'kb-new', + resourceReplacementOptions: [ + { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' }, + ], + }) + + expect(plan.conflicts).toEqual([]) + expect(plan.updates).toHaveLength(1) + expect(plan.updates[0].nextValue).toBe('kb-new,kb-old,kb-second') + }) + it('replaces all compatible knowledge base references across blocks', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['knowledge-2'] = { @@ -140,6 +167,122 @@ describe('buildWorkflowSearchReplacePlan', () => { ) }) + it('replaces loop and parallel subflow editor values', () => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['parallel-1'] = { + id: 'parallel-1', + type: 'parallel', + name: 'Parallel 1', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: {}, + data: { + parallelType: 'count', + count: 20, + }, + } + workflow.blocks['loop-1'] = { + id: 'loop-1', + type: 'loop', + name: 'Loop 1', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: {}, + data: { + loopType: 'forEach', + collection: "['item-2']", + }, + } + + const countMatches = indexWorkflowSearchMatches({ + workflow, + query: '20', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }).filter((match) => match.target.kind === 'subflow') + const collectionMatches = indexWorkflowSearchMatches({ + workflow, + query: 'item-2', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }).filter((match) => match.target.kind === 'subflow') + + const countPlan = buildWorkflowSearchReplacePlan({ + blocks: workflow.blocks, + matches: countMatches, + selectedMatchIds: new Set(countMatches.map((match) => match.id)), + defaultReplacement: '3', + }) + const collectionPlan = buildWorkflowSearchReplacePlan({ + blocks: workflow.blocks, + matches: collectionMatches, + selectedMatchIds: new Set(collectionMatches.map((match) => match.id)), + defaultReplacement: 'item-3', + }) + + expect(countPlan.conflicts).toEqual([]) + expect(countPlan.subflowUpdates).toEqual([ + expect.objectContaining({ + blockId: 'parallel-1', + blockType: 'parallel', + fieldId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations, + previousValue: '20', + nextValue: 3, + }), + ]) + expect(collectionPlan.conflicts).toEqual([]) + expect(collectionPlan.subflowUpdates).toEqual([ + expect.objectContaining({ + blockId: 'loop-1', + blockType: 'loop', + fieldId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, + previousValue: "['item-2']", + nextValue: "['item-3']", + }), + ]) + }) + + it('rejects invalid subflow iteration replacements', () => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['parallel-1'] = { + id: 'parallel-1', + type: 'parallel', + name: 'Parallel 1', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: {}, + data: { + parallelType: 'count', + count: 2, + }, + } + + const matches = indexWorkflowSearchMatches({ + workflow, + query: '2', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }).filter((match) => match.target.kind === 'subflow') + + const plan = buildWorkflowSearchReplacePlan({ + blocks: workflow.blocks, + matches, + selectedMatchIds: new Set(matches.map((match) => match.id)), + defaultReplacement: '25', + }) + + expect(plan.subflowUpdates).toEqual([]) + expect(plan.conflicts).toEqual([ + { + matchId: matches[0].id, + reason: 'Subflow iteration count must be between 1 and 20', + }, + ]) + }) + it('rejects structured resource replacements that are not resolvable options', () => { const workflow = createSearchReplaceWorkflowFixture() const matches = indexWorkflowSearchMatches({ diff --git a/apps/sim/lib/workflows/search-replace/replacements.ts b/apps/sim/lib/workflows/search-replace/replacements.ts index 1c9ada3f98c..9f1dc74c8c5 100644 --- a/apps/sim/lib/workflows/search-replace/replacements.ts +++ b/apps/sim/lib/workflows/search-replace/replacements.ts @@ -1,9 +1,14 @@ import { getWorkflowSearchReplacementIssue } from '@/lib/workflows/search-replace/replacement-validation' +import { + getWorkflowSearchSubflowField, + parseWorkflowSearchSubflowReplacement, +} from '@/lib/workflows/search-replace/subflow-fields' import type { WorkflowSearchBlockState, WorkflowSearchMatch, WorkflowSearchReplacementOption, WorkflowSearchReplacePlan, + WorkflowSearchReplaceSubflowUpdate, WorkflowSearchReplaceUpdate, } from '@/lib/workflows/search-replace/types' import { @@ -34,21 +39,39 @@ function replaceRange(value: string, start: number, end: number, replacement: st return `${value.slice(0, start)}${replacement}${value.slice(end)}` } -function replaceStructuredValue(value: unknown, rawValue: string, replacement: string): unknown { +function replaceStructuredValue( + value: unknown, + rawValue: string, + replacement: string, + targetOccurrenceIndex?: number +): unknown { + let occurrenceIndex = 0 + + const shouldReplace = (item: string) => { + if (item !== rawValue) return false + const currentOccurrenceIndex = occurrenceIndex + occurrenceIndex += 1 + return targetOccurrenceIndex === undefined || currentOccurrenceIndex === targetOccurrenceIndex + } + if (typeof value === 'string') { const parts = value.split(',').map((part) => part.trim()) if (parts.length > 1) { - return parts.map((part) => (part === rawValue ? replacement : part)).join(',') + return parts.map((part) => (shouldReplace(part) ? replacement : part)).join(',') } - return value === rawValue ? replacement : value + return shouldReplace(value) ? replacement : value } if (Array.isArray(value)) { - return value.map((item) => - typeof item === 'string' && item === rawValue - ? replacement - : replaceStructuredValue(item, rawValue, replacement) - ) + const replaceItem = (item: unknown): unknown => { + if (typeof item === 'string') { + return shouldReplace(item) ? replacement : item + } + if (Array.isArray(item)) return item.map(replaceItem) + return item + } + + return value.map(replaceItem) } return value @@ -88,6 +111,7 @@ export function buildWorkflowSearchReplacePlan({ const skipped: WorkflowSearchReplacePlan['skipped'] = [] const conflicts: WorkflowSearchReplacePlan['conflicts'] = [] const updatesByField = new Map() + const subflowUpdatesByField = new Map() const selectedMatches = matches.filter((match) => selectedMatchIds.has(match.id)) const orderedMatches = [...selectedMatches].sort((a, b) => { @@ -97,6 +121,9 @@ export function buildWorkflowSearchReplacePlan({ if (subBlockCompare !== 0) return subBlockCompare const pathCompare = pathToKey(a.valuePath).localeCompare(pathToKey(b.valuePath)) if (pathCompare !== 0) return pathCompare + const occurrenceCompare = + (b.structuredOccurrenceIndex ?? 0) - (a.structuredOccurrenceIndex ?? 0) + if (occurrenceCompare !== 0) return occurrenceCompare return (b.range?.start ?? 0) - (a.range?.start ?? 0) }) @@ -123,8 +150,76 @@ export function buildWorkflowSearchReplacePlan({ } const block = blocks[match.blockId] + if (!block) { + conflicts.push({ matchId: match.id, reason: 'Block no longer exists' }) + continue + } + + if (match.target.kind === 'subflow') { + if (block.type !== 'loop' && block.type !== 'parallel') { + conflicts.push({ matchId: match.id, reason: 'Subflow block no longer exists' }) + continue + } + + const currentField = getWorkflowSearchSubflowField(block, match.target.fieldId) + if (!currentField) { + conflicts.push({ matchId: match.id, reason: 'Subflow field is no longer available' }) + continue + } + + if (!currentField.editable) { + conflicts.push({ + matchId: match.id, + reason: currentField.reason ?? 'Subflow field is not editable', + }) + continue + } + + const updateKey = `${match.blockId}:${match.target.fieldId}` + const existingUpdate = subflowUpdatesByField.get(updateKey) + const previousValue = existingUpdate?.previousValue ?? currentField.value + const currentValue = String(existingUpdate?.nextValue ?? currentField.value) + + if (!match.range) { + conflicts.push({ matchId: match.id, reason: 'Subflow target is no longer text' }) + continue + } + + const currentRawValue = currentValue.slice(match.range.start, match.range.end) + if (currentRawValue !== match.rawValue) { + conflicts.push({ matchId: match.id, reason: 'Subflow target changed since search' }) + continue + } + + const nextTextValue = replaceRange( + currentValue, + match.range.start, + match.range.end, + replacement + ) + const parsedReplacement = parseWorkflowSearchSubflowReplacement({ + blockType: block.type, + fieldId: match.target.fieldId, + replacement: nextTextValue, + }) + if (!parsedReplacement.success) { + conflicts.push({ matchId: match.id, reason: parsedReplacement.reason }) + continue + } + + subflowUpdatesByField.set(updateKey, { + blockId: match.blockId, + blockType: block.type, + fieldId: match.target.fieldId, + previousValue, + nextValue: parsedReplacement.value, + matchIds: [...(existingUpdate?.matchIds ?? []), match.id], + }) + continue + } + const subBlock = block?.subBlocks?.[match.subBlockId] - if (!block || !subBlock) { + if (!subBlock) { conflicts.push({ matchId: match.id, reason: 'Block or subblock no longer exists' }) continue } @@ -160,7 +255,12 @@ export function buildWorkflowSearchReplacePlan({ continue } - const replacedValue = replaceStructuredValue(valueForReplacement, match.rawValue, replacement) + const replacedValue = replaceStructuredValue( + valueForReplacement, + match.rawValue, + replacement, + match.structuredOccurrenceIndex + ) nextValue = match.valuePath.length === 0 ? replacedValue @@ -177,13 +277,18 @@ export function buildWorkflowSearchReplacePlan({ } if (conflicts.length > 0) { - return { updates: [], skipped, conflicts } + return { updates: [], subflowUpdates: [], skipped, conflicts } } return { updates: [...updatesByField.values()].filter( (update) => update.previousValue !== update.nextValue ), + subflowUpdates: [...subflowUpdatesByField.values()].filter((update) => { + if (typeof update.nextValue === 'number') + return String(update.nextValue) !== update.previousValue + return update.nextValue !== update.previousValue + }), skipped, conflicts, } diff --git a/apps/sim/lib/workflows/search-replace/subflow-fields.ts b/apps/sim/lib/workflows/search-replace/subflow-fields.ts index fcd6e9c281f..be31095981a 100644 --- a/apps/sim/lib/workflows/search-replace/subflow-fields.ts +++ b/apps/sim/lib/workflows/search-replace/subflow-fields.ts @@ -1,3 +1,5 @@ +import type { SubBlockType } from '@sim/workflow-types/blocks' + export const WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS = { type: 'subflowType', iterations: 'subflowIterations', @@ -7,3 +9,151 @@ export const WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS = { export type WorkflowSearchSubflowFieldId = (typeof WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS)[keyof typeof WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS] + +export type WorkflowSearchSubflowEditableValue = string | number + +interface WorkflowSearchSubflowBlock { + type: string + data?: { + loopType?: string + parallelType?: string + count?: unknown + collection?: unknown + whileCondition?: unknown + doWhileCondition?: unknown + } +} + +export interface WorkflowSearchSubflowField { + id: WorkflowSearchSubflowFieldId + title: string + type: SubBlockType + value: string + editable: boolean + valueKind: 'number' | 'text' | 'enum' + reason?: string +} + +export function getWorkflowSearchSubflowFields( + block: WorkflowSearchSubflowBlock +): WorkflowSearchSubflowField[] { + if (block.type === 'loop') { + const loopType = block.data?.loopType ?? 'for' + const fields: WorkflowSearchSubflowField[] = [ + { + id: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.type, + title: 'Loop Type', + type: 'combobox', + value: String(loopType), + editable: false, + valueKind: 'enum', + reason: 'Subflow type is changed from the block sidebar', + }, + ] + + if (loopType === 'for') { + fields.push({ + id: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations, + title: 'Loop Iterations', + type: 'short-input', + value: String(block.data?.count ?? 5), + editable: true, + valueKind: 'number', + }) + } else if (loopType === 'forEach') { + fields.push({ + id: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, + title: 'Collection Items', + type: 'code', + value: String(block.data?.collection ?? ''), + editable: true, + valueKind: 'text', + }) + } else { + fields.push({ + id: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.condition, + title: 'While Condition', + type: 'code', + value: String( + loopType === 'doWhile' + ? (block.data?.doWhileCondition ?? '') + : (block.data?.whileCondition ?? '') + ), + editable: true, + valueKind: 'text', + }) + } + + return fields + } + + if (block.type === 'parallel') { + const parallelType = block.data?.parallelType ?? 'count' + return [ + { + id: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.type, + title: 'Parallel Type', + type: 'combobox', + value: String(parallelType), + editable: false, + valueKind: 'enum', + reason: 'Subflow type is changed from the block sidebar', + }, + { + id: + parallelType === 'count' + ? WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations + : WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, + title: parallelType === 'count' ? 'Parallel Iterations' : 'Parallel Items', + type: parallelType === 'count' ? 'short-input' : 'code', + value: + parallelType === 'count' + ? String(block.data?.count ?? 5) + : String(block.data?.collection ?? ''), + editable: true, + valueKind: parallelType === 'count' ? 'number' : 'text', + }, + ] + } + + return [] +} + +export function getWorkflowSearchSubflowField( + block: WorkflowSearchSubflowBlock, + fieldId: WorkflowSearchSubflowFieldId +) { + return getWorkflowSearchSubflowFields(block).find((field) => field.id === fieldId) +} + +export function parseWorkflowSearchSubflowReplacement({ + blockType, + fieldId, + replacement, +}: { + blockType: 'loop' | 'parallel' + fieldId: WorkflowSearchSubflowFieldId + replacement: string +}): + | { success: true; value: WorkflowSearchSubflowEditableValue } + | { success: false; reason: string } { + if (fieldId !== WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations) { + return { success: true, value: replacement } + } + + const trimmed = replacement.trim() + if (!/^\d+$/.test(trimmed)) { + return { success: false, reason: 'Subflow iteration count must be a whole number' } + } + + const count = Number.parseInt(trimmed, 10) + const max = blockType === 'parallel' ? 20 : 1000 + if (count < 1 || count > max) { + return { + success: false, + reason: `Subflow iteration count must be between 1 and ${max}`, + } + } + + return { success: true, value: count } +} diff --git a/apps/sim/lib/workflows/search-replace/types.ts b/apps/sim/lib/workflows/search-replace/types.ts index 128f29b190f..8b1c509c78d 100644 --- a/apps/sim/lib/workflows/search-replace/types.ts +++ b/apps/sim/lib/workflows/search-replace/types.ts @@ -1,4 +1,8 @@ import type { SubBlockType } from '@sim/workflow-types/blocks' +import type { + WorkflowSearchSubflowEditableValue, + WorkflowSearchSubflowFieldId, +} from '@/lib/workflows/search-replace/subflow-fields' import type { SubBlockConfig } from '@/blocks/types' import type { SelectorContext } from '@/hooks/selectors/types' import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types' @@ -38,6 +42,10 @@ export interface WorkflowSearchResourceMeta { key?: string } +export type WorkflowSearchTarget = + | { kind: 'subblock' } + | { kind: 'subflow'; fieldId: WorkflowSearchSubflowFieldId } + export interface WorkflowSearchMatch { id: string blockId: string @@ -48,10 +56,12 @@ export interface WorkflowSearchMatch { subBlockType: SubBlockType fieldTitle?: string valuePath: WorkflowSearchValuePath + target: WorkflowSearchTarget kind: WorkflowSearchMatchKind rawValue: string searchText: string range?: WorkflowSearchRange + structuredOccurrenceIndex?: number resource?: WorkflowSearchResourceMeta editable: boolean navigable: boolean @@ -78,6 +88,8 @@ export interface WorkflowSearchIndexerOptions { caseSensitive?: boolean includeResourceMatchesWithoutQuery?: boolean isSnapshotView?: boolean + isReadOnly?: boolean + readonlyReason?: string workspaceId?: string workflowId?: string blockConfigs?: Record @@ -117,6 +129,15 @@ export interface WorkflowSearchReplaceUpdate { matchIds: string[] } +export interface WorkflowSearchReplaceSubflowUpdate { + blockId: string + blockType: 'loop' | 'parallel' + fieldId: WorkflowSearchSubflowFieldId + previousValue: string + nextValue: WorkflowSearchSubflowEditableValue + matchIds: string[] +} + export interface WorkflowSearchReplaceSkipped { matchId: string reason: string @@ -124,6 +145,7 @@ export interface WorkflowSearchReplaceSkipped { export interface WorkflowSearchReplacePlan { updates: WorkflowSearchReplaceUpdate[] + subflowUpdates: WorkflowSearchReplaceSubflowUpdate[] skipped: WorkflowSearchReplaceSkipped[] conflicts: WorkflowSearchReplaceSkipped[] } diff --git a/apps/sim/lib/workflows/search-replace/value-walker.ts b/apps/sim/lib/workflows/search-replace/value-walker.ts index d8466de65e7..3f9070fbfd6 100644 --- a/apps/sim/lib/workflows/search-replace/value-walker.ts +++ b/apps/sim/lib/workflows/search-replace/value-walker.ts @@ -3,6 +3,7 @@ import type { WorkflowSearchValuePath } from '@/lib/workflows/search-replace/typ export interface WalkedStringValue { path: WorkflowSearchValuePath value: string + originalValue: unknown } function isRecord(value: unknown): value is Record { @@ -14,11 +15,11 @@ export function walkStringValues( path: WorkflowSearchValuePath = [] ): WalkedStringValue[] { if (typeof value === 'string') { - return value.length > 0 ? [{ path, value }] : [] + return value.length > 0 ? [{ path, value, originalValue: value }] : [] } if (typeof value === 'number' || typeof value === 'boolean') { - return [{ path, value: String(value) }] + return [{ path, value: String(value), originalValue: value }] } if (Array.isArray(value)) { From aa864d52eae20374c9e7000ce856386a182ff2c8 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 7 May 2026 18:47:14 -0700 Subject: [PATCH 05/11] resource resolver --- .../components/deploy-modal/deploy-modal.tsx | 11 ++- .../panel/components/deploy/deploy.tsx | 22 ++---- .../workflows/search-replace/indexer.test.ts | 77 ++++++++++++++++++- .../lib/workflows/search-replace/indexer.ts | 7 +- .../search-replace/resource-resolvers.ts | 18 ++--- 5 files changed, 98 insertions(+), 37 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 1103e3d7fe6..8e0b53a195f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -1050,13 +1050,12 @@ export function DeployModal({ interface StatusBadgeProps { isWarning: boolean - isSyncing?: boolean } -function StatusBadge({ isWarning, isSyncing = false }: StatusBadgeProps) { - const label = isSyncing ? 'Syncing changes' : isWarning ? 'Update deployment' : 'Live' +function StatusBadge({ isWarning }: StatusBadgeProps) { + const label = isWarning ? 'Update deployment' : 'Live' return ( - + {label} ) @@ -1111,7 +1110,7 @@ function GeneralFooter({ const isDeployBlocked = deployReadiness.isBlocked || isDeploymentSettling || isSubmitting || isUndeploying const blockedMessage = - deployReadiness.isBlocked && !isDeploymentSettling && !isSubmitting && !isUndeploying + deployReadiness.isBlocked && !deployReadiness.isSyncing && !isSubmitting && !isUndeploying ? deployReadiness.tooltip : null const deployActionLoading = isSubmitting || isDeploymentSettling @@ -1133,7 +1132,7 @@ function GeneralFooter({ return (
- + {blockedMessage && (
{blockedMessage}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index ec630e08781..cc5a65825f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Button, Loader, Tooltip } from '@/components/emcn' +import { Button, Tooltip } from '@/components/emcn' import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal' import { useChangeDetection, @@ -65,12 +65,16 @@ export function Deploy({ isDeploying || !canDeploy || isEmpty || - isDeploymentSettling || - (!isDeployed && deployReadiness.isBlocked) + (!isDeployed && deployReadiness.isBlocked && !deployReadiness.isSyncing) const onDeployClick = async () => { if (disabled || !canDeploy || !activeWorkflowId) return + if (isDeploymentSettling) { + setIsModalOpen(true) + return + } + const result = await handleDeployClick() if (result.shouldOpenModal) { setIsModalOpen(true) @@ -106,9 +110,6 @@ export function Deploy({ } const getButtonLabel = () => { - if (isDeployed && (changeDetected || isDeploymentSettling)) { - return 'Update' - } if (changeDetected) { return 'Update' } @@ -126,18 +127,11 @@ export function Deploy({ diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index 6229feec4c7..1a36acd8cdc 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -34,14 +34,30 @@ describe('indexWorkflowSearchMatches', () => { it('does not index internal row metadata in structured subblock values', () => { const workflow = createSearchReplaceWorkflowFixture() - const matches = indexWorkflowSearchMatches({ + const rowMatches = indexWorkflowSearchMatches({ workflow, query: 'row-1', mode: 'text', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }) + workflow.blocks['api-1'].subBlocks.body.value = { + filtersById: { + 'filter-1': { + id: 'filter-2', + collapsed: false, + value: '', + }, + }, + } + const objectMatches = indexWorkflowSearchMatches({ + workflow, + query: 'filter-2', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) - expect(matches).toEqual([]) + expect(rowMatches).toEqual([]) + expect(objectMatches).toEqual([]) }) it('indexes non-string scalar values as searchable but not editable', () => { @@ -210,6 +226,63 @@ describe('indexWorkflowSearchMatches', () => { ).toBe(true) }) + it('does not match opaque structured resource ids during display-label filtering', () => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-2-opaque-id' + + const matches = indexWorkflowSearchMatches({ + workflow, + query: '2', + mode: 'all', + includeResourceMatchesWithoutQuery: true, + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + const knowledgeMatch = matches.find( + (match) => match.kind === 'knowledge-base' && match.rawValue === 'kb-2-opaque-id' + ) + + expect(knowledgeMatch).toBeDefined() + expect( + workflowSearchMatchMatchesQuery({ ...knowledgeMatch!, displayLabel: 'Support Articles' }, '2') + ).toBe(false) + expect( + workflowSearchMatchMatchesQuery( + { ...knowledgeMatch!, displayLabel: 'Support Articles 2' }, + '2' + ) + ).toBe(true) + }) + + it('does not index structured resource ids as plain text matches', () => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-2-opaque-id' + + const matches = indexWorkflowSearchMatches({ + workflow, + query: '2', + mode: 'all', + includeResourceMatchesWithoutQuery: true, + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect( + matches.some( + (match) => + match.kind === 'text' && + match.blockId === 'knowledge-1' && + match.subBlockId === 'knowledgeBaseIds' + ) + ).toBe(false) + expect( + matches.some( + (match) => + match.kind === 'knowledge-base' && + match.blockId === 'knowledge-1' && + match.subBlockId === 'knowledgeBaseIds' + ) + ).toBe(true) + }) + it('captures selector context for selector-backed resources', () => { const workflow = createSearchReplaceWorkflowFixture() diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 183a1718488..2a3c4ef8acd 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -1,5 +1,6 @@ import type { SubBlockType } from '@sim/workflow-types/blocks' import { + getResourceKindForSubBlock, matchesSearchText, parseInlineReferences, parseStructuredResourceReferences, @@ -66,8 +67,7 @@ const STRUCTURED_METADATA_LEAF_KEYS = new Set(['id', 'collapsed']) function isSearchableLeafPath(path: Array): boolean { const lastSegment = path.at(-1) - const parentSegment = path.at(-2) - if (typeof parentSegment !== 'number' || typeof lastSegment !== 'string') return true + if (typeof lastSegment !== 'string') return true return !STRUCTURED_METADATA_LEAF_KEYS.has(lastSegment) } @@ -254,8 +254,9 @@ export function indexWorkflowSearchMatches( subBlockId const value = subBlockState?.value const stringLeaves = getSearchableStringLeaves(value) + const structuredResourceKind = getResourceKindForSubBlock(subBlockConfig) - if (mode !== 'resource') { + if (mode !== 'resource' && !structuredResourceKind) { for (const leaf of stringLeaves) { const leafEditable = editable && typeof leaf.originalValue === 'string' addTextMatches({ diff --git a/apps/sim/lib/workflows/search-replace/resource-resolvers.ts b/apps/sim/lib/workflows/search-replace/resource-resolvers.ts index 22b30dab34f..9b78b4d8aae 100644 --- a/apps/sim/lib/workflows/search-replace/resource-resolvers.ts +++ b/apps/sim/lib/workflows/search-replace/resource-resolvers.ts @@ -98,17 +98,11 @@ export function workflowSearchMatchMatchesQuery( if (match.kind === 'text') return true const normalize = (value: string) => (caseSensitive ? value : value.toLowerCase()) - const searchable = [ - match.displayLabel, - match.rawValue, - match.searchText, - match.fieldTitle, - match.blockName, - match.resource?.kind, - match.resource?.selectorKey, - ] - .filter(Boolean) - .join(' ') + const searchable = + match.resource?.kind === 'workflow-reference' || match.resource?.kind === 'environment' + ? [match.displayLabel, match.rawValue, match.searchText, match.fieldTitle, match.blockName] + : [match.displayLabel, match.fieldTitle, match.blockName] + const searchableText = searchable.filter(Boolean).join(' ') - return normalize(searchable).includes(normalize(trimmedQuery)) + return normalize(searchableText).includes(normalize(trimmedQuery)) } From 304c50c34de0d7d34279b445c6c4c40d8a7058da Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 7 May 2026 18:58:38 -0700 Subject: [PATCH 06/11] add cut --- .../components/block-menu/block-menu.tsx | 13 +++ .../components/canvas-menu/canvas-menu.tsx | 18 ++- .../w/[workflowId]/components/panel/panel.tsx | 9 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 105 +++++++++++++----- 4 files changed, 106 insertions(+), 39 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx index 79e8464bf49..f77a76394c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx @@ -35,6 +35,7 @@ export interface BlockMenuProps { onClose: () => void selectedBlocks: BlockInfo[] onCopy: () => void + onCut: () => void onPaste: () => void onDuplicate: () => void onDelete: () => void @@ -74,6 +75,7 @@ export function BlockMenu({ onClose, selectedBlocks, onCopy, + onCut, onPaste, onDuplicate, onDelete, @@ -162,6 +164,17 @@ export function BlockMenu({ Copy ⌘C + { + onCut() + onClose() + }} + > + Cut + ⌘X + void onFitToView: () => void onOpenLogs: () => void + onOpenSearchReplace: () => void onToggleVariables: () => void onToggleChat: () => void onToggleWorkflowLock?: () => void @@ -59,6 +60,7 @@ export function CanvasMenu({ onAutoLayout, onFitToView, onOpenLogs, + onOpenSearchReplace, onToggleVariables, onToggleChat, onToggleWorkflowLock, @@ -114,9 +116,6 @@ export function CanvasMenu({ Redo ⌘⇧Z - - {/* Edit and creation actions */} - Paste ⌘V + + {/* Edit and creation actions */} + + { + onOpenSearchReplace() + onClose() + }} + > + Search and replace + ⌘F + { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4172ead234b..78ad3020518 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,7 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' -import { History, Plus, Search } from 'lucide-react' +import { History, Plus } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' @@ -86,7 +86,6 @@ import { useVariablesModalStore } from '@/stores/variables/modal' import { useVariablesStore } from '@/stores/variables/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { captureBaselineSnapshot } from '@/stores/workflow-diff/utils' -import { useWorkflowSearchReplaceStore } from '@/stores/workflow-search-replace/store' import { getWorkflowWithValues } from '@/stores/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -609,8 +608,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const hasValidationErrors = false // TODO: Add validation logic if needed const isWorkflowBlocked = isExecuting || hasValidationErrors const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions)) - const openWorkflowSearchReplace = useWorkflowSearchReplaceStore((state) => state.open) - /** * Register global keyboard shortcuts using the central commands registry. * @@ -678,10 +675,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel Variables - - - Search and replace - {userPermissions.canAdmin && !isSnapshotView && ( { + if (protectedIds.length === 0) return false + + if (allProtected) { + addNotification({ + level: 'info', + message: 'Cannot delete locked blocks or blocks inside locked containers', + workflowId: activeWorkflowId || undefined, + }) + return true + } + + addNotification({ + level: 'info', + message: `Skipped ${protectedIds.length} protected block(s)`, + workflowId: activeWorkflowId || undefined, + }) + return false + }, + [activeWorkflowId, addNotification] + ) + + const removeBlocksWithProtection = useCallback( + (blockIds: string[]) => { + const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(blockIds, blocks) + if (notifyProtectedBlockRemoval(protectedIds, allProtected)) return [] + + if (deletableIds.length > 0) { + collaborativeBatchRemoveBlocks(deletableIds) + } + + return deletableIds + }, + [blocks, collaborativeBatchRemoveBlocks, notifyProtectedBlockRemoval] + ) + + const cutBlocksWithProtection = useCallback( + (blockIds: string[]) => { + const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(blockIds, blocks) + if (notifyProtectedBlockRemoval(protectedIds, allProtected)) return + + if (deletableIds.length > 0) { + copyBlocks(deletableIds) + collaborativeBatchRemoveBlocks(deletableIds) + } + }, + [blocks, collaborativeBatchRemoveBlocks, copyBlocks, notifyProtectedBlockRemoval] + ) + /** * Executes a paste operation with validation and selection handling. * Consolidates shared logic for context paste, duplicate, and keyboard paste. @@ -1165,35 +1216,13 @@ const WorkflowContent = React.memo( executePasteOperation('duplicate', DEFAULT_PASTE_OFFSET) }, [contextMenuBlocks, copyBlocks, executePasteOperation]) - const handleContextDelete = useCallback(() => { - const blockIds = contextMenuBlocks.map((b) => b.id) - const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(blockIds, blocks) + const handleContextCut = useCallback(() => { + cutBlocksWithProtection(contextMenuBlocks.map((b) => b.id)) + }, [contextMenuBlocks, cutBlocksWithProtection]) - if (protectedIds.length > 0) { - if (allProtected) { - addNotification({ - level: 'info', - message: 'Cannot delete locked blocks or blocks inside locked containers', - workflowId: activeWorkflowId || undefined, - }) - return - } - addNotification({ - level: 'info', - message: `Skipped ${protectedIds.length} protected block(s)`, - workflowId: activeWorkflowId || undefined, - }) - } - if (deletableIds.length > 0) { - collaborativeBatchRemoveBlocks(deletableIds) - } - }, [ - contextMenuBlocks, - collaborativeBatchRemoveBlocks, - addNotification, - activeWorkflowId, - blocks, - ]) + const handleContextDelete = useCallback(() => { + removeBlocksWithProtection(contextMenuBlocks.map((b) => b.id)) + }, [contextMenuBlocks, removeBlocksWithProtection]) const handleContextToggleEnabled = useCallback(() => { const blockIds = contextMenuBlocks.map((block) => block.id) @@ -1416,6 +1445,10 @@ const WorkflowContent = React.memo( router.push(`/workspace/${workspaceId}/logs?workflowIds=${workflowIdParam}`) }, [router, workspaceId, workflowIdParam]) + const handleContextOpenSearchReplace = useCallback(() => { + useWorkflowSearchReplaceStore.getState().open() + }, []) + const handleContextToggleVariables = useCallback(() => { const { isOpen, setIsOpen } = useVariablesModalStore.getState() setIsOpen(!isOpen) @@ -1467,6 +1500,19 @@ const WorkflowContent = React.memo( copyBlocks([currentBlockId]) } } + } else if ((event.ctrlKey || event.metaKey) && event.key === 'x') { + const selection = window.getSelection() + const hasTextSelection = selection && selection.toString().length > 0 + + if (hasTextSelection || !effectivePermissions.canEdit) { + return + } + + const selectedNodes = getNodes().filter((node) => node.selected) + if (selectedNodes.length > 0) { + event.preventDefault() + cutBlocksWithProtection(selectedNodes.map((node) => node.id)) + } } else if ((event.ctrlKey || event.metaKey) && event.key === 'v') { if (effectivePermissions.canEdit && hasClipboard()) { event.preventDefault() @@ -1487,6 +1533,7 @@ const WorkflowContent = React.memo( redo, getNodes, copyBlocks, + cutBlocksWithProtection, hasClipboard, effectivePermissions.canEdit, clipboard, @@ -4172,6 +4219,7 @@ const WorkflowContent = React.memo( onClose={closeContextMenu} selectedBlocks={contextMenuBlocks} onCopy={handleContextCopy} + onCut={handleContextCut} onPaste={handleContextPaste} onDuplicate={handleContextDuplicate} onDelete={handleContextDelete} @@ -4214,6 +4262,7 @@ const WorkflowContent = React.memo( onAutoLayout={handleAutoLayout} onFitToView={() => fitViewToBounds({ padding: 0.1, duration: 300 })} onOpenLogs={handleContextOpenLogs} + onOpenSearchReplace={handleContextOpenSearchReplace} onToggleVariables={handleContextToggleVariables} onToggleChat={handleContextToggleChat} isVariablesOpen={isVariablesOpen} From e19f7f9439559c8912f2747ba0be8e60366b1d0e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 7 May 2026 19:04:08 -0700 Subject: [PATCH 07/11] update docs --- apps/docs/content/docs/en/keyboard-shortcuts/index.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx b/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx index ad9ece3b50f..c5459d6369b 100644 --- a/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx +++ b/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx @@ -31,10 +31,12 @@ Speed up your workflow building with these keyboard shortcuts and mouse controls | `Mod` + `Z` | Undo | | `Mod` + `Shift` + `Z` | Redo | | `Mod` + `C` | Copy selected blocks | +| `Mod` + `X` | Cut selected blocks | | `Mod` + `V` | Paste blocks | | `Delete` or `Backspace` | Delete selected blocks or edges | | `Shift` + `L` | Auto-layout canvas | | `Mod` + `Shift` + `F` | Fit to view | +| `Mod` + `F` | Open workflow search and replace | | `Mod` + `Shift` + `Enter` | Accept Copilot changes | ## Panel Navigation @@ -43,7 +45,7 @@ These shortcuts switch between panel tabs on the right side of the canvas. | Shortcut | Action | |----------|--------| -| `Mod` + `F` | Focus Toolbar search | +| `Mod` + `Alt` + `F` | Focus Toolbar search | ## Global Navigation From 2eecb5e8feea0f499cfa2aa432884d3761540db7 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 7 May 2026 19:29:51 -0700 Subject: [PATCH 08/11] address comments --- apps/realtime/src/database/operations.ts | 88 ++++------ apps/realtime/src/handlers/subblocks.ts | 37 ++--- .../workflow-search-replace.tsx | 20 ++- apps/sim/hooks/use-collaborative-workflow.ts | 58 ++++--- apps/sim/hooks/use-undo-redo.ts | 154 +++++++++++++++++- .../lib/workflows/search-replace/indexer.ts | 21 +-- .../workflows/search-replace/state.test.ts | 19 +-- .../sim/lib/workflows/search-replace/state.ts | 10 +- apps/sim/stores/undo-redo/types.ts | 7 + apps/sim/stores/undo-redo/utils.test.ts | 18 ++ apps/sim/stores/undo-redo/utils.ts | 7 + .../stores/workflows/workflow/utils.test.ts | 54 +++++- apps/sim/stores/workflows/workflow/utils.ts | 18 +- packages/workflow-types/src/workflow.ts | 49 ++++++ 14 files changed, 395 insertions(+), 165 deletions(-) diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index b9e1c8b74a5..14fa8639eaf 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -16,6 +16,7 @@ import { import { getActiveWorkflowContext } from '@sim/workflow-authz' import { loadWorkflowFromNormalizedTablesRaw } from '@sim/workflow-persistence/load' import { mergeSubBlockValues } from '@sim/workflow-persistence/subblocks' +import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' @@ -47,26 +48,6 @@ interface DbBlockRef { data: unknown } -/** - * Checks if a block is protected (locked or inside a locked ancestor). - * Works with raw DB records. - */ -function isDbBlockProtected(blockId: string, blocksById: Record): boolean { - const block = blocksById[blockId] - if (!block) return false - if (block.locked) return true - const visited = new Set() - let parentId = (block.data as Record | null)?.parentId as string | undefined - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - if (blocksById[parentId]?.locked) return true - parentId = (blocksById[parentId]?.data as Record | null)?.parentId as - | string - | undefined - } - return false -} - /** * Finds all descendant block IDs of a container (recursive). * Works with raw DB block arrays. @@ -880,7 +861,7 @@ async function handleBlocksOperationTx( ) // Filter out protected blocks from deletion request - const deletableIds = ids.filter((id) => !isDbBlockProtected(id, blocksById)) + const deletableIds = ids.filter((id) => !isWorkflowBlockProtected(id, blocksById)) if (deletableIds.length === 0) { logger.info('All requested blocks are protected, skipping deletion') return @@ -995,14 +976,14 @@ async function handleBlocksOperationTx( // Collect all blocks to toggle including descendants of containers for (const id of blockIds) { const block = blocksById[id] - if (!block || isDbBlockProtected(id, blocksById)) continue + if (!block || isWorkflowBlockProtected(id, blocksById)) continue blocksToToggle.add(id) // If it's a loop or parallel, also include all non-locked descendants if (block.type === 'loop' || block.type === 'parallel') { for (const descId of findDbDescendants(id, allBlocks)) { - if (!isDbBlockProtected(descId, blocksById)) { + if (!isWorkflowBlockProtected(descId, blocksById)) { blocksToToggle.add(descId) } } @@ -1057,7 +1038,7 @@ async function handleBlocksOperationTx( // Filter to only toggle handles on unprotected blocks const blocksToToggle = blockIds.filter( - (id) => blocksById[id] && !isDbBlockProtected(id, blocksById) + (id) => blocksById[id] && !isWorkflowBlockProtected(id, blocksById) ) if (blocksToToggle.length === 0) { logger.info('All requested blocks are protected, skipping handles toggle') @@ -1169,13 +1150,13 @@ async function handleBlocksOperationTx( if (!id) continue // Skip protected blocks (locked or inside locked container) - if (isDbBlockProtected(id, blocksById)) { + if (isWorkflowBlockProtected(id, blocksById)) { logger.info(`Skipping block ${id} parent update - block is protected`) continue } // Skip if trying to move into a locked container (or any of its ancestors) - if (parentId && isDbBlockProtected(parentId, blocksById)) { + if (parentId && isWorkflowBlockProtected(parentId, blocksById)) { logger.info(`Skipping block ${id} parent update - target parent ${parentId} is protected`) continue } @@ -1299,7 +1280,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str } } - if (isDbBlockProtected(payload.target, blocksById)) { + if (isWorkflowBlockProtected(payload.target, blocksById)) { logger.info(`Skipping edge add - target block is protected`) break } @@ -1387,7 +1368,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str } } - if (isDbBlockProtected(edgeToRemove.targetBlockId, blocksById)) { + if (isWorkflowBlockProtected(edgeToRemove.targetBlockId, blocksById)) { logger.info(`Skipping edge remove - target block is protected`) break } @@ -1498,7 +1479,7 @@ async function handleEdgesOperationTx( } const safeEdgeIds = edgesToRemove - .filter((e: EdgeToRemove) => !isDbBlockProtected(e.targetBlockId, blocksById)) + .filter((e: EdgeToRemove) => !isWorkflowBlockProtected(e.targetBlockId, blocksById)) .map((e: EdgeToRemove) => e.id) if (safeEdgeIds.length === 0) { @@ -1585,7 +1566,7 @@ async function handleEdgesOperationTx( // Filter edges - only add edges where target block is not protected const safeEdges = (edges as Array>).filter( - (e) => !isDbBlockProtected(e.target as string, blocksById) + (e) => !isWorkflowBlockProtected(e.target as string, blocksById) ) if (safeEdges.length === 0) { @@ -1756,43 +1737,34 @@ async function handleSubblockOperationTx( return } + const allBlocks = await tx + .select({ + id: workflowBlocks.id, + subBlocks: workflowBlocks.subBlocks, + locked: workflowBlocks.locked, + data: workflowBlocks.data, + }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, workflowId)) + + type SubblockUpdateBlockRecord = (typeof allBlocks)[number] + const blocksById: Record = Object.fromEntries( + allBlocks.map((block: SubblockUpdateBlockRecord) => [block.id, block]) + ) + for (const update of updates) { const { blockId, subblockId, value, expectedValue } = update if (!blockId || !subblockId) { throw new Error('Missing required fields for subblock batch update') } - const [block] = await tx - .select({ - subBlocks: workflowBlocks.subBlocks, - locked: workflowBlocks.locked, - data: workflowBlocks.data, - }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) - + const block = blocksById[blockId] if (!block) { throw new Error(`Block ${blockId} not found`) } - if (block.locked) { - throw new Error(`Block ${blockId} is locked`) - } - - const parentId = (block.data as Record | null)?.parentId as - | string - | undefined - if (parentId) { - const [parentBlock] = await tx - .select({ locked: workflowBlocks.locked }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, parentId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) - - if (parentBlock?.locked) { - throw new Error(`Parent block ${parentId} is locked`) - } + if (isWorkflowBlockProtected(blockId, blocksById)) { + throw new Error(`Block ${blockId} is locked or inside a locked container`) } const subBlocks = { ...((block.subBlocks as Record) || {}) } @@ -1813,6 +1785,8 @@ async function handleSubblockOperationTx( updatedAt: new Date(), }) .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + + blocksById[blockId] = { ...block, subBlocks } } logger.debug(`Batch updated ${updates.length} subblocks for workflow ${workflowId}`) diff --git a/apps/realtime/src/handlers/subblocks.ts b/apps/realtime/src/handlers/subblocks.ts index b3be99e7457..4650f8487cc 100644 --- a/apps/realtime/src/handlers/subblocks.ts +++ b/apps/realtime/src/handlers/subblocks.ts @@ -3,6 +3,7 @@ import { workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' import { and, eq } from 'drizzle-orm' import type { AuthenticatedSocket } from '@/middleware/auth' import { checkRolePermission } from '@/middleware/permissions' @@ -273,45 +274,33 @@ async function flushSubblockUpdate( let updateSuccessful = false let blockLocked = false await db.transaction(async (tx) => { - const [block] = await tx + const allBlocks = await tx .select({ + id: workflowBlocks.id, subBlocks: workflowBlocks.subBlocks, locked: workflowBlocks.locked, data: workflowBlocks.data, }) .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) + .where(eq(workflowBlocks.workflowId, workflowId)) + type SubblockUpdateBlockRecord = (typeof allBlocks)[number] + const blocksById: Record = Object.fromEntries( + allBlocks.map((block: SubblockUpdateBlockRecord) => [block.id, block]) + ) + const block = blocksById[blockId] if (!block) { return } - // Check if block is locked directly - if (block.locked) { - logger.info(`Skipping subblock update - block ${blockId} is locked`) + if (isWorkflowBlockProtected(blockId, blocksById)) { + logger.info( + `Skipping subblock update - block ${blockId} is locked or inside a locked container` + ) blockLocked = true return } - // Check if block is inside a locked parent container - const parentId = (block.data as Record | null)?.parentId as - | string - | undefined - if (parentId) { - const [parentBlock] = await tx - .select({ locked: workflowBlocks.locked }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, parentId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) - - if (parentBlock?.locked) { - logger.info(`Skipping subblock update - parent ${parentId} is locked`) - blockLocked = true - return - } - } - const subBlocks = (block.subBlocks as any) || {} if (!subBlocks[subblockId]) { subBlocks[subblockId] = { id: subblockId, type: 'unknown', value } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx index 3a224cb133b..77c4903cc53 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown, ChevronRight, ChevronUp, X } from 'lucide-react' import { useParams } from 'next/navigation' import { Button, Input } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies' import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer' import { @@ -140,10 +141,10 @@ export function WorkflowSearchReplace() { () => getWorkflowSearchBlocks({ blocks: currentWorkflow.blocks, - workflowId, isSnapshotView: currentWorkflow.isSnapshotView, + subblockValues: workflowSubblockValues, }), - [currentWorkflow.blocks, currentWorkflow.isSnapshotView, workflowId, workflowSubblockValues] + [currentWorkflow.blocks, currentWorkflow.isSnapshotView, workflowSubblockValues] ) const matches = useMemo( @@ -394,8 +395,15 @@ export function WorkflowSearchReplace() { return } - const applied = - batchUpdates.length === 0 ? true : collaborativeBatchSetSubblockValues(batchUpdates) + const applied = collaborativeBatchSetSubblockValues(batchUpdates, { + subflowUpdates: plan.subflowUpdates.map((update) => ({ + blockId: update.blockId, + blockType: update.blockType, + fieldId: update.fieldId, + before: update.previousValue, + after: update.nextValue, + })), + }) if (!applied) { addNotification({ level: 'error', @@ -435,7 +443,7 @@ export function WorkflowSearchReplace() { : `${activeMatchIndex >= 0 ? activeMatchIndex + 1 : 1} of ${hydratedMatches.length}` return (
setIsReplaceExpanded((expanded) => !expanded)} > + }>, + options: { + subflowUpdates?: Array<{ + blockId: string + blockType: 'loop' | 'parallel' + fieldId: string + before: unknown + after: unknown + }> + } = {} ) => { - if (isApplyingRemoteChange.current || updates.length === 0) return false + const undoSubflowUpdates = options.subflowUpdates ?? [] + if ( + isApplyingRemoteChange.current || + (updates.length === 0 && undoSubflowUpdates.length === 0) + ) { + return false + } if (isBaselineDiffView) { logger.debug('Skipping collaborative batch subblock update while viewing baseline diff') @@ -1551,24 +1566,26 @@ export function useCollaborativeWorkflow() { return false } - updates.forEach((update) => { - useSubBlockStore.getState().setValue(update.blockId, update.subblockId, update.value) - useWorkflowStore - .getState() - .syncDynamicHandleSubblockValue(update.blockId, update.subblockId, update.value) - }) + if (updates.length > 0) { + updates.forEach((update) => { + useSubBlockStore.getState().setValue(update.blockId, update.subblockId, update.value) + useWorkflowStore + .getState() + .syncDynamicHandleSubblockValue(update.blockId, update.subblockId, update.value) + }) - const operationId = generateId() - addToQueue({ - id: operationId, - operation: { - operation: SUBBLOCK_OPERATIONS.BATCH_UPDATE, - target: OPERATION_TARGETS.SUBBLOCK, - payload: { updates }, - }, - workflowId: activeWorkflowId, - userId: session?.user?.id || 'unknown', - }) + const operationId = generateId() + addToQueue({ + id: operationId, + operation: { + operation: SUBBLOCK_OPERATIONS.BATCH_UPDATE, + target: OPERATION_TARGETS.SUBBLOCK, + payload: { updates }, + }, + workflowId: activeWorkflowId, + userId: session?.user?.id || 'unknown', + }) + } undoRedo.recordBatchUpdateSubblocks( updates.map((update) => ({ @@ -1576,7 +1593,8 @@ export function useCollaborativeWorkflow() { subBlockId: update.subblockId, before: update.expectedValue, after: update.value, - })) + })), + undoSubflowUpdates ) return true diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index dca49eaacf6..86fe2a6bcce 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -15,11 +15,13 @@ import { EDGES_OPERATIONS, OPERATION_TARGETS, SUBBLOCK_OPERATIONS, + SUBFLOW_OPERATIONS, UNDO_REDO_OPERATIONS, } from '@sim/realtime-protocol/constants' import type { Edge } from 'reactflow' import { useSession } from '@/lib/auth/auth-client' import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-operations' +import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields' import { useOperationQueue } from '@/stores/operation-queue/store' import { type BatchAddBlocksOperation, @@ -458,8 +460,11 @@ export function useUndoRedo() { ) const recordBatchUpdateSubblocks = useCallback( - (updates: BatchUpdateSubblocksOperation['data']['updates']) => { - if (!activeWorkflowId || updates.length === 0) return + ( + updates: BatchUpdateSubblocksOperation['data']['updates'], + subflowUpdates: NonNullable = [] + ) => { + if (!activeWorkflowId || (updates.length === 0 && subflowUpdates.length === 0)) return const operation: BatchUpdateSubblocksOperation = { id: generateId(), @@ -467,7 +472,7 @@ export function useUndoRedo() { timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { updates }, + data: { updates, subflowUpdates }, } const inverse: BatchUpdateSubblocksOperation = { @@ -483,12 +488,22 @@ export function useUndoRedo() { before: update.after, after: update.before, })), + subflowUpdates: subflowUpdates.map((update) => ({ + blockId: update.blockId, + blockType: update.blockType, + fieldId: update.fieldId, + before: update.after, + after: update.before, + })), }, } const entry = createOperationEntry(operation, inverse) useUndoRedoStore.getState().push(activeWorkflowId, userId, entry) - logger.debug('Recorded batch subblock update', { count: updates.length }) + logger.debug('Recorded batch field update', { + subblockCount: updates.length, + subflowCount: subflowUpdates.length, + }) }, [activeWorkflowId, userId] ) @@ -525,6 +540,129 @@ export function useUndoRedo() { [activeWorkflowId, addToQueue, userId] ) + const applySubflowUndoRedoUpdate = useCallback( + (update: NonNullable[number]) => { + if (!activeWorkflowId) return + + const currentBlock = useWorkflowStore.getState().blocks[update.blockId] + if (!currentBlock || currentBlock.type !== update.blockType) return + + const childNodes = Object.values(useWorkflowStore.getState().blocks) + .filter((block) => block.data?.parentId === update.blockId) + .map((block) => block.id) + + if (update.blockType === 'loop') { + const loopType = currentBlock.data?.loopType || 'for' + const currentCollection = currentBlock.data?.collection || '' + const currentWhileCondition = currentBlock.data?.whileCondition || '' + const currentDoWhileCondition = currentBlock.data?.doWhileCondition || '' + const currentIterations = currentBlock.data?.count || 5 + const nextIterations = Number.parseInt(String(update.after), 10) + + const config: Record = { + id: update.blockId, + nodes: childNodes, + iterations: + update.fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations + ? nextIterations + : currentIterations, + loopType, + forEachItems: + update.fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items + ? update.after + : currentCollection, + whileCondition: + update.fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.condition && loopType !== 'doWhile' + ? update.after + : currentWhileCondition, + doWhileCondition: + update.fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.condition && loopType === 'doWhile' + ? update.after + : currentDoWhileCondition, + } + + addToQueue({ + id: generateId(), + operation: { + operation: SUBFLOW_OPERATIONS.UPDATE, + target: OPERATION_TARGETS.SUBFLOW, + payload: { id: update.blockId, type: 'loop', config }, + }, + workflowId: activeWorkflowId, + userId, + }) + + if (update.fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations) { + if (!Number.isNaN(nextIterations)) { + useWorkflowStore.getState().updateLoopCount(update.blockId, nextIterations) + } + return + } + + if (update.fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.condition) { + if (loopType === 'doWhile') { + useWorkflowStore + .getState() + .setLoopDoWhileCondition(update.blockId, String(update.after)) + } else { + useWorkflowStore.getState().setLoopWhileCondition(update.blockId, String(update.after)) + } + return + } + + useWorkflowStore.getState().setLoopForEachItems(update.blockId, String(update.after)) + return + } + + const currentCount = currentBlock.data?.count || 5 + const currentParallelType = currentBlock.data?.parallelType || 'count' + const currentDistribution = currentBlock.data?.collection || '' + const nextCount = Number.parseInt(String(update.after), 10) + const config = { + id: update.blockId, + nodes: childNodes, + count: + update.fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations + ? nextCount + : currentCount, + distribution: + update.fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items + ? update.after + : currentDistribution, + parallelType: currentParallelType, + } + + addToQueue({ + id: generateId(), + operation: { + operation: SUBFLOW_OPERATIONS.UPDATE, + target: OPERATION_TARGETS.SUBFLOW, + payload: { id: update.blockId, type: 'parallel', config }, + }, + workflowId: activeWorkflowId, + userId, + }) + + if (update.fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations) { + if (!Number.isNaN(nextCount)) { + useWorkflowStore.getState().updateParallelCount(update.blockId, nextCount) + } + return + } + + useWorkflowStore.getState().updateParallelCollection(update.blockId, String(update.after)) + }, + [activeWorkflowId, addToQueue, userId] + ) + + const applyBatchFieldUndoRedo = useCallback( + (operation: BatchUpdateSubblocksOperation) => { + applyBatchSubblockUndoRedo(operation.data.updates) + operation.data.subflowUpdates?.forEach(applySubflowUndoRedoUpdate) + }, + [applyBatchSubblockUndoRedo, applySubflowUndoRedoUpdate] + ) + const undo = useCallback(async () => { if (!activeWorkflowId) return @@ -976,7 +1114,7 @@ export function useUndoRedo() { } case UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS: { const subblockOp = entry.inverse as BatchUpdateSubblocksOperation - applyBatchSubblockUndoRedo(subblockOp.data.updates) + applyBatchFieldUndoRedo(subblockOp) break } case UNDO_REDO_OPERATIONS.APPLY_DIFF: { @@ -1152,7 +1290,7 @@ export function useUndoRedo() { logger.info('Undo operation', { type: entry.operation.type, workflowId: activeWorkflowId }) }) - }, [activeWorkflowId, userId, addToQueue, applyBatchSubblockUndoRedo]) + }, [activeWorkflowId, userId, addToQueue, applyBatchFieldUndoRedo]) const redo = useCallback(async () => { if (!activeWorkflowId || !userId) return @@ -1608,7 +1746,7 @@ export function useUndoRedo() { } case UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS: { const subblockOp = entry.operation as BatchUpdateSubblocksOperation - applyBatchSubblockUndoRedo(subblockOp.data.updates) + applyBatchFieldUndoRedo(subblockOp) break } case UNDO_REDO_OPERATIONS.APPLY_DIFF: { @@ -1782,7 +1920,7 @@ export function useUndoRedo() { userId, }) }) - }, [activeWorkflowId, userId, addToQueue, applyBatchSubblockUndoRedo]) + }, [activeWorkflowId, userId, addToQueue, applyBatchFieldUndoRedo]) const getStackSizes = useCallback(() => { if (!activeWorkflowId) return { undoSize: 0, redoSize: 0 } diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 2a3c4ef8acd..b2395117369 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -1,4 +1,5 @@ import type { SubBlockType } from '@sim/workflow-types/blocks' +import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' import { getResourceKindForSubBlock, matchesSearchText, @@ -19,24 +20,6 @@ import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import type { SelectorContext } from '@/hooks/selectors/types' -function hasLockedAncestor( - block: WorkflowSearchBlockState, - blocks: Record -): boolean { - let parentId = block.data?.parentId - const visited = new Set() - - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - const parent = blocks[parentId] - if (!parent) return false - if (parent.locked) return true - parentId = parent.data?.parentId - } - - return false -} - function normalizeForSearch(value: string, caseSensitive: boolean): string { return caseSensitive ? value : value.toLowerCase() } @@ -219,7 +202,7 @@ export function indexWorkflowSearchMatches( workspaceId, workflowId, }) - const protectedByLock = Boolean(block.locked || hasLockedAncestor(block, workflow.blocks)) + const protectedByLock = isWorkflowBlockProtected(block.id, workflow.blocks) const editable = !protectedByLock && !isReadOnly if (mode !== 'resource') { diff --git a/apps/sim/lib/workflows/search-replace/state.test.ts b/apps/sim/lib/workflows/search-replace/state.test.ts index ed35e0140ee..27ada060bdd 100644 --- a/apps/sim/lib/workflows/search-replace/state.test.ts +++ b/apps/sim/lib/workflows/search-replace/state.test.ts @@ -1,9 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { getWorkflowSearchBlocks } from '@/lib/workflows/search-replace/state' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { BlockState } from '@/stores/workflows/workflow/types' describe('getWorkflowSearchBlocks', () => { @@ -21,19 +20,13 @@ describe('getWorkflowSearchBlocks', () => { }, } as Record - beforeEach(() => { - useSubBlockStore.setState({ workflowValues: {} }) - }) - it('uses merged live subblock values for normal workflow search', () => { - useSubBlockStore.getState().setWorkflowValues('workflow-1', { - block1: { code: 'Hello' }, - }) - const result = getWorkflowSearchBlocks({ blocks, - workflowId: 'workflow-1', isSnapshotView: false, + subblockValues: { + block1: { code: 'Hello' }, + }, }) expect(result.block1.subBlocks.code.value).toBe('Hello') @@ -42,8 +35,10 @@ describe('getWorkflowSearchBlocks', () => { it('does not merge snapshot blocks', () => { const result = getWorkflowSearchBlocks({ blocks, - workflowId: 'workflow-1', isSnapshotView: true, + subblockValues: { + block1: { code: 'Hello' }, + }, }) expect(result).toBe(blocks) diff --git a/apps/sim/lib/workflows/search-replace/state.ts b/apps/sim/lib/workflows/search-replace/state.ts index bd4648ce6bb..6fca8bc1539 100644 --- a/apps/sim/lib/workflows/search-replace/state.ts +++ b/apps/sim/lib/workflows/search-replace/state.ts @@ -1,17 +1,17 @@ -import { mergeSubblockState } from '@/stores/workflows/utils' +import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import type { BlockState } from '@/stores/workflows/workflow/types' interface GetWorkflowSearchBlocksOptions { blocks: Record - workflowId?: string isSnapshotView?: boolean + subblockValues?: Record> } export function getWorkflowSearchBlocks({ blocks, - workflowId, isSnapshotView, + subblockValues, }: GetWorkflowSearchBlocksOptions): Record { - if (isSnapshotView || !workflowId) return blocks - return mergeSubblockState(blocks, workflowId) + if (isSnapshotView || !subblockValues) return blocks + return mergeSubblockStateWithValues(blocks, subblockValues) } diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index c63504782d5..d89c931f4e7 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -114,6 +114,13 @@ export interface BatchUpdateSubblocksOperation extends BaseOperation { before: unknown after: unknown }> + subflowUpdates?: Array<{ + blockId: string + blockType: 'loop' | 'parallel' + fieldId: string + before: unknown + after: unknown + }> } } diff --git a/apps/sim/stores/undo-redo/utils.test.ts b/apps/sim/stores/undo-redo/utils.test.ts index ac91d3d014e..95ea716adb0 100644 --- a/apps/sim/stores/undo-redo/utils.test.ts +++ b/apps/sim/stores/undo-redo/utils.test.ts @@ -411,6 +411,15 @@ describe('createInverseOperation', () => { after: 'new', }, ], + subflowUpdates: [ + { + blockId: 'loop-1', + blockType: 'loop', + fieldId: 'subflowIterations', + before: 2, + after: 3, + }, + ], }, } @@ -425,6 +434,15 @@ describe('createInverseOperation', () => { after: 'old', }, ], + subflowUpdates: [ + { + blockId: 'loop-1', + blockType: 'loop', + fieldId: 'subflowIterations', + before: 3, + after: 2, + }, + ], }, }) }) diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index 1ac962160f6..5dc5b31ff69 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -189,6 +189,13 @@ export function createInverseOperation(operation: Operation): Operation { before: update.after, after: update.before, })), + subflowUpdates: op.data.subflowUpdates?.map((update) => ({ + blockId: update.blockId, + blockType: update.blockType, + fieldId: update.fieldId, + before: update.after, + after: update.before, + })), }, } as BatchUpdateSubblocksOperation } diff --git a/apps/sim/stores/workflows/workflow/utils.test.ts b/apps/sim/stores/workflows/workflow/utils.test.ts index 780f91bc22a..4a816deccfb 100644 --- a/apps/sim/stores/workflows/workflow/utils.test.ts +++ b/apps/sim/stores/workflows/workflow/utils.test.ts @@ -1,7 +1,11 @@ -import { createLoopBlock } from '@sim/testing' +import { createAgentBlock, createLoopBlock } from '@sim/testing' import { describe, expect, it } from 'vitest' import type { BlockState } from '@/stores/workflows/workflow/types' -import { convertLoopBlockToLoop } from '@/stores/workflows/workflow/utils' +import { + convertLoopBlockToLoop, + isAncestorProtected, + isBlockProtected, +} from '@/stores/workflows/workflow/utils' describe('convertLoopBlockToLoop', () => { it.concurrent('should keep JSON array string as-is for forEach loops', () => { @@ -94,3 +98,49 @@ describe('convertLoopBlockToLoop', () => { expect(result?.forEachItems).toBe('["should", "not", "matter"]') }) }) + +describe('block lock protection', () => { + it.concurrent('treats deeply nested blocks inside locked containers as protected', () => { + const blocks: Record = { + grandparent: createLoopBlock({ + id: 'grandparent', + name: 'Grandparent Loop', + locked: true, + }), + parent: createLoopBlock({ + id: 'parent', + name: 'Parent Loop', + parentId: 'grandparent', + }), + child: createAgentBlock({ + id: 'child', + name: 'Child Agent', + parentId: 'parent', + }), + } + + expect(isAncestorProtected('child', blocks)).toBe(true) + expect(isBlockProtected('child', blocks)).toBe(true) + }) + + it.concurrent( + 'does not treat ancestor cycles as protected unless a locked ancestor is found', + () => { + const blocks: Record = { + first: createAgentBlock({ + id: 'first', + name: 'First Agent', + parentId: 'second', + }), + second: createAgentBlock({ + id: 'second', + name: 'Second Agent', + parentId: 'first', + }), + } + + expect(isAncestorProtected('first', blocks)).toBe(false) + expect(isBlockProtected('first', blocks)).toBe(false) + } + ) +}) diff --git a/apps/sim/stores/workflows/workflow/utils.ts b/apps/sim/stores/workflows/workflow/utils.ts index 65d9f3fb20c..26c2f642a85 100644 --- a/apps/sim/stores/workflows/workflow/utils.ts +++ b/apps/sim/stores/workflows/workflow/utils.ts @@ -1,3 +1,7 @@ +import { + isWorkflowBlockAncestorLocked, + isWorkflowBlockProtected, +} from '@sim/workflow-types/workflow' import type { Edge } from 'reactflow' import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' @@ -168,14 +172,7 @@ export function findAllDescendantNodes( * @returns True if any ancestor is locked */ export function isAncestorProtected(blockId: string, blocks: Record): boolean { - const visited = new Set() - let parentId = blocks[blockId]?.data?.parentId - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - if (blocks[parentId]?.locked) return true - parentId = blocks[parentId]?.data?.parentId - } - return false + return isWorkflowBlockAncestorLocked(blockId, blocks) } /** @@ -187,10 +184,7 @@ export function isAncestorProtected(blockId: string, blocks: Record): boolean { - const block = blocks[blockId] - if (!block) return false - if (block.locked) return true - return isAncestorProtected(blockId, blocks) + return isWorkflowBlockProtected(blockId, blocks) } /** diff --git a/packages/workflow-types/src/workflow.ts b/packages/workflow-types/src/workflow.ts index fb3c35e51b5..006bd2ccab6 100644 --- a/packages/workflow-types/src/workflow.ts +++ b/packages/workflow-types/src/workflow.ts @@ -78,6 +78,55 @@ export interface BlockState { locked?: boolean } +export interface WorkflowLockBlock { + locked?: boolean | null + data?: unknown +} + +/** + * Reads a workflow block's parent ID from runtime block data. + */ +export function getWorkflowBlockParentId(block?: WorkflowLockBlock): string | undefined { + const data = block?.data + if (typeof data !== 'object' || data === null || !('parentId' in data)) return undefined + + const parentId = (data as Record).parentId + return typeof parentId === 'string' && parentId.length > 0 ? parentId : undefined +} + +/** + * Checks whether any parent container in a block's ancestry is locked. + */ +export function isWorkflowBlockAncestorLocked( + blockId: string, + blocks: Record +): boolean { + const visited = new Set() + let parentId = getWorkflowBlockParentId(blocks[blockId]) + + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = blocks[parentId] + if (!parent) return false + if (parent.locked) return true + parentId = getWorkflowBlockParentId(parent) + } + + return false +} + +/** + * Checks whether a block is locked directly or protected by a locked ancestor. + */ +export function isWorkflowBlockProtected( + blockId: string, + blocks: Record +): boolean { + const block = blocks[blockId] + if (!block) return false + return Boolean(block.locked || isWorkflowBlockAncestorLocked(blockId, blocks)) +} + export interface SubBlockState { id: string type: SubBlockType From d3d83c4c147045e247e4c689f1efdf03e0eec852 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 7 May 2026 19:50:45 -0700 Subject: [PATCH 09/11] make source code for func blocks dispay resolved code instead --- apps/sim/executor/execution/block-executor.ts | 12 ++++- .../function/function-handler.test.ts | 10 +++-- .../handlers/function/function-handler.ts | 11 +++-- apps/sim/executor/variables/resolver.test.ts | 14 +++--- apps/sim/executor/variables/resolver.ts | 45 ++++++++++++++++++- 5 files changed, 75 insertions(+), 17 deletions(-) diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 48d1e970410..f1506bb33fc 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -47,6 +47,7 @@ import { isJSONString } from '@/executor/utils/json' import { filterOutputForLog } from '@/executor/utils/output-filter' import { FUNCTION_BLOCK_CONTEXT_VARS_KEY, + FUNCTION_BLOCK_DISPLAY_CODE_KEY, type VariableResolver, } from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' @@ -126,7 +127,13 @@ export class BlockExecutor { displayInputs, contextVariables, } = this.resolver.resolveInputsForFunctionBlock(ctx, node.id, block.config.params, block) - resolvedInputs = { ...fnInputs, [FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables } + resolvedInputs = { + ...fnInputs, + [FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables, + ...(displayInputs.code !== undefined + ? { [FUNCTION_BLOCK_DISPLAY_CODE_KEY]: displayInputs.code } + : {}), + } inputsForLog = displayInputs } else { resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block) @@ -446,7 +453,8 @@ export class BlockExecutor { if ( SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode' || - key === FUNCTION_BLOCK_CONTEXT_VARS_KEY + key === FUNCTION_BLOCK_CONTEXT_VARS_KEY || + key === FUNCTION_BLOCK_DISPLAY_CODE_KEY ) { continue } diff --git a/apps/sim/executor/handlers/function/function-handler.test.ts b/apps/sim/executor/handlers/function/function-handler.test.ts index f6facf69cfe..b288940850d 100644 --- a/apps/sim/executor/handlers/function/function-handler.test.ts +++ b/apps/sim/executor/handlers/function/function-handler.test.ts @@ -3,7 +3,10 @@ import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' import { BlockType } from '@/executor/constants' import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler' import type { ExecutionContext } from '@/executor/types' -import { FUNCTION_BLOCK_CONTEXT_VARS_KEY } from '@/executor/variables/resolver' +import { + FUNCTION_BLOCK_CONTEXT_VARS_KEY, + FUNCTION_BLOCK_DISPLAY_CODE_KEY, +} from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' @@ -196,11 +199,12 @@ describe('FunctionBlockHandler', () => { ) }) - it('should pass original function code for error display after reference resolution', async () => { + it('should pass display-resolved function code for error display', async () => { mockBlock.config.params = { code: 'retur ' } await handler.execute(mockContext, mockBlock, { code: 'retur globalThis["__blockRef_0"]', + [FUNCTION_BLOCK_DISPLAY_CODE_KEY]: 'retur "value"', [FUNCTION_BLOCK_CONTEXT_VARS_KEY]: { __blockRef_0: 'value' }, }) @@ -208,7 +212,7 @@ describe('FunctionBlockHandler', () => { 'function_execute', expect.objectContaining({ code: 'retur globalThis["__blockRef_0"]', - sourceCode: 'retur ', + sourceCode: 'retur "value"', }), false, mockContext diff --git a/apps/sim/executor/handlers/function/function-handler.ts b/apps/sim/executor/handlers/function/function-handler.ts index c5dc9c47dcd..53fa8b4451b 100644 --- a/apps/sim/executor/handlers/function/function-handler.ts +++ b/apps/sim/executor/handlers/function/function-handler.ts @@ -8,7 +8,10 @@ import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages' import { BlockType } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { collectBlockData } from '@/executor/utils/block-data' -import { FUNCTION_BLOCK_CONTEXT_VARS_KEY } from '@/executor/variables/resolver' +import { + FUNCTION_BLOCK_CONTEXT_VARS_KEY, + FUNCTION_BLOCK_DISPLAY_CODE_KEY, +} from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' @@ -42,9 +45,9 @@ export class FunctionBlockHandler implements BlockHandler { inputs: Record ): Promise { const codeContent = readCodeContent(inputs.code) ?? inputs.code - const sourceCode = readCodeContent( - (block.config?.params as Record | undefined)?.code - ) + const sourceCode = + readCodeContent(inputs[FUNCTION_BLOCK_DISPLAY_CODE_KEY]) ?? + readCodeContent((block.config?.params as Record | undefined)?.code) const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx) diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts index 545d4fa91de..ee852e19e2c 100644 --- a/apps/sim/executor/variables/resolver.test.ts +++ b/apps/sim/executor/variables/resolver.test.ts @@ -80,7 +80,7 @@ describe('VariableResolver function block inputs', () => { ) expect(result.resolvedInputs.code).toBe('return globalThis["__blockRef_0"]') - expect(result.displayInputs.code).toBe('return ') + expect(result.displayInputs.code).toBe('return "hello world"') expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' }) }) @@ -95,7 +95,7 @@ describe('VariableResolver function block inputs', () => { ) expect(result.resolvedInputs.code).toBe('return globals()["__blockRef_0"]') - expect(result.displayInputs.code).toBe('return ') + expect(result.displayInputs.code).toBe('return "hello world"') expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' }) }) @@ -112,7 +112,9 @@ describe('VariableResolver function block inputs', () => { expect(result.resolvedInputs.code).toBe( 'a = globals()["__blockRef_0"]\nb = globals()["__blockRef_1"]\nreturn b' ) - expect(result.displayInputs.code).toBe('a = \nb = \nreturn b') + expect(result.displayInputs.code).toBe( + 'a = json.loads("[\\"a\\",\\"b\\"]")\nb = json.loads("[\\"a\\",\\"b\\"]")\nreturn b' + ) expect(result.contextVariables).toEqual({ __blockRef_0: ['a', 'b'], __blockRef_1: ['a', 'b'], @@ -132,9 +134,7 @@ describe('VariableResolver function block inputs', () => { expect(result.resolvedInputs.code).toBe( `echo "\${__blockRef_0}"suffix && echo "\${__blockRef_1}"` ) - expect(result.displayInputs.code).toBe( - 'echo suffix && echo ""' - ) + expect(result.displayInputs.code).toBe('echo "hello world"suffix && echo "hello world"') expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world', __blockRef_1: 'hello world', @@ -154,7 +154,7 @@ describe('VariableResolver function block inputs', () => { expect(result.resolvedInputs.code).toBe( `# don't confuse quote tracking\necho "\${__blockRef_0}"` ) - expect(result.displayInputs.code).toBe("# don't confuse quote tracking\necho ") + expect(result.displayInputs.code).toBe('# don\'t confuse quote tracking\necho "hello world"') expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' }) }) }) diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index d8566278e82..6041234a1bb 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -18,6 +18,8 @@ import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' /** Key used to carry pre-resolved context variables through the inputs map. */ export const FUNCTION_BLOCK_CONTEXT_VARS_KEY = '_runtimeContextVars' +/** Key used to carry display-resolved code through the function execution path. */ +export const FUNCTION_BLOCK_DISPLAY_CODE_KEY = '_runtimeDisplayCode' const logger = createLogger('VariableResolver') @@ -292,7 +294,12 @@ export class VariableResolver { index, effectiveValue ) - displayResult += match + displayResult += this.formatDisplayValueForCodeContext( + effectiveValue, + language, + template, + index + ) return replacement } @@ -354,6 +361,42 @@ export class VariableResolver { return `globalThis[${JSON.stringify(varName)}]` } + private formatDisplayValueForCodeContext( + value: unknown, + language: string | undefined, + template: string, + matchIndex: number + ): string { + if (language === 'shell') { + return this.formatShellDisplayValue(value, template, matchIndex) + } + + return this.blockResolver.formatValueForBlock(value, BlockType.FUNCTION, language) + } + + private formatShellDisplayValue(value: unknown, template: string, matchIndex: number): string { + const text = this.stringifyShellDisplayValue(value) + const quoteContext = this.getShellQuoteContext(template, matchIndex) + if (quoteContext === 'double') { + return text.replace(/["\\$`]/g, '\\$&') + } + + return `"${text.replace(/["\\$`]/g, '\\$&')}"` + } + + private stringifyShellDisplayValue(value: unknown): string { + if (value === null || value === undefined) { + return '' + } + if (typeof value === 'string') { + return value + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value) + } + return JSON.stringify(value) + } + private formatShellContextVariableReference( varName: string, template: string, From b03a07d9a1988b7af22ed86db9765c083e442cd7 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 7 May 2026 20:05:02 -0700 Subject: [PATCH 10/11] fix match issue --- .../workflow-search-replace.tsx | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx index 77c4903cc53..95cbd4c91de 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx @@ -15,6 +15,7 @@ import { import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements' import { getWorkflowSearchCompatibleResourceMatches, + getWorkflowSearchMatchResourceGroupKey, workflowSearchMatchMatchesQuery, } from '@/lib/workflows/search-replace/resource-resolvers' import { getWorkflowSearchBlocks } from '@/lib/workflows/search-replace/state' @@ -109,11 +110,14 @@ export function WorkflowSearchReplace() { const searchInputRef = useRef(null) const [isApplying, setIsApplying] = useState(false) const [isReplaceExpanded, setIsReplaceExpanded] = useState(false) + const [resourceReplacementByContext, setResourceReplacementByContext] = useState< + Record + >({}) const { isOpen, query, - replacement, + replacement: textReplacement, activeMatchId, position, close, @@ -263,6 +267,27 @@ export function WorkflowSearchReplace() { ) const controlTargetMatches = activeMatch ? [activeMatch] : [] const usesResourceReplacement = controlTargetMatches.some(isConstrainedResourceMatch) + const resourceReplacementContextKey = + activeMatch && isConstrainedResourceMatch(activeMatch) + ? getWorkflowSearchMatchResourceGroupKey(activeMatch) + : null + const replacement = resourceReplacementContextKey + ? (resourceReplacementByContext[resourceReplacementContextKey] ?? '') + : textReplacement + const handleReplacementChange = useCallback( + (nextReplacement: string) => { + if (!resourceReplacementContextKey) { + setReplacement(nextReplacement) + return + } + + setResourceReplacementByContext((current) => ({ + ...current, + [resourceReplacementContextKey]: nextReplacement, + })) + }, + [resourceReplacementContextKey, setReplacement] + ) const compatibleResourceOptions = useMemo( () => getCompatibleResourceReplacementOptions(controlTargetMatches, resourceOptions), [controlTargetMatches, resourceOptions] @@ -525,7 +550,7 @@ export function WorkflowSearchReplace() { canReplaceAll={Boolean( eligibleMatchIds.length > 0 && hasReplacement && !allReplacementIssue )} - onReplacementChange={setReplacement} + onReplacementChange={handleReplacementChange} onReplaceActive={handleReplaceActive} onReplaceAll={handleReplaceAll} /> From dd630839b782eab973d93282673c341e93dce172 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 7 May 2026 20:17:04 -0700 Subject: [PATCH 11/11] fix padding --- .../editor/components/sub-block/sub-block.tsx | 15 ++++--- .../subflow-editor/subflow-editor.tsx | 43 ++++++++++++------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index ef0ed5a6100..db50e5b3200 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -72,6 +72,8 @@ const FOLDER_OVERRIDES: SelectorOverrides = { }, } +const WORKFLOW_SEARCH_CURRENT_MATCH_CLASS = 'rounded-md bg-orange-400 px-1 py-0.5' + /** * Interface for wand control handlers exposed by sub-block inputs */ @@ -230,6 +232,7 @@ const renderLabel = ( onCopy: () => void }, labelSuffix?: React.ReactNode, + isSearchHighlighted?: boolean, externalLink?: { show: boolean onClick: () => void @@ -249,7 +252,11 @@ const renderLabel = ( return (