fix(table): fix table boolean, add dynamic row number col size, search & replace imporvements (#4515)

* fix(table): fix table boolean, add dynamic row number col size, fix style for search and replace

* sidebar styling

* refactor(search-replace): extract duplicate WORKFLOW_SEARCH_HIGHLIGHT_CLASS to shared constants

* fix(tables): add missing onError toast to useAddTableColumn

* fix(tables): add consistent onError toast handlers across all table mutations

* subblock sweep

* more resources supported

* code restructuring

* better organization

* canonical modes search replace counterpart

* more cases

* tool inp edge case

* more subblock cases

* trigger canoncial index ops consolidation

* minor codec fix

* fix(tables): prevent double toast in workflow-sidebar and csv upload mutations

* resolver fix for quoted js literals

* code scan context

* triple quotes case

* escaped behaviour

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
Waleed
2026-05-08 10:39:36 -07:00
committed by GitHub
co-authored by Vikhyath Mondreti
parent eb871fc0de
commit 57f00e2a96
53 changed files with 6514 additions and 526 deletions
@@ -135,8 +135,6 @@ function ColumnConfigBody({
toast.success(`Saved "${trimmedName}"`)
onClose()
} catch (err) {
// Server validation errors carry a Zod issue array on the body; surface
// them inline next to the offending field instead of as a raw toast.
if (isValidationError(err)) {
const nameIssue =
findValidationIssue(err, ['updates', 'name']) ??
@@ -146,14 +144,14 @@ function ColumnConfigBody({
setNameError(nameIssue.message)
return
}
toast.error(toError(err).message)
}
toast.error(toError(err).message)
}
}
return (
<div className='flex h-full flex-col'>
<div className='flex items-center justify-between border-[var(--border)] border-b px-3 py-2'>
<div className='flex items-center justify-between border-[var(--border)] border-b px-3 py-[8.5px]'>
<h2 className='font-medium text-[var(--text-primary)] text-small'>Configure column</h2>
<Button
variant='ghost'
@@ -208,7 +208,11 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
case 'boolean':
return (
<div
className={cn('flex min-h-[20px] items-center justify-center', isEditing && 'invisible')}
data-boolean-cell-toggle
className={cn(
'flex min-h-[20px] w-full items-center justify-center',
isEditing && 'invisible'
)}
>
<Checkbox size='sm' checked={kind.checked} className='pointer-events-none' />
</div>
@@ -92,25 +92,18 @@ function rowSelectionCoversAll(sel: RowSelection, rows: TableRowType[]): boolean
const COL_WIDTH_MIN = 80
const COL_WIDTH_AUTO_FIT_MAX = 1000
// Wide enough to host the row-number + per-row run button side by side.
// Single-digit row numbers (rows 1–9) and multi-digit need to render with
// the play button at the same x-position so the column doesn't reflow
// row-by-row.
//
// Bucketed by the table's plan-derived `maxRows`, not the live count: a small
// table sized for ≤9,999 always renders the narrow gutter; an enterprise
// table sized up to 9,999,999 always renders the wide one. The gutter never
// changes width as rows are added.
//
// Tables without workflow columns drop the per-row run button (~28px), so
// the gutter shrinks accordingly.
const CHECKBOX_COL_WIDTH_SMALL_WITH_RUN = 48
const CHECKBOX_COL_WIDTH_SMALL_NUMBER_ONLY = 32
const CHECKBOX_COL_WIDTH_LARGE_WITH_RUN = 68
const CHECKBOX_COL_WIDTH_LARGE_NUMBER_ONLY = 52
/** Bucket boundary: tables sized for >9,999 rows get the wide gutter. */
const LARGE_ROW_NUMBER_THRESHOLD = 10000
const ADD_COL_WIDTH = 120
/** Returns sticky row-number column dimensions sized to the digit count of `maxRows`. */
function checkboxColLayout(
maxRows: number,
hasWorkflowCols: boolean
): { colWidth: number; numDivWidth: number } {
const digits = maxRows > 0 ? Math.floor(Math.log10(maxRows)) + 1 : 1
const numDivWidth = Math.max(20, digits * 8 + 4)
const colWidth = Math.max(32, numDivWidth + 8) + (hasWorkflowCols ? 16 : 0)
return { colWidth, numDivWidth }
}
const SKELETON_COL_COUNT = 4
const SKELETON_ROW_COUNT = 10
const ROW_HEIGHT_ESTIMATE = 35
@@ -128,7 +121,7 @@ const CELL_HEADER_CHECKBOX =
const CELL_CONTENT =
'relative flex h-[22px] min-w-0 items-center overflow-clip text-ellipsis whitespace-nowrap text-small'
const SELECTION_OVERLAY =
'pointer-events-none absolute -top-px -right-px -bottom-px -left-px z-[5] border-[2px] border-[var(--selection)]'
'pointer-events-none absolute -top-px -right-px -bottom-px z-[5] border-[2px] border-[var(--selection)]'
/**
* Snapshot of grid selection state the wrapper needs to render `<TableActionBar>`.
@@ -452,22 +445,10 @@ export function TableGrid({
}, [columns, columnOrder, tableWorkflowGroups])
const hasWorkflowColumns = columns.some((c) => !!c.workflowGroupId)
/**
* The sticky left column hosts the row number / checkbox always, plus a
* per-row run button only when the table has workflow columns. Width is
* picked from the table's plan-derived `maxRows` so a free-tier table
* (≤9,999) gets the narrow gutter and an enterprise table (up to
* 9,999,999) gets the wide one. Bucketed, not continuous, so the gutter
* never reflows as rows are added.
*/
const isLargeRowCountTable = (tableData?.maxRows ?? 0) >= LARGE_ROW_NUMBER_THRESHOLD
const checkboxColWidth = isLargeRowCountTable
? hasWorkflowColumns
? CHECKBOX_COL_WIDTH_LARGE_WITH_RUN
: CHECKBOX_COL_WIDTH_LARGE_NUMBER_ONLY
: hasWorkflowColumns
? CHECKBOX_COL_WIDTH_SMALL_WITH_RUN
: CHECKBOX_COL_WIDTH_SMALL_NUMBER_ONLY
const { colWidth: checkboxColWidth, numDivWidth } = checkboxColLayout(
tableData?.maxRows ?? 0,
hasWorkflowColumns
)
const headerGroups = useMemo(
() => buildHeaderGroups(displayColumns, tableWorkflowGroups),
@@ -3053,7 +3034,7 @@ export function TableGrid({
onRowToggle={handleRowToggle}
runningCount={runningByRowId.get(row.id) ?? 0}
hasWorkflowColumns={hasWorkflowColumns}
isLargeRowCountTable={isLargeRowCountTable}
numDivWidth={numDivWidth}
onStopRow={onStopRow}
onRunRow={handleRunRow}
workflowGroups={tableWorkflowGroups}
@@ -3178,8 +3159,8 @@ interface DataRowProps {
runningCount: number
/** Whether the table has at least one workflow column — controls whether a run/stop icon is rendered. */
hasWorkflowColumns: boolean
/** True for tables sized for >9,999 rows; widens the row-number slot to fit 5–7 digit numbers. */
isLargeRowCountTable: boolean
/** Width of the row-number inner div in px, derived from the table's maxRows digit count. */
numDivWidth: number
onStopRow: (rowId: string) => void
onRunRow: (rowId: string) => void
/**
@@ -3239,7 +3220,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
prev.onRowToggle !== next.onRowToggle ||
prev.runningCount !== next.runningCount ||
prev.hasWorkflowColumns !== next.hasWorkflowColumns ||
prev.isLargeRowCountTable !== next.isLargeRowCountTable ||
prev.numDivWidth !== next.numDivWidth ||
prev.onStopRow !== next.onStopRow ||
prev.onRunRow !== next.onRunRow ||
prev.workflowGroups !== next.workflowGroups
@@ -3281,7 +3262,7 @@ const DataRow = React.memo(function DataRow({
onRowToggle,
runningCount,
hasWorkflowColumns,
isLargeRowCountTable,
numDivWidth,
onStopRow,
onRunRow,
workflowGroups,
@@ -3313,12 +3294,15 @@ const DataRow = React.memo(function DataRow({
return (
<tr onContextMenu={(e) => onContextMenu(e, row)}>
<td className={cn(CELL_CHECKBOX, 'cursor-pointer')}>
<div className='flex items-center justify-between gap-1'>
<div
className={cn(
'flex items-center gap-1',
hasWorkflowColumns ? 'justify-between' : 'justify-center'
)}
>
<div
className={cn(
'group/checkbox flex h-[20px] shrink-0 items-center justify-end',
isLargeRowCountTable ? 'w-[40px]' : 'w-[20px]'
)}
className='group/checkbox flex h-[20px] shrink-0 items-center justify-center'
style={{ width: numDivWidth }}
onMouseDown={(e) => {
if (e.button !== 0) return
onRowToggle(rowIndex, e.shiftKey)
@@ -3326,7 +3310,7 @@ const DataRow = React.memo(function DataRow({
>
<span
className={cn(
'text-right text-[var(--text-tertiary)] text-xs tabular-nums',
'text-center text-[var(--text-tertiary)] text-xs tabular-nums',
isRowSelected ? 'hidden' : 'block group-hover/checkbox:hidden'
)}
>
@@ -3409,7 +3393,8 @@ const DataRow = React.memo(function DataRow({
{isHighlighted && (isMultiCell || isRowChecked) && (
<div
className={cn(
'-top-px -right-px -bottom-px -left-px pointer-events-none absolute z-[4]',
'-top-px -right-px -bottom-px pointer-events-none absolute z-[4]',
colIndex === 0 ? 'left-0' : '-left-px',
SELECTION_TINT_BG,
isFirstRow && isTopEdge && 'top-0',
isTopEdge && 'border-t border-t-[var(--selection)]',
@@ -3419,7 +3404,15 @@ const DataRow = React.memo(function DataRow({
)}
/>
)}
{isAnchor && <div className={cn(SELECTION_OVERLAY, isFirstRow && 'top-0')} />}
{isAnchor && (
<div
className={cn(
SELECTION_OVERLAY,
colIndex === 0 ? 'left-0' : '-left-px',
isFirstRow && 'top-0'
)}
/>
)}
<div className={CELL_CONTENT}>
<CellContent
value={
@@ -659,8 +659,8 @@ function WorkflowSidebarBody({
setNameError(nameIssue.message)
return
}
toast.error(toError(err).message)
}
toast.error(toError(err).message)
}
}
@@ -678,7 +678,7 @@ function WorkflowSidebarBody({
return (
<div className='flex h-full flex-col'>
<div className='flex items-center justify-between border-[var(--border)] border-b px-3 py-2'>
<div className='flex items-center justify-between border-[var(--border)] border-b px-3 py-[8.5px]'>
<h2 className='font-medium text-[var(--text-primary)] text-small'>
{titleByMode[config.mode]}
</h2>
@@ -0,0 +1,2 @@
export const WORKFLOW_SEARCH_HIGHLIGHT_CLASS =
'rounded-sm bg-orange-400 shadow-[3px_0_0_#fb923c,-3px_0_0_#fb923c]'
@@ -5,11 +5,13 @@ import { Button, Input, Textarea, Tooltip } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { WORKFLOW_SEARCH_HIGHLIGHT_CLASS } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { ActiveSearchTarget } from '@/stores/panel/editor/store'
interface EvalMetric {
id: string
@@ -27,6 +29,7 @@ interface EvalInputProps {
isPreview?: boolean
previewValue?: EvalMetric[] | null
disabled?: boolean
activeSearchTarget?: ActiveSearchTarget | null
}
// Default values
@@ -43,6 +46,7 @@ export function EvalInput({
isPreview = false,
previewValue,
disabled = false,
activeSearchTarget,
}: EvalInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue<EvalMetric[]>(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
@@ -67,6 +71,17 @@ export function EvalInput({
const defaultMetric = useMemo(() => createDefaultMetric(), [])
const metrics: EvalMetric[] = value || [defaultMetric]
const isNestedSearchHighlighted = (metricIndex: number, metricPath: Array<string | number>) =>
activeSearchTarget?.subBlockId === subBlockId &&
activeSearchTarget.valuePath[0] === metricIndex &&
metricPath.every((segment, index) => activeSearchTarget.valuePath[index + 1] === segment)
const renderFieldLabel = (label: string, highlighted: boolean) => (
<Label className='text-small'>
{highlighted ? <mark className={WORKFLOW_SEARCH_HIGHLIGHT_CLASS}>{label}</mark> : label}
</Label>
)
const addMetric = () => {
if (isPreview || disabled) return
@@ -176,7 +191,7 @@ export function EvalInput({
<div className='flex flex-col gap-2 border-[var(--border-1)] px-2.5 pt-1.5 pb-2.5'>
<div key={`name-${metric.id}`} className='flex flex-col gap-1.5'>
<Label className='text-small'>Name</Label>
{renderFieldLabel('Name', isNestedSearchHighlighted(index, ['name']))}
<Input
name='name'
value={metric.name}
@@ -187,7 +202,7 @@ export function EvalInput({
</div>
<div key={`description-${metric.id}`} className='flex flex-col gap-1.5'>
<Label className='text-small'>Description</Label>
{renderFieldLabel('Description', isNestedSearchHighlighted(index, ['description']))}
<div className='relative'>
{(() => {
const fieldState = inputController.fieldHelpers.getFieldState(metric.id)
@@ -259,7 +274,7 @@ export function EvalInput({
<div key={`range-${metric.id}`} className='grid grid-cols-2 gap-2'>
<div className='flex flex-col gap-1.5'>
<Label className='text-small'>Min Value</Label>
{renderFieldLabel('Min Value', isNestedSearchHighlighted(index, ['range', 'min']))}
<Input
type='text'
value={metric.range.min}
@@ -272,7 +287,7 @@ export function EvalInput({
/>
</div>
<div className='flex flex-col gap-1.5'>
<Label className='text-small'>Max Value</Label>
{renderFieldLabel('Max Value', isNestedSearchHighlighted(index, ['range', 'max']))}
<Input
type='text'
value={metric.range.max}
@@ -1,4 +1,5 @@
import { ResponseFormat as SharedResponseFormat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format'
import type { ActiveSearchTarget } from '@/stores/panel/editor/store'
interface ResponseFormatProps {
blockId: string
@@ -7,6 +8,7 @@ interface ResponseFormatProps {
previewValue?: any
disabled?: boolean
config?: any
activeSearchTarget?: ActiveSearchTarget | null
}
export function ResponseFormat({
@@ -16,6 +18,7 @@ export function ResponseFormat({
previewValue,
disabled = false,
config,
activeSearchTarget,
}: ResponseFormatProps) {
return (
<SharedResponseFormat
@@ -25,6 +28,7 @@ export function ResponseFormat({
previewValue={previewValue}
disabled={disabled}
config={config}
activeSearchTarget={activeSearchTarget}
/>
)
}
@@ -46,6 +46,8 @@ interface ShortInputProps {
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
/** Whether to hide the internal wand button (controlled by parent) */
hideInternalWand?: boolean
/** Whether workflow search is actively highlighting this input */
isSearchHighlighted?: boolean
}
/**
@@ -74,6 +76,7 @@ export const ShortInput = memo(function ShortInput({
useWebhookUrl = false,
wandControlRef,
hideInternalWand = false,
isSearchHighlighted = false,
}: ShortInputProps) {
const [localContent, setLocalContent] = useState<string>('')
const [isFocused, setIsFocused] = useState(false)
@@ -332,16 +335,15 @@ export const ShortInput = memo(function ShortInput({
? webhookManagement.webhookUrl
: ctrlValue
const displayValue =
password && !isFocused ? '•'.repeat(actualValue?.length ?? 0) : actualValue
const shouldMask = password && !isFocused && !isSearchHighlighted
const displayValue = shouldMask ? '•'.repeat(actualValue?.length ?? 0) : actualValue
const formattedText =
password && !isFocused
? '•'.repeat(actualValue?.length ?? 0)
: formatDisplayText(actualValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})
const formattedText = shouldMask
? '•'.repeat(actualValue?.length ?? 0)
: formatDisplayText(actualValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})
return (
<>
@@ -20,11 +20,13 @@ import {
} from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { WORKFLOW_SEARCH_HIGHLIGHT_CLASS } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { ActiveSearchTarget } from '@/stores/panel/editor/store'
interface Field {
id: string
@@ -49,6 +51,7 @@ interface FieldFormatProps {
valuePlaceholder?: string
descriptionPlaceholder?: string
config?: any
activeSearchTarget?: ActiveSearchTarget | null
}
/**
@@ -103,6 +106,7 @@ export function FieldFormat({
showDescription = false,
valuePlaceholder = 'Enter default value',
descriptionPlaceholder = 'Describe this field',
activeSearchTarget,
}: FieldFormatProps) {
const [storeValue, setStoreValue] = useSubBlockValue<Field[]>(blockId, subBlockId)
const valueInputRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement>>({})
@@ -127,6 +131,17 @@ export function FieldFormat({
const fields: Field[] = Array.isArray(value) && value.length > 0 ? value : [createDefaultField()]
const isReadOnly = isPreview || disabled
const isNestedSearchHighlighted = (fieldIndex: number, fieldKey: keyof Field) =>
activeSearchTarget?.subBlockId === subBlockId &&
activeSearchTarget.valuePath[0] === fieldIndex &&
activeSearchTarget.valuePath.at(-1) === fieldKey
const renderFieldLabel = (label: string, highlighted: boolean) => (
<Label className='text-small'>
{highlighted ? <mark className={WORKFLOW_SEARCH_HIGHLIGHT_CLASS}>{label}</mark> : label}
</Label>
)
/**
* Adds a new field to the list
*/
@@ -555,13 +570,13 @@ export function FieldFormat({
<ExpandableContent>
<div className='flex flex-col gap-2 rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 pt-1.5 pb-2.5'>
<div className='flex flex-col gap-1.5'>
<Label className='text-small'>Name</Label>
{renderFieldLabel('Name', isNestedSearchHighlighted(index, 'name'))}
<div className='relative'>{renderNameInput(field)}</div>
</div>
{showType && (
<div className='flex flex-col gap-1.5'>
<Label className='text-small'>Type</Label>
{renderFieldLabel('Type', isNestedSearchHighlighted(index, 'type'))}
<Combobox
options={TYPE_OPTIONS}
value={field.type}
@@ -573,7 +588,10 @@ export function FieldFormat({
{showDescription && (
<div className='flex flex-col gap-1.5'>
<Label className='text-small'>Description</Label>
{renderFieldLabel(
'Description',
isNestedSearchHighlighted(index, 'description')
)}
<Input
value={field.description ?? ''}
onChange={(e) => updateField(field.id, 'description', e.target.value)}
@@ -585,7 +603,7 @@ export function FieldFormat({
{showValue && (
<div className='flex flex-col gap-1.5'>
<Label className='text-small'>Value</Label>
{renderFieldLabel('Value', isNestedSearchHighlighted(index, 'value'))}
<div className='relative'>{renderValueInput(field)}</div>
</div>
)}
@@ -77,6 +77,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import {
formatParameterLabel,
getSubBlocksForToolInput,
getToolIdForOperation,
getToolParametersConfig,
isPasswordParameter,
type SubBlocksForToolInput,
@@ -383,36 +384,6 @@ function getOperationOptions(blockType: string): { label: string; id: string }[]
})
}
/**
* Gets the correct tool ID for a given operation.
*
* @param blockType - The block type
* @param operation - The selected operation (for multi-operation tools)
* @returns The tool ID to use for execution, or `undefined` if not found
*/
function getToolIdForOperation(blockType: string, operation?: string): string | undefined {
const block = getAllBlocks().find((b) => b.type === blockType)
if (!block || !block.tools?.access) return undefined
if (block.tools.access.length === 1) {
return block.tools.access[0]
}
if (operation && block.tools?.config?.tool) {
try {
return block.tools.config.tool({ operation })
} catch (error) {
logger.error('Error selecting tool for operation:', error)
}
}
if (operation && block.tools.access.includes(operation)) {
return operation
}
return block.tools.access[0]
}
/**
* Creates a styled icon element for tool items in the selection dropdown.
*
@@ -1,36 +1 @@
/**
* Represents a tool selected and configured in the workflow
*
* @remarks
* Valid types include:
* - Standard block types (e.g., 'api', 'search', 'function')
* - 'custom-tool': User-defined tools with custom code
* - 'mcp': Individual MCP tool from a connected server
*
* For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded.
* Everything else (title, schema, code) is loaded dynamically from the database.
* Legacy custom tools with inline schema/code are still supported for backwards compatibility.
*/
export interface StoredTool {
/** Block type identifier */
type: string
/** Display title for the tool (optional for new custom tool format) */
title?: string
/** Direct tool ID for execution (optional for new custom tool format) */
toolId?: string
/** Parameter values configured by the user */
params?: Record<string, string>
/** Whether the tool details are expanded in UI */
isExpanded?: boolean
/** Database ID for custom tools (new format - reference only) */
customToolId?: string
/** Tool schema for custom tools (legacy format - inline JSON schema) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema?: Record<string, any>
/** Implementation code for custom tools (legacy format - inline) */
code?: string
/** Selected operation for multi-operation tools */
operation?: string
/** Tool usage control mode for LLM */
usageControl?: 'auto' | 'force' | 'none'
}
export type { StoredTool } from '@/lib/workflows/tool-input/types'
@@ -6,6 +6,8 @@ import { useStoreWithEqualityFn } from 'zustand/traditional'
import {
buildCanonicalIndex,
isNonEmptyValue,
normalizeDependencyValue,
parseDependsOn,
resolveDependencyValue,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks/registry'
@@ -14,35 +16,6 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
type DependsOnConfig = string[] | { all?: string[]; any?: string[] }
/**
* Parses dependsOn config and returns normalized all/any arrays
*/
function parseDependsOn(dependsOn: DependsOnConfig | undefined): {
allFields: string[]
anyFields: string[]
allDependsOnFields: string[]
} {
if (!dependsOn) {
return { allFields: [], anyFields: [], allDependsOnFields: [] }
}
if (Array.isArray(dependsOn)) {
// Simple array format: all fields required (AND logic)
return { allFields: dependsOn, anyFields: [], allDependsOnFields: dependsOn }
}
// Object format with all/any
const allFields = dependsOn.all || []
const anyFields = dependsOn.any || []
return {
allFields,
anyFields,
allDependsOnFields: [...allFields, ...anyFields],
}
}
/**
* Centralized dependsOn gating for sub-block components.
* - Computes dependency values from the active workflow/block
@@ -76,29 +49,6 @@ export function useDependsOnGate(
// For backward compatibility, expose flat list of all dependency fields
const dependsOn = allDependsOnFields
const normalizeDependencyValue = (rawValue: unknown): unknown => {
if (rawValue === null || rawValue === undefined) return null
if (typeof rawValue === 'object') {
if (Array.isArray(rawValue)) {
if (rawValue.length === 0) return null
return rawValue.map((item) => normalizeDependencyValue(item))
}
const record = rawValue as Record<string, any>
if ('value' in record) {
return normalizeDependencyValue(record.value)
}
if ('id' in record) {
return record.id
}
return record
}
return rawValue
}
const dependencySelector = useCallback(
(state: ReturnType<typeof useSubBlockStore.getState>) => {
if (allDependsOnFields.length === 0) return {} as Record<string, unknown>
@@ -54,6 +54,8 @@ import { MODAL_REGISTRY } from '@/app/workspace/[workspaceId]/w/[workflowId]/com
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import type { SubBlockConfig } from '@/blocks/types'
import { useWebhookManagement } from '@/hooks/use-webhook-management'
import type { ActiveSearchTarget } from '@/stores/panel/editor/store'
import { WORKFLOW_SEARCH_HIGHLIGHT_CLASS } from '../constants'
const SLACK_OVERRIDES: SelectorOverrides = {
transformContext: (context, deps) => {
@@ -72,7 +74,17 @@ const FOLDER_OVERRIDES: SelectorOverrides = {
},
}
const WORKFLOW_SEARCH_CURRENT_MATCH_CLASS = 'rounded-md bg-orange-400 px-1 py-0.5'
function hasNestedWorkflowSearchHighlight(
config: SubBlockConfig,
activeSearchTarget?: ActiveSearchTarget | null
) {
if (!activeSearchTarget || activeSearchTarget.valuePath.length === 0) return false
return (
config.type === 'input-format' ||
config.type === 'response-format' ||
config.type === 'eval-input'
)
}
/**
* Interface for wand control handlers exposed by sub-block inputs
@@ -106,6 +118,7 @@ interface SubBlockProps {
/** Provides sibling values for dependency resolution in non-preview contexts (e.g. tool-input) */
dependencyContext?: Record<string, unknown>
isSearchHighlighted?: boolean
activeSearchTarget?: ActiveSearchTarget | null
}
/**
@@ -253,7 +266,7 @@ const renderLabel = (
<div className='flex items-center justify-between gap-1.5 pl-0.5'>
<Label className='flex items-baseline gap-1.5 whitespace-nowrap'>
{isSearchHighlighted ? (
<mark className={WORKFLOW_SEARCH_CURRENT_MATCH_CLASS}>{config.title}</mark>
<mark className={WORKFLOW_SEARCH_HIGHLIGHT_CLASS}>{config.title}</mark>
) : (
config.title
)}
@@ -445,6 +458,7 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
canonicalToggleEqual &&
prevProps.labelSuffix === nextProps.labelSuffix &&
prevProps.isSearchHighlighted === nextProps.isSearchHighlighted &&
prevProps.activeSearchTarget === nextProps.activeSearchTarget &&
prevProps.dependencyContext === nextProps.dependencyContext
)
}
@@ -462,6 +476,7 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
* @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
* @param activeSearchTarget - Active workflow search target for nested field highlighting
*/
function SubBlockComponent({
blockId,
@@ -474,6 +489,7 @@ function SubBlockComponent({
labelSuffix,
dependencyContext,
isSearchHighlighted,
activeSearchTarget,
}: SubBlockProps): JSX.Element {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -655,6 +671,7 @@ function SubBlockComponent({
disabled={isDisabled}
wandControlRef={wandControlRef}
hideInternalWand={true}
isSearchHighlighted={isSearchHighlighted}
/>
)
@@ -886,6 +903,7 @@ function SubBlockComponent({
isPreview={isPreview}
previewValue={previewValue as any}
disabled={isDisabled}
activeSearchTarget={activeSearchTarget}
/>
)
@@ -1014,6 +1032,7 @@ function SubBlockComponent({
disabled={isDisabled}
config={config}
showValue={true}
activeSearchTarget={activeSearchTarget}
/>
)
@@ -1049,6 +1068,7 @@ function SubBlockComponent({
previewValue={previewValue}
config={config}
disabled={isDisabled}
activeSearchTarget={activeSearchTarget}
/>
)
@@ -1175,6 +1195,9 @@ function SubBlockComponent({
}
}
const highlightParentLabel =
isSearchHighlighted && !hasNestedWorkflowSearchHighlight(config, activeSearchTarget)
return (
<div
onMouseDown={handleMouseDown}
@@ -1208,7 +1231,7 @@ function SubBlockComponent({
onCopy: handleCopy,
},
labelSuffix,
isSearchHighlighted,
highlightParentLabel,
externalLink
)}
{renderInput()}
@@ -10,8 +10,7 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
import type { ConnectedBlock } from '../../hooks/use-block-connections'
import { useSubflowEditor } from '../../hooks/use-subflow-editor'
import { ConnectionBlocks } from '../connection-blocks'
const WORKFLOW_SEARCH_CURRENT_MATCH_CLASS = 'rounded-md bg-orange-400 px-1 py-0.5'
import { WORKFLOW_SEARCH_HIGHLIGHT_CLASS } from '../constants'
interface SubflowEditorProps {
currentBlock: BlockState
@@ -93,7 +92,7 @@ export function SubflowEditor({
>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
{isTypeHighlighted ? (
<mark className={WORKFLOW_SEARCH_CURRENT_MATCH_CLASS}>
<mark className={WORKFLOW_SEARCH_HIGHLIGHT_CLASS}>
{currentBlock.type === 'loop' ? 'Loop Type' : 'Parallel Type'}
</mark>
) : currentBlock.type === 'loop' ? (
@@ -128,7 +127,7 @@ export function SubflowEditor({
>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
{isConfigHighlighted ? (
<mark className={WORKFLOW_SEARCH_CURRENT_MATCH_CLASS}>
<mark className={WORKFLOW_SEARCH_HIGHLIGHT_CLASS}>
{isCountMode
? `${currentBlock.type === 'loop' ? 'Loop' : 'Parallel'} Iterations`
: isConditionMode
@@ -24,6 +24,7 @@ import {
hasAdvancedValues,
isCanonicalPair,
resolveCanonicalMode,
shouldUseSubBlockForTriggerModeCanonicalIndex,
} from '@/lib/workflows/subblocks/visibility'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
@@ -47,7 +48,6 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types'
import { useFolderMap } from '@/hooks/queries/folders'
import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree'
import { useWorkflowMap, useWorkflowState } from '@/hooks/queries/workflows'
@@ -154,12 +154,7 @@ export function Editor() {
const subBlocksForCanonical = useMemo(() => {
const subBlocks = blockConfig?.subBlocks || []
if (!triggerMode) return subBlocks
return subBlocks.filter(
(subBlock) =>
subBlock.mode === 'trigger' ||
subBlock.mode === 'trigger-advanced' ||
subBlock.type === ('trigger-config' as SubBlockType)
)
return subBlocks.filter(shouldUseSubBlockForTriggerModeCanonicalIndex)
}, [blockConfig?.subBlocks, triggerMode])
const canonicalIndex = useMemo(
@@ -625,6 +620,7 @@ export function Editor() {
activeSearchTarget.canonicalSubBlockId ===
(subBlock.canonicalParamId ?? subBlock.id))
}
activeSearchTarget={activeSearchTarget}
canonicalToggle={
isCanonicalSwap && canonicalMode && canonicalId
? {
@@ -699,6 +695,7 @@ export function Editor() {
activeSearchTarget.canonicalSubBlockId ===
(subBlock.canonicalParamId ?? subBlock.id))
}
activeSearchTarget={activeSearchTarget}
/>
{index < advancedOnlySubBlocks.length - 1 && (
<FieldDivider subblockMarker />
@@ -5,8 +5,10 @@ import {
isSubBlockFeatureEnabled,
isSubBlockHidden,
isSubBlockVisibleForMode,
isSubBlockVisibleForTriggerMode,
shouldUseSubBlockForTriggerModeCanonicalIndex,
} from '@/lib/workflows/subblocks/visibility'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useReactiveConditions } from '@/hooks/use-reactive-conditions'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
@@ -100,12 +102,7 @@ export function useEditorSubblockLayout(
)
const subBlocksForCanonical = displayTriggerMode
? (config.subBlocks || []).filter(
(subBlock) =>
subBlock.mode === 'trigger' ||
subBlock.mode === 'trigger-advanced' ||
subBlock.type === ('trigger-config' as SubBlockType)
)
? (config.subBlocks || []).filter(shouldUseSubBlockForTriggerModeCanonicalIndex)
: config.subBlocks || []
const canonicalIndex = buildCanonicalIndex(subBlocksForCanonical)
const effectiveAdvanced = displayAdvancedMode
@@ -132,19 +129,7 @@ export function useEditorSubblockLayout(
// Hide tool API key fields when hosted or when env var is set
if (isSubBlockHidden(block)) return false
// Special handling for trigger-config type (legacy trigger configuration UI)
if (block.type === ('trigger-config' as SubBlockType)) {
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
return displayTriggerMode || isPureTriggerBlock
}
// Filter by mode if specified
if (block.mode === 'trigger' || block.mode === 'trigger-advanced') {
if (!displayTriggerMode) return false
}
// When in trigger mode, hide blocks that don't have mode: 'trigger' or 'trigger-advanced'
if (displayTriggerMode && block.mode !== 'trigger' && block.mode !== 'trigger-advanced') {
if (!isSubBlockVisibleForTriggerMode(block, displayTriggerMode, config)) {
return false
}
@@ -6,9 +6,13 @@ import type {
import { usePersonalEnvironment, useWorkspaceEnvironment } from '@/hooks/queries/environment'
import {
flattenWorkflowSearchReplacementOptions,
useWorkflowSearchFileReplacementOptions,
useWorkflowSearchKnowledgeReplacementOptions,
useWorkflowSearchMcpServerReplacementOptions,
useWorkflowSearchMcpToolReplacementOptions,
useWorkflowSearchOAuthReplacementOptions,
useWorkflowSearchSelectorReplacementOptions,
useWorkflowSearchTableReplacementOptions,
} from '@/hooks/queries/workflow-search-replace'
interface UseWorkflowResourceReplacementOptionsParams {
@@ -23,8 +27,12 @@ export function useWorkflowResourceReplacementOptions({
workflowId,
}: UseWorkflowResourceReplacementOptionsParams): WorkflowSearchReplacementOption[] {
const oauthOptions = useWorkflowSearchOAuthReplacementOptions(matches, workspaceId, workflowId)
const knowledgeOptions = useWorkflowSearchKnowledgeReplacementOptions(workspaceId)
const knowledgeOptions = useWorkflowSearchKnowledgeReplacementOptions(matches, workspaceId)
const selectorOptions = useWorkflowSearchSelectorReplacementOptions(matches)
const tableOptions = useWorkflowSearchTableReplacementOptions(matches, workspaceId)
const fileOptions = useWorkflowSearchFileReplacementOptions(matches, workspaceId)
const mcpServerOptions = useWorkflowSearchMcpServerReplacementOptions(matches, workspaceId)
const mcpToolOptions = useWorkflowSearchMcpToolReplacementOptions(matches, workspaceId)
const { data: personalEnvironment } = usePersonalEnvironment()
const { data: workspaceEnvironment } = useWorkspaceEnvironment(workspaceId ?? '')
@@ -46,6 +54,20 @@ export function useWorkflowResourceReplacementOptions({
...flattenWorkflowSearchReplacementOptions(oauthOptions),
...flattenWorkflowSearchReplacementOptions(knowledgeOptions),
...flattenWorkflowSearchReplacementOptions(selectorOptions),
...flattenWorkflowSearchReplacementOptions(tableOptions),
...flattenWorkflowSearchReplacementOptions(fileOptions),
...flattenWorkflowSearchReplacementOptions(mcpServerOptions),
...flattenWorkflowSearchReplacementOptions(mcpToolOptions),
]
}, [knowledgeOptions, oauthOptions, personalEnvironment, selectorOptions, workspaceEnvironment])
}, [
fileOptions,
knowledgeOptions,
mcpServerOptions,
mcpToolOptions,
oauthOptions,
personalEnvironment,
selectorOptions,
tableOptions,
workspaceEnvironment,
])
}
@@ -1,11 +1,16 @@
import { useMemo } from 'react'
import { getWorkflowSearchMatchResourceGroupKey } from '@/lib/workflows/search-replace/resource-resolvers'
import { getWorkflowSearchMatchResourceGroupKey } from '@/lib/workflows/search-replace/resources'
import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
import { usePersonalEnvironment, useWorkspaceEnvironment } from '@/hooks/queries/environment'
import {
useWorkflowSearchFileDetails,
useWorkflowSearchKnowledgeBaseDetails,
useWorkflowSearchMcpServerDetails,
useWorkflowSearchMcpToolDetails,
useWorkflowSearchOAuthCredentialDetails,
useWorkflowSearchSelectorDetails,
useWorkflowSearchTableDetails,
type WorkflowSearchResolvedResource,
} from '@/hooks/queries/workflow-search-replace'
export interface HydratedWorkflowSearchMatch extends WorkflowSearchMatch {
@@ -28,6 +33,10 @@ export function useWorkflowSearchReferenceHydration({
const oauthDetails = useWorkflowSearchOAuthCredentialDetails(matches, workflowId)
const knowledgeDetails = useWorkflowSearchKnowledgeBaseDetails(matches)
const selectorDetails = useWorkflowSearchSelectorDetails(matches)
const tableDetails = useWorkflowSearchTableDetails(matches, workspaceId)
const fileDetails = useWorkflowSearchFileDetails(matches, workspaceId)
const mcpServerDetails = useWorkflowSearchMcpServerDetails(matches, workspaceId)
const mcpToolDetails = useWorkflowSearchMcpToolDetails(matches, workspaceId)
const { data: personalEnvironment } = usePersonalEnvironment()
const { data: workspaceEnvironment } = useWorkspaceEnvironment(workspaceId ?? '')
@@ -41,7 +50,7 @@ export function useWorkflowSearchReferenceHydration({
{ label: string; resolved: boolean; inaccessible: boolean }
>()
const setResolvedLabel = (query: (typeof oauthDetails)[number]) => {
const setResolvedLabel = (query: { data?: WorkflowSearchResolvedResource }) => {
if (!query.data) return
const value = {
label: query.data.label,
@@ -60,6 +69,10 @@ export function useWorkflowSearchReferenceHydration({
oauthDetails.forEach(setResolvedLabel)
knowledgeDetails.forEach(setResolvedLabel)
selectorDetails.forEach(setResolvedLabel)
tableDetails.forEach(setResolvedLabel)
fileDetails.forEach(setResolvedLabel)
mcpServerDetails.forEach(setResolvedLabel)
mcpToolDetails.forEach(setResolvedLabel)
const personalKeys = new Set(Object.keys(personalEnvironment ?? {}))
const workspaceKeys = new Set(Object.keys(workspaceEnvironment?.workspace ?? {}))
@@ -96,11 +109,15 @@ export function useWorkflowSearchReferenceHydration({
}
})
}, [
fileDetails,
knowledgeDetails,
matches,
mcpServerDetails,
mcpToolDetails,
oauthDetails,
personalEnvironment,
selectorDetails,
tableDetails,
workspaceEnvironment,
])
}
@@ -32,30 +32,28 @@ export function ReplacementControls({
}: ReplacementControlsProps) {
return (
<div className='space-y-2'>
<div className='space-y-1.5'>
{usesResourceReplacement ? (
<Combobox
options={compatibleResourceOptions.map((option) => ({
label: option.label,
value: option.value,
}))}
value={replacement}
onChange={onReplacementChange}
placeholder='Choose replacement...'
searchable
searchPlaceholder='Search resources...'
emptyMessage='No valid replacements available'
disabled={disabled || compatibleResourceOptions.length === 0}
/>
) : (
<Input
value={replacement}
placeholder='Replace'
disabled={disabled}
onChange={(event) => onReplacementChange(event.target.value)}
/>
)}
</div>
{usesResourceReplacement ? (
<Combobox
options={compatibleResourceOptions.map((option) => ({
label: option.label,
value: option.value,
}))}
value={replacement}
onChange={onReplacementChange}
placeholder='Choose replacement...'
searchable
searchPlaceholder='Search resources...'
emptyMessage='No valid replacements available'
disabled={disabled || compatibleResourceOptions.length === 0}
/>
) : (
<Input
value={replacement}
placeholder='Replace'
disabled={disabled}
onChange={(event) => onReplacementChange(event.target.value)}
/>
)}
<div className='flex items-center justify-between gap-2'>
<span className='text-[var(--text-muted)] text-xs'>
@@ -7,17 +7,15 @@ 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 {
getCompatibleResourceReplacementOptions,
getWorkflowSearchReplacementIssue,
isConstrainedResourceMatch,
} from '@/lib/workflows/search-replace/replacement-validation'
import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements'
import {
getCompatibleResourceReplacementOptions,
getWorkflowSearchCompatibleResourceMatches,
getWorkflowSearchMatchResourceGroupKey,
getWorkflowSearchReplacementIssue,
isConstrainedResourceMatch,
workflowSearchMatchMatchesQuery,
} from '@/lib/workflows/search-replace/resource-resolvers'
} from '@/lib/workflows/search-replace/resources'
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'
@@ -33,6 +31,7 @@ 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 { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useFolderMap } from '@/hooks/queries/folders'
import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree'
import { useWorkflowMap } from '@/hooks/queries/workflows'
@@ -127,6 +126,7 @@ export function WorkflowSearchReplace() {
setReplacement,
setActiveMatchId,
} = useWorkflowSearchReplaceStore()
const { data: workspaceCredentials } = useWorkspaceCredentials({ workspaceId, enabled: isOpen })
useRegisterGlobalCommands([
createCommand({
@@ -151,6 +151,14 @@ export function WorkflowSearchReplace() {
[currentWorkflow.blocks, currentWorkflow.isSnapshotView, workflowSubblockValues]
)
const credentialTypeById = useMemo(
() =>
Object.fromEntries(
(workspaceCredentials ?? []).map((credential) => [credential.id, credential.type])
),
[workspaceCredentials]
)
const matches = useMemo(
() =>
indexWorkflowSearchMatches({
@@ -163,9 +171,11 @@ export function WorkflowSearchReplace() {
readonlyReason,
workspaceId,
workflowId,
credentialTypeById,
}),
[
currentWorkflow.isSnapshotView,
credentialTypeById,
query,
readonlyReason,
searchBlocks,
@@ -481,7 +491,7 @@ export function WorkflowSearchReplace() {
onMouseDown={handleMouseDown}
>
<div className='flex min-w-0 items-center'>
<span className='truncate font-medium text-[var(--text-primary)] text-sm'>
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
Search and replace
</span>
</div>
@@ -490,8 +500,8 @@ export function WorkflowSearchReplace() {
onMouseDown={(event) => event.stopPropagation()}
>
<span className='text-[var(--text-muted)] text-xs'>{matchCountLabel}</span>
<Button variant='ghost' className='!p-1.5 -m-1.5' onClick={close}>
<X className='h-[16px] w-[16px]' />
<Button variant='ghost' className='h-[26px] w-[26px] p-0' onClick={close}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
@@ -504,7 +514,10 @@ export function WorkflowSearchReplace() {
onClick={() => setIsReplaceExpanded((expanded) => !expanded)}
>
<ChevronRight
className={cn('h-4 w-4 transition-transform', isReplaceExpanded && 'rotate-90')}
className={cn(
'h-[14px] w-[14px] text-[var(--text-icon)] transition-transform',
isReplaceExpanded && 'rotate-90'
)}
/>
</Button>
<Input
@@ -524,7 +537,7 @@ export function WorkflowSearchReplace() {
disabled={hydratedMatches.length === 0}
onClick={() => handleMoveActiveMatch(-1)}
>
<ChevronUp className='h-4 w-4' />
<ChevronUp className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</Button>
<Button
variant='ghost'
@@ -532,7 +545,7 @@ export function WorkflowSearchReplace() {
disabled={hydratedMatches.length === 0}
onClick={() => handleMoveActiveMatch(1)}
>
<ChevronDown className='h-4 w-4' />
<ChevronDown className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</Button>
{isReplaceExpanded && (
+1
View File
@@ -14,6 +14,7 @@ export const WorkflowBlock: BlockConfig = {
id: 'workflowId',
title: 'Select Workflow',
type: 'workflow-selector',
selectorKey: 'sim.workflows',
placeholder: 'Search workflows...',
required: true,
},
+1
View File
@@ -19,6 +19,7 @@ export const WorkflowInputBlock: BlockConfig = {
id: 'workflowId',
title: 'Select Workflow',
type: 'workflow-selector',
selectorKey: 'sim.workflows',
placeholder: 'Search workflows...',
required: true,
},
@@ -99,6 +99,163 @@ describe('VariableResolver function block inputs', () => {
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
})
it('breaks JavaScript string literals around quoted block references', () => {
const { block, ctx, resolver } = createResolver('javascript')
const result = resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: "const rawEmail = '<Producer.result>';\nreturn rawEmail" },
block
)
expect(result.resolvedInputs.code).toBe(
"const rawEmail = '' + JSON.stringify(globalThis[\"__blockRef_0\"]) + '';\nreturn rawEmail"
)
expect(result.displayInputs.code).toBe('const rawEmail = \'"hello world"\';\nreturn rawEmail')
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
})
it('uses template interpolation for JavaScript template literal block references', () => {
const { block, ctx, resolver } = createResolver('javascript')
const result = resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: 'return `value: <Producer.result>`' },
block
)
expect(result.resolvedInputs.code).toBe(
'return `value: ${JSON.stringify(globalThis["__blockRef_0"])}`'
)
expect(result.displayInputs.code).toBe('return `value: "hello world"`')
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
})
it('keeps JavaScript block references inside template expressions executable', () => {
const { block, ctx, resolver } = createResolver('javascript')
const result = resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: 'return `${String(<Producer.result>)}`' },
block
)
expect(result.resolvedInputs.code).toBe('return `${String(globalThis["__blockRef_0"])}`')
expect(result.displayInputs.code).toBe('return `${String("hello world")}`')
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
})
it('ignores JavaScript comment quotes before later block references', () => {
const { block, ctx, resolver } = createResolver('javascript')
const result = resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: "// don't confuse quote tracking\nreturn <Producer.result>" },
block
)
expect(result.resolvedInputs.code).toBe(
'// don\'t confuse quote tracking\nreturn globalThis["__blockRef_0"]'
)
expect(result.displayInputs.code).toBe('// don\'t confuse quote tracking\nreturn "hello world"')
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
})
it('breaks Python string literals around quoted block references', () => {
const { block, ctx, resolver } = createResolver('python')
const result = resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: "raw_email = '<Producer.result>'\nreturn raw_email" },
block
)
expect(result.resolvedInputs.code).toBe(
"raw_email = '' + json.dumps(globals()[\"__blockRef_0\"]) + ''\nreturn raw_email"
)
expect(result.displayInputs.code).toBe('raw_email = \'"hello world"\'\nreturn raw_email')
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
})
it('breaks Python triple-double-quoted strings around block references', () => {
const { block, ctx, resolver } = createResolver('python')
const result = resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: 'prompt = """\nSummary: <Producer.result>\n"""\nreturn prompt' },
block
)
expect(result.resolvedInputs.code).toBe(
'prompt = """\nSummary: """ + json.dumps(globals()["__blockRef_0"]) + """\n"""\nreturn prompt'
)
expect(result.displayInputs.code).toBe(
'prompt = """\nSummary: "hello world"\n"""\nreturn prompt'
)
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
})
it('ignores escaped triple-double quotes before later Python block references', () => {
const { block, ctx, resolver } = createResolver('python')
const result = resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: 'prompt = """Escaped delimiter: \\"\\"\\"\nSummary: <Producer.result>\n"""' },
block
)
expect(result.resolvedInputs.code).toBe(
'prompt = """Escaped delimiter: \\"\\"\\"\nSummary: """ + json.dumps(globals()["__blockRef_0"]) + """\n"""'
)
expect(result.displayInputs.code).toBe(
'prompt = """Escaped delimiter: \\"\\"\\"\nSummary: "hello world"\n"""'
)
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
})
it('breaks Python triple-single-quoted strings around block references', () => {
const { block, ctx, resolver } = createResolver('python')
const result = resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: "prompt = '''\nSummary: <Producer.result>\n'''\nreturn prompt" },
block
)
expect(result.resolvedInputs.code).toBe(
"prompt = '''\nSummary: ''' + json.dumps(globals()[\"__blockRef_0\"]) + '''\n'''\nreturn prompt"
)
expect(result.displayInputs.code).toBe(
"prompt = '''\nSummary: \"hello world\"\n'''\nreturn prompt"
)
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
})
it('ignores Python comment quotes before later block references', () => {
const { block, ctx, resolver } = createResolver('python')
const result = resolver.resolveInputsForFunctionBlock(
ctx,
'function',
{ code: "# don't confuse quote tracking\nreturn <Producer.result>" },
block
)
expect(result.resolvedInputs.code).toBe(
'# don\'t confuse quote tracking\nreturn globals()["__blockRef_0"]'
)
expect(result.displayInputs.code).toBe('# don\'t confuse quote tracking\nreturn "hello world"')
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
})
it('uses separate Python context variables for repeated mutable references', () => {
const { block, ctx, resolver } = createResolver('python')
+205 -2
View File
@@ -24,6 +24,17 @@ export const FUNCTION_BLOCK_DISPLAY_CODE_KEY = '_runtimeDisplayCode'
const logger = createLogger('VariableResolver')
type ShellQuoteContext = 'single' | 'double' | null
type CodeStringQuoteContext = ShellQuoteContext | 'triple-single' | 'triple-double' | 'template'
type CodeScanMode =
| { type: 'normal' }
| { type: 'single' }
| { type: 'double' }
| { type: 'triple-single' }
| { type: 'triple-double' }
| { type: 'template' }
| { type: 'template-expression'; depth: number }
| { type: 'line-comment' }
| { type: 'block-comment' }
export class VariableResolver {
private resolvers: Resolver[]
@@ -351,14 +362,49 @@ export class VariableResolver {
value: unknown
): string {
if (language === 'python') {
return `globals()[${JSON.stringify(varName)}]`
const expression = `globals()[${JSON.stringify(varName)}]`
const quoteContext = this.getCodeStringQuoteContext(template, matchIndex, language)
if (this.isPythonStringQuoteContext(quoteContext)) {
const quote = this.getCodeStringQuoteToken(quoteContext)
return `${quote} + json.dumps(${expression}) + ${quote}`
}
return expression
}
if (language === 'shell') {
return this.formatShellContextVariableReference(varName, template, matchIndex, value)
}
return `globalThis[${JSON.stringify(varName)}]`
const expression = `globalThis[${JSON.stringify(varName)}]`
const quoteContext = this.getCodeStringQuoteContext(template, matchIndex, language)
if (quoteContext === 'template') {
return `\${JSON.stringify(${expression})}`
}
if (quoteContext === 'single' || quoteContext === 'double') {
const quote = this.getCodeStringQuoteToken(quoteContext)
return `${quote} + JSON.stringify(${expression}) + ${quote}`
}
return expression
}
private isPythonStringQuoteContext(
quoteContext: CodeStringQuoteContext
): quoteContext is 'single' | 'double' | 'triple-single' | 'triple-double' {
return (
quoteContext === 'single' ||
quoteContext === 'double' ||
quoteContext === 'triple-single' ||
quoteContext === 'triple-double'
)
}
private getCodeStringQuoteToken(
quoteContext: 'single' | 'double' | 'triple-single' | 'triple-double'
): string {
if (quoteContext === 'single') return "'"
if (quoteContext === 'double') return '"'
if (quoteContext === 'triple-single') return "'''"
return '"""'
}
private formatDisplayValueForCodeContext(
@@ -397,6 +443,163 @@ export class VariableResolver {
return JSON.stringify(value)
}
private getCodeStringQuoteContext(
template: string,
index: number,
language: string | undefined
): CodeStringQuoteContext {
const isPython = language === 'python'
const modes: CodeScanMode[] = [{ type: 'normal' }]
for (let i = 0; i < index; i++) {
const char = template[i]
const next = template[i + 1]
const mode = modes[modes.length - 1]
if (mode.type === 'line-comment') {
if (char === '\n') {
modes.pop()
}
continue
}
if (mode.type === 'block-comment') {
if (char === '*' && next === '/') {
modes.pop()
i++
}
continue
}
if (mode.type === 'single' || mode.type === 'double') {
const quote = mode.type === 'single' ? "'" : '"'
if (char === '\\') {
i++
continue
}
if (char === quote || char === '\n') {
modes.pop()
}
continue
}
if (mode.type === 'triple-single' || mode.type === 'triple-double') {
const quote = mode.type === 'triple-single' ? "'" : '"'
if (char === '\\') {
i++
continue
}
if (char === quote && next === quote && template[i + 2] === quote) {
modes.pop()
i += 2
}
continue
}
if (mode.type === 'template') {
if (char === '\\') {
i++
continue
}
if (char === '`') {
modes.pop()
continue
}
if (char === '$' && next === '{') {
modes.push({ type: 'template-expression', depth: 1 })
i++
}
continue
}
if (mode.type === 'template-expression') {
if (!isPython && char === '/' && next === '/') {
modes.push({ type: 'line-comment' })
i++
continue
}
if (!isPython && char === '/' && next === '*') {
modes.push({ type: 'block-comment' })
i++
continue
}
if (isPython && char === "'" && next === "'" && template[i + 2] === "'") {
modes.push({ type: 'triple-single' })
i += 2
continue
}
if (isPython && char === '"' && next === '"' && template[i + 2] === '"') {
modes.push({ type: 'triple-double' })
i += 2
continue
}
if (char === "'") {
modes.push({ type: 'single' })
continue
}
if (char === '"') {
modes.push({ type: 'double' })
continue
}
if (!isPython && char === '`') {
modes.push({ type: 'template' })
continue
}
if (char === '{') {
mode.depth += 1
continue
}
if (char === '}') {
mode.depth -= 1
if (mode.depth === 0) {
modes.pop()
}
}
continue
}
if (isPython && char === '#') {
modes.push({ type: 'line-comment' })
continue
}
if (!isPython && char === '/' && next === '/') {
modes.push({ type: 'line-comment' })
i++
continue
}
if (!isPython && char === '/' && next === '*') {
modes.push({ type: 'block-comment' })
i++
continue
}
if (isPython && char === "'" && next === "'" && template[i + 2] === "'") {
modes.push({ type: 'triple-single' })
i += 2
} else if (isPython && char === '"' && next === '"' && template[i + 2] === '"') {
modes.push({ type: 'triple-double' })
i += 2
} else if (char === "'") {
modes.push({ type: 'single' })
} else if (char === '"') {
modes.push({ type: 'double' })
} else if (!isPython && char === '`') {
modes.push({ type: 'template' })
}
}
const mode = modes[modes.length - 1]
if (
mode.type === 'single' ||
mode.type === 'double' ||
mode.type === 'triple-single' ||
mode.type === 'triple-double' ||
mode.type === 'template'
) {
return mode.type
}
return null
}
private formatShellContextVariableReference(
varName: string,
template: string,
+42 -6
View File
@@ -300,6 +300,10 @@ export function useCreateTable(workspaceId: string) {
body: { ...params, workspaceId },
})
},
onError: (error) => {
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
},
@@ -319,6 +323,10 @@ export function useAddTableColumn({ workspaceId, tableId }: RowMutationContext)
body: { workspaceId, column },
})
},
onError: (error) => {
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
onSettled: () => {
invalidateTableSchema(queryClient, tableId)
},
@@ -362,6 +370,10 @@ export function useDeleteTable(workspaceId: string) {
query: { workspaceId },
})
},
onError: (error) => {
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
onSettled: (_data, _error, tableId) => {
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
queryClient.removeQueries({ queryKey: tableKeys.detail(tableId) })
@@ -648,6 +660,10 @@ export function useDeleteTableRow({ workspaceId, tableId }: RowMutationContext)
body: { workspaceId },
})
},
onError: (error) => {
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
onSettled: () => {
invalidateRowCount(queryClient, tableId)
},
@@ -685,6 +701,10 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext)
return { deletedRowIds }
},
onError: (error) => {
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
onSettled: () => {
invalidateRowCount(queryClient, tableId)
},
@@ -862,6 +882,10 @@ export function useRestoreTable() {
params: { tableId },
})
},
onError: (error) => {
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
},
@@ -898,12 +922,12 @@ export function useUploadCsvToTable() {
return response.json()
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
},
onError: (error) => {
logger.error('Failed to upload CSV:', error)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
},
})
}
@@ -974,13 +998,13 @@ export function useImportCsvIntoTable() {
return response.json()
},
onError: (error) => {
logger.error('Failed to import CSV into table:', error)
},
onSettled: (_data, _error, variables) => {
if (!variables) return
invalidateRowCount(queryClient, variables.tableId)
},
onError: (error) => {
logger.error('Failed to import CSV into table:', error)
},
})
}
@@ -1274,6 +1298,10 @@ export function useAddWorkflowGroup({ workspaceId, tableId }: RowMutationContext
body: { workspaceId, group, outputColumns },
})
},
onError: (error) => {
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
onSettled: () => {
invalidateTableSchema(queryClient, tableId)
},
@@ -1300,6 +1328,10 @@ export function useUpdateWorkflowGroup({ workspaceId, tableId }: RowMutationCont
body: { workspaceId, ...vars },
})
},
onError: (error) => {
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
onSettled: () => {
invalidateTableSchema(queryClient, tableId)
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
@@ -1320,6 +1352,10 @@ export function useDeleteWorkflowGroup({ workspaceId, tableId }: RowMutationCont
body: { workspaceId, groupId },
})
},
onError: (error) => {
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
onSettled: () => {
invalidateTableSchema(queryClient, tableId)
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
@@ -2,11 +2,83 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
import {
buildWorkflowSearchMcpToolReplacementOptions,
flattenWorkflowSearchReplacementOptions,
workflowSearchReplaceKeys,
} from '@/hooks/queries/workflow-search-replace'
function createMcpToolMatch(serverId?: string): WorkflowSearchMatch {
return {
id: serverId ? `match-${serverId}` : 'match-all',
blockId: 'mcp-1',
blockName: 'MCP',
blockType: 'mcp',
subBlockId: 'tool',
canonicalSubBlockId: 'tool',
subBlockType: 'mcp-tool-selector',
valuePath: [],
target: { kind: 'subblock' },
kind: 'mcp-tool',
rawValue: serverId ? `${serverId}-search` : 'search',
searchText: 'Search',
editable: true,
navigable: true,
protected: false,
resource: {
kind: 'mcp-tool',
key: serverId ? `${serverId}-search` : 'search',
selectorContext: serverId ? { mcpServerId: serverId } : undefined,
resourceGroupKey: serverId ? `mcp-tool:${serverId}` : 'mcp-tool:any',
},
}
}
describe('buildWorkflowSearchMcpToolReplacementOptions', () => {
const tools = [
{
id: 'a-search',
name: 'search',
serverId: 'server-a',
serverName: 'Server A',
inputSchema: {},
},
{
id: 'b-search',
name: 'search',
serverId: 'server-b',
serverName: 'Server B',
inputSchema: {},
},
]
it('filters MCP tool replacement options to the matched server context', () => {
const options = buildWorkflowSearchMcpToolReplacementOptions(
[createMcpToolMatch('server-a')],
tools
)
expect(options).toEqual([
{
kind: 'mcp-tool',
value: 'mcp-server-a-search',
label: 'Server A: search',
resourceGroupKey: 'mcp-tool:server-a',
},
])
})
it('keeps all MCP tool replacement options when no server context exists', () => {
const options = buildWorkflowSearchMcpToolReplacementOptions([createMcpToolMatch()], tools)
expect(options.map((option) => option.value)).toEqual([
'mcp-server-a-search',
'mcp-server-b-search',
])
})
})
describe('workflowSearchReplaceKeys', () => {
it('builds stable hierarchical keys for credential candidates', () => {
expect(
@@ -1,8 +1,29 @@
import { useMemo } from 'react'
import { useQueries } from '@tanstack/react-query'
import { useQueries, useQuery } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge'
import {
type DiscoverMcpToolsResponse,
discoverMcpToolsContract,
type ListMcpServersResponse,
listMcpServersContract,
} from '@/lib/api/contracts/mcp'
import {
type GetTableResponse,
getTableContract,
type ListTablesResponse,
listTablesContract,
} from '@/lib/api/contracts/tables'
import {
type ListWorkspaceFilesResponse,
listWorkspaceFilesContract,
} from '@/lib/api/contracts/workspace-files'
import { createMcpToolId } from '@/lib/mcp/shared'
import type { Credential } from '@/lib/oauth'
import { stableStringifyWorkflowSearchValue } from '@/lib/workflows/search-replace/resource-resolvers'
import {
getWorkflowSearchMatchResourceGroupKey,
stableStringifyWorkflowSearchValue,
} from '@/lib/workflows/search-replace/resources'
import type {
WorkflowSearchMatch,
WorkflowSearchReplacementOption,
@@ -46,6 +67,26 @@ export const workflowSearchReplaceKeys = {
knowledgeDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'knowledge'] as const,
knowledgeDetail: (knowledgeBaseId?: string) =>
[...workflowSearchReplaceKeys.knowledgeDetails(), knowledgeBaseId ?? ''] as const,
tableDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'table'] as const,
tableDetail: (workspaceId?: string, tableId?: string) =>
[...workflowSearchReplaceKeys.tableDetails(), workspaceId ?? '', tableId ?? ''] as const,
tableReplacementOptions: (workspaceId?: string) =>
[...workflowSearchReplaceKeys.replacementOptions(), 'table', workspaceId ?? ''] as const,
fileDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'file'] as const,
fileListDetails: (workspaceId?: string) =>
[...workflowSearchReplaceKeys.fileDetails(), 'list', workspaceId ?? ''] as const,
fileReplacementOptions: (workspaceId?: string) =>
[...workflowSearchReplaceKeys.replacementOptions(), 'file', workspaceId ?? ''] as const,
mcpServerDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'mcp-server'] as const,
mcpServerListDetails: (workspaceId?: string) =>
[...workflowSearchReplaceKeys.mcpServerDetails(), 'list', workspaceId ?? ''] as const,
mcpServerReplacementOptions: (workspaceId?: string) =>
[...workflowSearchReplaceKeys.replacementOptions(), 'mcp-server', workspaceId ?? ''] as const,
mcpToolDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'mcp-tool'] as const,
mcpToolListDetails: (workspaceId?: string) =>
[...workflowSearchReplaceKeys.mcpToolDetails(), 'list', workspaceId ?? ''] as const,
mcpToolReplacementOptions: (workspaceId?: string) =>
[...workflowSearchReplaceKeys.replacementOptions(), 'mcp-tool', workspaceId ?? ''] as const,
knowledgeReplacementOptions: (workspaceId?: string) =>
[...workflowSearchReplaceKeys.replacementOptions(), 'knowledge', workspaceId ?? ''] as const,
selectorDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'selector'] as const,
@@ -107,6 +148,22 @@ function uniqueSelectorOptionGroups(matches: WorkflowSearchMatch[]): WorkflowSea
})
}
function uniqueResourceOptionGroups(
matches: WorkflowSearchMatch[],
kind: WorkflowSearchMatch['kind'],
predicate?: (match: WorkflowSearchMatch) => boolean
): WorkflowSearchMatch[] {
const seen = new Set<string>()
return matches.filter((match) => {
if (match.kind !== kind || predicate?.(match) === false) return false
const key = getWorkflowSearchMatchResourceGroupKey(match)
if (seen.has(key)) return false
seen.add(key)
return true
})
}
export function useWorkflowSearchOAuthCredentialDetails(
matches: WorkflowSearchMatch[],
workflowId?: string
@@ -154,6 +211,154 @@ export function useWorkflowSearchKnowledgeBaseDetails(matches: WorkflowSearchMat
})
}
export function useWorkflowSearchTableDetails(
matches: WorkflowSearchMatch[],
workspaceId?: string
) {
const tableMatches = useMemo(() => uniqueMatches(matches, 'table'), [matches])
return useQueries({
queries: tableMatches.map((match) => ({
queryKey: workflowSearchReplaceKeys.tableDetail(workspaceId, match.rawValue),
queryFn: ({ signal }: { signal: AbortSignal }) =>
requestJson(getTableContract, {
params: { tableId: match.rawValue },
query: { workspaceId: workspaceId as string },
signal,
}),
enabled: Boolean(workspaceId && match.rawValue),
staleTime: 60 * 1000,
select: (response: GetTableResponse): WorkflowSearchResolvedResource => ({
matchRawValue: match.rawValue,
resourceGroupKey: match.resource?.resourceGroupKey,
label: response.data.table.name,
resolved: true,
inaccessible: false,
}),
})),
})
}
export function useWorkflowSearchFileDetails(matches: WorkflowSearchMatch[], workspaceId?: string) {
const fileMatches = useMemo(
() =>
uniqueMatches(
matches.filter((match) => !match.resource?.selectorKey),
'file'
),
[matches]
)
const filesQuery = useQuery({
queryKey: workflowSearchReplaceKeys.fileListDetails(workspaceId),
queryFn: ({ signal }: { signal: AbortSignal }) =>
requestJson(listWorkspaceFilesContract, {
params: { id: workspaceId as string },
query: { scope: 'active' },
signal,
}),
enabled: Boolean(workspaceId && fileMatches.length > 0),
staleTime: 60 * 1000,
})
return useMemo(
() =>
fileMatches.map((match) => {
const file = filesQuery.data?.files.find((item) =>
[item.id, item.key, item.path, item.name].includes(match.rawValue)
)
return {
data: filesQuery.data
? {
matchRawValue: match.rawValue,
resourceGroupKey: match.resource?.resourceGroupKey,
label: file?.name ?? match.rawValue,
resolved: Boolean(file),
inaccessible: false,
}
: undefined,
}
}),
[fileMatches, filesQuery.data]
)
}
export function useWorkflowSearchMcpServerDetails(
matches: WorkflowSearchMatch[],
workspaceId?: string
) {
const serverMatches = useMemo(() => uniqueMatches(matches, 'mcp-server'), [matches])
const serversQuery = useQuery({
queryKey: workflowSearchReplaceKeys.mcpServerListDetails(workspaceId),
queryFn: ({ signal }: { signal: AbortSignal }) =>
requestJson(listMcpServersContract, {
query: { workspaceId: workspaceId as string },
signal,
}),
enabled: Boolean(workspaceId && serverMatches.length > 0),
staleTime: 60 * 1000,
})
return useMemo(
() =>
serverMatches.map((match) => {
const server = serversQuery.data?.data.servers.find((item) => item.id === match.rawValue)
return {
data: serversQuery.data
? {
matchRawValue: match.rawValue,
resourceGroupKey: match.resource?.resourceGroupKey,
label: server?.name ?? match.rawValue,
resolved: Boolean(server),
inaccessible: false,
}
: undefined,
}
}),
[serverMatches, serversQuery.data]
)
}
export function useWorkflowSearchMcpToolDetails(
matches: WorkflowSearchMatch[],
workspaceId?: string
) {
const toolMatches = useMemo(() => uniqueMatches(matches, 'mcp-tool'), [matches])
const toolsQuery = useQuery({
queryKey: workflowSearchReplaceKeys.mcpToolListDetails(workspaceId),
queryFn: ({ signal }: { signal: AbortSignal }) =>
requestJson(discoverMcpToolsContract, {
query: { workspaceId: workspaceId as string },
signal,
}),
enabled: Boolean(workspaceId && toolMatches.length > 0),
staleTime: 60 * 1000,
})
return useMemo(
() =>
toolMatches.map((match) => {
const tool = toolsQuery.data?.data.tools.find(
(item) => createMcpToolId(item.serverId, item.name) === match.rawValue
)
return {
data: toolsQuery.data
? {
matchRawValue: match.rawValue,
resourceGroupKey: match.resource?.resourceGroupKey,
label: tool ? `${tool.serverName}: ${tool.name}` : match.rawValue,
resolved: Boolean(tool),
inaccessible: false,
}
: undefined,
}
}),
[toolMatches, toolsQuery.data]
)
}
export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[]) {
const selectorMatches = useMemo(() => uniqueSelectorDetailMatches(matches), [matches])
@@ -228,22 +433,177 @@ export function useWorkflowSearchOAuthReplacementOptions(
})
}
export function useWorkflowSearchKnowledgeReplacementOptions(workspaceId?: string) {
export function useWorkflowSearchKnowledgeReplacementOptions(
matches: WorkflowSearchMatch[],
workspaceId?: string
) {
const knowledgeGroups = useMemo(
() => uniqueResourceOptionGroups(matches, 'knowledge-base'),
[matches]
)
return useQueries({
queries: [
{
queryKey: workflowSearchReplaceKeys.knowledgeReplacementOptions(workspaceId),
queryFn: ({ signal }: { signal: AbortSignal }) =>
fetchKnowledgeBases(workspaceId, 'active', signal),
enabled: Boolean(workspaceId),
enabled: Boolean(workspaceId && knowledgeGroups.length > 0),
staleTime: 60 * 1000,
placeholderData: (previous: KnowledgeBaseData[] | undefined) => previous,
select: (knowledgeBases: KnowledgeBaseData[]): WorkflowSearchReplacementOption[] =>
knowledgeBases.map((knowledgeBase) => ({
kind: 'knowledge-base',
value: knowledgeBase.id,
label: knowledgeBase.name,
})),
knowledgeGroups.flatMap((match) =>
knowledgeBases.map((knowledgeBase) => ({
kind: 'knowledge-base',
value: knowledgeBase.id,
label: knowledgeBase.name,
resourceGroupKey: match.resource?.resourceGroupKey,
}))
),
},
],
})
}
export function useWorkflowSearchTableReplacementOptions(
matches: WorkflowSearchMatch[],
workspaceId?: string
) {
const tableGroups = useMemo(() => uniqueResourceOptionGroups(matches, 'table'), [matches])
return useQueries({
queries: [
{
queryKey: workflowSearchReplaceKeys.tableReplacementOptions(workspaceId),
queryFn: ({ signal }: { signal: AbortSignal }) =>
requestJson(listTablesContract, {
query: { workspaceId: workspaceId as string, scope: 'active' },
signal,
}),
enabled: Boolean(workspaceId && tableGroups.length > 0),
staleTime: 60 * 1000,
select: (response: ListTablesResponse): WorkflowSearchReplacementOption[] =>
tableGroups.flatMap((match) =>
response.data.tables.map((table) => ({
kind: 'table',
value: table.id,
label: table.name,
resourceGroupKey: match.resource?.resourceGroupKey,
}))
),
},
],
})
}
export function useWorkflowSearchFileReplacementOptions(
matches: WorkflowSearchMatch[],
workspaceId?: string
) {
const fileGroups = useMemo(
() => uniqueResourceOptionGroups(matches, 'file', (match) => !match.resource?.selectorKey),
[matches]
)
return useQueries({
queries: [
{
queryKey: workflowSearchReplaceKeys.fileReplacementOptions(workspaceId),
queryFn: ({ signal }: { signal: AbortSignal }) =>
requestJson(listWorkspaceFilesContract, {
params: { id: workspaceId as string },
query: { scope: 'active' },
signal,
}),
enabled: Boolean(workspaceId && fileGroups.length > 0),
staleTime: 60 * 1000,
select: (response: ListWorkspaceFilesResponse): WorkflowSearchReplacementOption[] =>
fileGroups.flatMap((match) =>
response.files.map((file) => ({
kind: 'file',
value: JSON.stringify({
name: file.name,
path: file.path,
key: file.key,
size: file.size,
type: file.type,
}),
label: file.name,
resourceGroupKey: match.resource?.resourceGroupKey,
}))
),
},
],
})
}
export function useWorkflowSearchMcpServerReplacementOptions(
matches: WorkflowSearchMatch[],
workspaceId?: string
) {
const serverGroups = useMemo(() => uniqueResourceOptionGroups(matches, 'mcp-server'), [matches])
return useQueries({
queries: [
{
queryKey: workflowSearchReplaceKeys.mcpServerReplacementOptions(workspaceId),
queryFn: ({ signal }: { signal: AbortSignal }) =>
requestJson(listMcpServersContract, {
query: { workspaceId: workspaceId as string },
signal,
}),
enabled: Boolean(workspaceId && serverGroups.length > 0),
staleTime: 60 * 1000,
select: (response: ListMcpServersResponse): WorkflowSearchReplacementOption[] =>
serverGroups.flatMap((match) =>
response.data.servers.map((server) => ({
kind: 'mcp-server',
value: server.id,
label: server.name,
resourceGroupKey: match.resource?.resourceGroupKey,
}))
),
},
],
})
}
export function buildWorkflowSearchMcpToolReplacementOptions(
toolGroups: WorkflowSearchMatch[],
tools: DiscoverMcpToolsResponse['data']['tools']
): WorkflowSearchReplacementOption[] {
return toolGroups.flatMap((match) => {
const serverId = match.resource?.selectorContext?.mcpServerId
return tools
.filter((tool) => !serverId || tool.serverId === serverId)
.map((tool) => ({
kind: 'mcp-tool',
value: createMcpToolId(tool.serverId, tool.name),
label: `${tool.serverName}: ${tool.name}`,
resourceGroupKey: match.resource?.resourceGroupKey,
}))
})
}
export function useWorkflowSearchMcpToolReplacementOptions(
matches: WorkflowSearchMatch[],
workspaceId?: string
) {
const toolGroups = useMemo(() => uniqueResourceOptionGroups(matches, 'mcp-tool'), [matches])
return useQueries({
queries: [
{
queryKey: workflowSearchReplaceKeys.mcpToolReplacementOptions(workspaceId),
queryFn: ({ signal }: { signal: AbortSignal }) =>
requestJson(discoverMcpToolsContract, {
query: { workspaceId: workspaceId as string },
signal,
}),
enabled: Boolean(workspaceId && toolGroups.length > 0),
staleTime: 60 * 1000,
select: (response: DiscoverMcpToolsResponse): WorkflowSearchReplacementOption[] =>
buildWorkflowSearchMcpToolReplacementOptions(toolGroups, response.data.tools),
},
],
})
+1
View File
@@ -90,6 +90,7 @@ export interface SelectorContext {
awsSecretAccessKey?: string
awsRegion?: string
logGroupName?: string
mcpServerId?: string
}
export interface SelectorQueryArgs {
+36 -1
View File
@@ -12,11 +12,16 @@ import {
} from '@sim/realtime-protocol/constants'
import { generateId } from '@sim/utils/id'
import { useQueryClient } from '@tanstack/react-query'
import { isEqual } from 'es-toolkit'
import type { Edge } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import { requestJson } from '@/lib/api/client/request'
import { getWorkflowStateContract } from '@/lib/api/contracts'
import { useSession } from '@/lib/auth/auth-client'
import {
type WorkflowSearchSubflowFieldId,
workflowSearchSubflowFieldMatchesExpected,
} from '@/lib/workflows/search-replace/subflow-fields'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
import { getSubBlocksDependingOnChange } from '@/blocks/utils'
@@ -1542,7 +1547,7 @@ export function useCollaborativeWorkflow() {
subflowUpdates?: Array<{
blockId: string
blockType: 'loop' | 'parallel'
fieldId: string
fieldId: WorkflowSearchSubflowFieldId
before: unknown
after: unknown
}>
@@ -1566,6 +1571,36 @@ export function useCollaborativeWorkflow() {
return false
}
const staleUpdate = updates.find((update) => {
if (!Object.hasOwn(update, 'expectedValue')) return false
const currentValue = useSubBlockStore.getState().getValue(update.blockId, update.subblockId)
return !isEqual(currentValue, update.expectedValue)
})
if (staleUpdate) {
logger.warn('Skipping batch subblock update because expected value changed', {
blockId: staleUpdate.blockId,
subblockId: staleUpdate.subblockId,
})
return false
}
const staleSubflowUpdate = undoSubflowUpdates.find((update) => {
const currentBlock = useWorkflowStore.getState().blocks[update.blockId]
if (!currentBlock || currentBlock.type !== update.blockType) return true
return !workflowSearchSubflowFieldMatchesExpected(
currentBlock,
update.fieldId,
update.before
)
})
if (staleSubflowUpdate) {
logger.warn('Skipping batch subflow update because expected value changed', {
blockId: staleSubflowUpdate.blockId,
fieldId: staleSubflowUpdate.fieldId,
})
return false
}
if (updates.length > 0) {
updates.forEach((update) => {
useSubBlockStore.getState().setValue(update.blockId, update.subblockId, update.value)
+3 -1
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import type { McpToolSchema, McpToolSchemaProperty } from '@/lib/mcp/types'
const dateStringSchema = z.preprocess(
@@ -271,6 +271,7 @@ export const listMcpServersContract = defineRouteContract({
),
},
})
export type ListMcpServersResponse = ContractJsonResponse<typeof listMcpServersContract>
export const createMcpServerContract = defineRouteContract({
method: 'POST',
@@ -343,6 +344,7 @@ export const discoverMcpToolsContract = defineRouteContract({
),
},
})
export type DiscoverMcpToolsResponse = ContractJsonResponse<typeof discoverMcpToolsContract>
export const refreshMcpToolsContract = defineRouteContract({
method: 'POST',
+3 -1
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import type {
CsvHeaderMapping,
Filter,
@@ -330,6 +330,7 @@ export const listTablesContract = defineRouteContract({
),
},
})
export type ListTablesResponse = ContractJsonResponse<typeof listTablesContract>
export const createTableContract = defineRouteContract({
method: 'POST',
@@ -356,6 +357,7 @@ export const getTableContract = defineRouteContract({
schema: successResponseSchema(z.object({ table: tableDefinitionSchema })),
},
})
export type GetTableResponse = ContractJsonResponse<typeof getTableContract>
export const renameTableContract = defineRouteContract({
method: 'PATCH',
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
export const workspaceFileScopeSchema = z.enum(['active', 'archived', 'all'])
@@ -58,6 +58,7 @@ export const listWorkspaceFilesContract = defineRouteContract({
}),
},
})
export type ListWorkspaceFilesResponse = ContractJsonResponse<typeof listWorkspaceFilesContract>
export const renameWorkspaceFileContract = defineRouteContract({
method: 'PATCH',
@@ -5,6 +5,7 @@
import type { Edge } from 'reactflow'
import { isNonEmptyValue } from '@/lib/workflows/subblocks/visibility'
import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks'
import type {
BlockState,
Loop,
@@ -410,13 +411,6 @@ export function extractBlockFieldsForComparison(block: BlockState): ExtractedBlo
}
}
/**
* Pattern matching synthetic subBlock IDs created by ToolSubBlockRenderer.
* These IDs follow the format `{subBlockId}-tool-{index}-{paramId}` and are
* mirrors of values already stored in toolConfig.value.tools[N].params.
*/
const SYNTHETIC_TOOL_SUBBLOCK_RE = /-tool-\d+-/
/**
* Filters subBlock IDs to exclude system, trigger runtime, and synthetic tool subBlocks.
*
@@ -429,7 +423,7 @@ export function filterSubBlockIds(subBlockIds: string[]): string[] {
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id)) return false
if (SYSTEM_SUBBLOCK_IDS.some((sysId) => id === sysId || id.startsWith(`${sysId}_`)))
return false
if (SYNTHETIC_TOOL_SUBBLOCK_RE.test(id)) return false
if (isSyntheticToolSubBlockId(id)) return false
return true
})
.sort()
@@ -0,0 +1,24 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
import type { SubBlockConfig } from '@/blocks/types'
describe('getWorkflowSearchDependentClears', () => {
it('returns transitive dependents without cycling', () => {
const subBlocks: SubBlockConfig[] = [
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'project', title: 'Project', type: 'project-selector', dependsOn: ['credential'] },
{ id: 'issue', title: 'Issue', type: 'file-selector', dependsOn: ['project'] },
{ id: 'assignee', title: 'Assignee', type: 'user-selector', dependsOn: ['issue'] },
{ id: 'unrelated', title: 'Unrelated', type: 'short-input' },
]
expect(getWorkflowSearchDependentClears(subBlocks, 'credential')).toEqual([
{ subBlockId: 'project', reason: 'project depends on credential' },
{ subBlockId: 'issue', reason: 'issue depends on project' },
{ subBlockId: 'assignee', reason: 'assignee depends on issue' },
])
})
})
@@ -10,8 +10,24 @@ export function getWorkflowSearchDependentClears(
allSubBlocks: SubBlockConfig[],
changedSubBlockId: string
): DependentClear[] {
return getSubBlocksDependingOnChange(allSubBlocks, changedSubBlockId).map((subBlock) => ({
subBlockId: subBlock.id,
reason: `${subBlock.id} depends on ${changedSubBlockId}`,
}))
const clears: DependentClear[] = []
const visited = new Set<string>([changedSubBlockId])
const queue = [changedSubBlockId]
while (queue.length > 0) {
const currentSubBlockId = queue.shift()
if (!currentSubBlockId) continue
for (const subBlock of getSubBlocksDependingOnChange(allSubBlocks, currentSubBlockId)) {
if (!subBlock.id || visited.has(subBlock.id)) continue
visited.add(subBlock.id)
clears.push({
subBlockId: subBlock.id,
reason: `${subBlock.id} depends on ${currentSubBlockId}`,
})
queue.push(subBlock.id)
}
}
return clears
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
import type { SubBlockType } from '@sim/workflow-types/blocks'
import type {
WorkflowSearchRange,
WorkflowSearchValuePath,
} from '@/lib/workflows/search-replace/types'
import { getValueAtPath, setValueAtPath } from '@/lib/workflows/search-replace/value-walker'
const SEARCHABLE_JSON_ARRAY_VALUE_FIELDS: Partial<Record<SubBlockType, Record<string, string>>> = {
'condition-input': {
value: 'Condition',
},
'router-input': {
value: 'Route',
},
'knowledge-tag-filters': {
tagValue: 'Value',
valueTo: 'Value To',
},
'document-tag-entry': {
value: 'Value',
},
'variables-input': {
value: 'Value',
},
}
const SEARCHABLE_JSON_OBJECT_VALUE_FIELDS: Partial<Record<SubBlockType, string>> = {
'input-mapping': 'Value',
'workflow-input-mapper': 'Value',
}
const SERIALIZED_SUBBLOCK_VALUE_TYPES = new Set<SubBlockType>([
'file-upload',
'grouped-checkbox-list',
'table',
])
export interface SearchableJsonStringLeaf {
path: WorkflowSearchValuePath
value: string
originalValue: string
fieldTitle: string
}
export interface JsonStringLeafReplacementResult {
handled: boolean
success: boolean
nextValue?: unknown
reason?: string
}
function parseJsonValue(value: string): unknown | null {
try {
return JSON.parse(value)
} catch {
return null
}
}
function getParsedValue(value: unknown): { parsed: unknown; stringify: boolean } | null {
if (typeof value === 'string') {
const parsed = parseJsonValue(value)
return parsed === null ? null : { parsed, stringify: true }
}
if (value && typeof value === 'object') {
return { parsed: value, stringify: false }
}
return null
}
function getObjectStringLeaves({
value,
path = [],
fieldTitle,
}: {
value: unknown
path?: WorkflowSearchValuePath
fieldTitle: string
}): SearchableJsonStringLeaf[] {
if (typeof value === 'string' && value.length > 0) {
return [{ path, value, originalValue: value, fieldTitle }]
}
if (Array.isArray(value)) {
return value.flatMap((item, index) =>
getObjectStringLeaves({ value: item, path: [...path, index], fieldTitle })
)
}
if (!value || typeof value !== 'object') return []
return Object.entries(value).flatMap(([fieldKey, fieldValue]) =>
getObjectStringLeaves({ value: fieldValue, path: [...path, fieldKey], fieldTitle })
)
}
export function isSearchableJsonValueSubBlock(
subBlockType: SubBlockType | undefined
): subBlockType is
| 'condition-input'
| 'router-input'
| 'knowledge-tag-filters'
| 'document-tag-entry'
| 'variables-input'
| 'input-mapping'
| 'workflow-input-mapper'
| 'table' {
return Boolean(
subBlockType &&
(subBlockType === 'table' ||
SEARCHABLE_JSON_ARRAY_VALUE_FIELDS[subBlockType] ||
SEARCHABLE_JSON_OBJECT_VALUE_FIELDS[subBlockType])
)
}
export function shouldParseSerializedSubBlockValue(
subBlockType: SubBlockType | undefined
): subBlockType is SubBlockType {
return Boolean(
subBlockType &&
(isSearchableJsonValueSubBlock(subBlockType) ||
SERIALIZED_SUBBLOCK_VALUE_TYPES.has(subBlockType))
)
}
export function getSearchableJsonStringLeaves(
value: unknown,
subBlockType: SubBlockType | undefined
): SearchableJsonStringLeaf[] {
const parsedValue = getParsedValue(value)
if (!parsedValue) return []
const { parsed } = parsedValue
if (subBlockType === 'table') {
if (!Array.isArray(parsed)) return []
return parsed.flatMap((row, rowIndex) => {
if (!row || typeof row !== 'object' || Array.isArray(row)) return []
const cells = (row as Record<string, unknown>).cells
if (!cells || typeof cells !== 'object' || Array.isArray(cells)) return []
return Object.entries(cells).flatMap(([column, cellValue]) =>
typeof cellValue === 'string' && cellValue.length > 0
? [
{
path: [rowIndex, 'cells', column],
value: cellValue,
originalValue: cellValue,
fieldTitle: column,
},
]
: []
)
})
}
const arrayFieldTitles = subBlockType
? SEARCHABLE_JSON_ARRAY_VALUE_FIELDS[subBlockType]
: undefined
if (arrayFieldTitles) {
if (!Array.isArray(parsed)) return []
return parsed.flatMap((row, index) => {
if (!row || typeof row !== 'object' || Array.isArray(row)) return []
return Object.entries(arrayFieldTitles).flatMap(([fieldKey, fieldTitle]) => {
const fieldValue = (row as Record<string, unknown>)[fieldKey]
return typeof fieldValue === 'string' && fieldValue.length > 0
? [{ path: [index, fieldKey], value: fieldValue, originalValue: fieldValue, fieldTitle }]
: []
})
})
}
const objectFieldTitle = subBlockType
? SEARCHABLE_JSON_OBJECT_VALUE_FIELDS[subBlockType]
: undefined
if (objectFieldTitle) {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []
return getObjectStringLeaves({ value: parsed, fieldTitle: objectFieldTitle })
}
return []
}
export function replaceJsonStringLeafRange({
value,
subBlockType,
path,
range,
rawValue,
replacement,
}: {
value: unknown
subBlockType: SubBlockType | undefined
path: WorkflowSearchValuePath
range: WorkflowSearchRange
rawValue: string
replacement: string
}): JsonStringLeafReplacementResult {
if (!isSearchableJsonValueSubBlock(subBlockType)) {
return { handled: false, success: false }
}
const parsedValue = getParsedValue(value)
if (!parsedValue) {
return { handled: true, success: false, reason: 'Target JSON is no longer valid' }
}
const { parsed, stringify } = parsedValue
const currentLeaf = getValueAtPath(parsed, path)
if (typeof currentLeaf !== 'string') {
for (let prefixLength = path.length - 1; prefixLength > 0; prefixLength -= 1) {
const valuePrefix = path.slice(0, prefixLength)
const nestedValue = getValueAtPath(parsed, valuePrefix)
if (typeof nestedValue !== 'string') continue
const nestedResult = replaceJsonStringLeafRange({
value: nestedValue,
subBlockType,
path: path.slice(prefixLength),
range,
rawValue,
replacement,
})
if (!nestedResult.handled || !nestedResult.success) return nestedResult
return {
handled: true,
success: true,
nextValue: stringify
? JSON.stringify(setValueAtPath(parsed, valuePrefix, nestedResult.nextValue))
: setValueAtPath(parsed, valuePrefix, nestedResult.nextValue),
}
}
}
if (typeof currentLeaf !== 'string') {
return { handled: true, success: false, reason: 'Target value is no longer text' }
}
const currentRawValue = currentLeaf.slice(range.start, range.end)
if (currentRawValue !== rawValue) {
return { handled: true, success: false, reason: 'Target text changed since search' }
}
const nextLeaf = `${currentLeaf.slice(0, range.start)}${replacement}${currentLeaf.slice(range.end)}`
return {
handled: true,
success: true,
nextValue: stringify
? JSON.stringify(setValueAtPath(parsed, path, nextLeaf))
: setValueAtPath(parsed, path, nextLeaf),
}
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,10 @@
import { getWorkflowSearchReplacementIssue } from '@/lib/workflows/search-replace/replacement-validation'
import { replaceJsonStringLeafRange } from '@/lib/workflows/search-replace/json-value-fields'
import {
getWorkflowSearchReplacementIssue,
normalizeWorkflowSearchResourceReplacement,
replaceWorkflowSearchResourceValue,
workflowSearchResourceValueContains,
} from '@/lib/workflows/search-replace/resources'
import {
getWorkflowSearchSubflowField,
parseWorkflowSearchSubflowReplacement,
@@ -27,67 +33,47 @@ interface BuildWorkflowSearchReplacePlanParams {
}
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
return normalizeWorkflowSearchResourceReplacement(match, 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,
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) => (shouldReplace(part) ? replacement : part)).join(',')
}
return shouldReplace(value) ? replacement : value
}
if (Array.isArray(value)) {
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
function clearDependentValues(value: unknown, paths: WorkflowSearchMatch['dependentValuePaths']) {
return (paths ?? []).reduce((currentValue, path) => setValueAtPath(currentValue, path, ''), value)
}
function structuredValueContains(value: unknown, rawValue: string): boolean {
if (typeof value === 'string') {
return value
.split(',')
.map((part) => part.trim())
.includes(rawValue)
function pathStartsWith(
path: WorkflowSearchMatch['valuePath'],
prefix: WorkflowSearchMatch['valuePath']
) {
return prefix.every((segment, index) => path[index] === segment)
}
function getTouchedPathsByField(matches: WorkflowSearchMatch[]) {
const touchedPathsByField = new Map<string, WorkflowSearchMatch['valuePath'][]>()
for (const match of matches) {
if (match.target.kind !== 'subblock') continue
const updateKey = `${match.blockId}:${match.subBlockId}`
const paths = touchedPathsByField.get(updateKey) ?? []
paths.push(match.valuePath)
touchedPathsByField.set(updateKey, paths)
}
if (Array.isArray(value)) {
return value.some((item) => structuredValueContains(item, rawValue))
}
return false
return touchedPathsByField
}
function getDependentValuePathsToClear(
match: WorkflowSearchMatch,
touchedPathsByField: Map<string, WorkflowSearchMatch['valuePath'][]>
) {
if (!match.dependentValuePaths?.length) return undefined
const updateKey = `${match.blockId}:${match.subBlockId}`
const touchedPaths = touchedPathsByField.get(updateKey) ?? []
return match.dependentValuePaths.filter(
(dependentPath) =>
!touchedPaths.some((touchedPath) => pathStartsWith(touchedPath, dependentPath))
)
}
function getReplacement(
@@ -114,6 +100,7 @@ export function buildWorkflowSearchReplacePlan({
const subflowUpdatesByField = new Map<string, WorkflowSearchReplaceSubflowUpdate>()
const selectedMatches = matches.filter((match) => selectedMatchIds.has(match.id))
const touchedPathsByField = getTouchedPathsByField(selectedMatches)
const orderedMatches = [...selectedMatches].sort((a, b) => {
const blockCompare = a.blockId.localeCompare(b.blockId)
if (blockCompare !== 0) return blockCompare
@@ -228,8 +215,37 @@ export function buildWorkflowSearchReplacePlan({
const existingUpdate = updatesByField.get(updateKey)
const previousValue: unknown = existingUpdate?.previousValue ?? subBlock.value
let nextValue: unknown = existingUpdate?.nextValue ?? subBlock.value
const dependentValuePathsToClear = getDependentValuePathsToClear(match, touchedPathsByField)
if (match.range) {
const jsonReplacement = replaceJsonStringLeafRange({
value: nextValue,
subBlockType: match.subBlockType,
path: match.valuePath,
range: match.range,
rawValue: match.rawValue,
replacement,
})
if (jsonReplacement.handled) {
if (!jsonReplacement.success) {
conflicts.push({
matchId: match.id,
reason: jsonReplacement.reason ?? 'Target value is no longer text',
})
continue
}
nextValue = jsonReplacement.nextValue
nextValue = clearDependentValues(nextValue, dependentValuePathsToClear)
updatesByField.set(updateKey, {
blockId: match.blockId,
subBlockId: match.subBlockId,
previousValue,
nextValue,
matchIds: [...(existingUpdate?.matchIds ?? []), match.id],
})
continue
}
const currentLeaf = getValueAtPath(nextValue, match.valuePath)
if (typeof currentLeaf !== 'string') {
conflicts.push({ matchId: match.id, reason: 'Target value is no longer text' })
@@ -247,24 +263,33 @@ export function buildWorkflowSearchReplacePlan({
match.valuePath,
replaceRange(currentLeaf, match.range.start, match.range.end, replacement)
)
nextValue = clearDependentValues(nextValue, dependentValuePathsToClear)
} else {
const currentValue = getValueAtPath(nextValue, match.valuePath)
const valueForReplacement = match.valuePath.length === 0 ? nextValue : currentValue
if (!structuredValueContains(valueForReplacement, match.rawValue)) {
if (!workflowSearchResourceValueContains(match, valueForReplacement)) {
conflicts.push({ matchId: match.id, reason: 'Target resource changed since search' })
continue
}
const replacedValue = replaceStructuredValue(
const resourceReplacement = replaceWorkflowSearchResourceValue(
match,
valueForReplacement,
match.rawValue,
replacement,
match.structuredOccurrenceIndex
replacement
)
if (!resourceReplacement.success) {
conflicts.push({
matchId: match.id,
reason: resourceReplacement.reason ?? 'Target resource is no longer replaceable',
})
continue
}
const replacedValue = resourceReplacement.nextValue
nextValue =
match.valuePath.length === 0
? replacedValue
: setValueAtPath(nextValue, match.valuePath, replacedValue)
nextValue = clearDependentValues(nextValue, dependentValuePathsToClear)
}
updatesByField.set(updateKey, {
@@ -0,0 +1,4 @@
export * from './references'
export * from './registry'
export * from './resolvers'
export * from './validation'
@@ -1,7 +1,9 @@
import type { SubBlockType } from '@sim/workflow-types/blocks'
import { buildWorkflowSearchResourceGroupKey } from '@/lib/workflows/search-replace/resource-resolvers'
import {
getWorkflowSearchSubBlockResourceKind,
parseWorkflowSearchSubBlockResources,
type StructuredResourceReference,
} from '@/lib/workflows/search-replace/resources/registry'
import type {
WorkflowSearchMatchKind,
WorkflowSearchRange,
WorkflowSearchResourceMeta,
} from '@/lib/workflows/search-replace/types'
@@ -17,40 +19,10 @@ export interface ParsedInlineReference {
resource: WorkflowSearchResourceMeta
}
export interface StructuredResourceReference {
kind: Exclude<WorkflowSearchMatchKind, 'text' | 'environment' | 'workflow-reference'>
rawValue: string
searchText: string
resource: WorkflowSearchResourceMeta
}
const RESOURCE_KIND_BY_SUBBLOCK_TYPE: Partial<
Record<
SubBlockType,
Exclude<WorkflowSearchMatchKind, 'text' | 'environment' | 'workflow-reference'>
>
> = {
'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<SubBlockConfig, 'type'>
): StructuredResourceReference['kind'] | null {
if (!subBlockConfig) return null
return RESOURCE_KIND_BY_SUBBLOCK_TYPE[subBlockConfig.type] ?? null
return getWorkflowSearchSubBlockResourceKind(subBlockConfig)
}
export function parseInlineReferences(value: string): ParsedInlineReference[] {
@@ -95,49 +67,12 @@ export function parseInlineReferences(value: string): ParsedInlineReference[] {
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,
subBlockConfig?: Pick<SubBlockConfig, 'type' | 'serviceId' | 'selectorKey' | 'requiredScopes'>,
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,
}
})
return parseWorkflowSearchSubBlockResources(value, subBlockConfig, selectorContext)
}
export function matchesSearchText(
@@ -0,0 +1,420 @@
import type { SubBlockType } from '@sim/workflow-types/blocks'
import { buildWorkflowSearchResourceGroupKey } from '@/lib/workflows/search-replace/resources/resolvers'
import type {
WorkflowSearchMatch,
WorkflowSearchMatchKind,
WorkflowSearchResourceMeta,
} from '@/lib/workflows/search-replace/types'
import type { SubBlockConfig } from '@/blocks/types'
import type { SelectorContext } from '@/hooks/selectors/types'
export type StructuredWorkflowSearchResourceKind = Exclude<
WorkflowSearchMatchKind,
'text' | 'environment' | 'workflow-reference'
>
interface ResourceCodecParseParams {
value: unknown
kind: StructuredWorkflowSearchResourceKind
subBlockConfig: Pick<SubBlockConfig, 'type' | 'serviceId' | 'selectorKey' | 'requiredScopes'>
selectorContext?: SelectorContext
}
export interface StructuredResourceReference {
kind: StructuredWorkflowSearchResourceKind
rawValue: string
searchText: string
resource: WorkflowSearchResourceMeta
}
interface ResourceCodecReplaceResult {
success: boolean
nextValue?: unknown
reason?: string
}
interface WorkflowSearchResourceCodec {
parse(params: ResourceCodecParseParams): StructuredResourceReference[]
contains(value: unknown, rawValue: string): boolean
replace(
value: unknown,
rawValue: string,
replacement: string,
targetOccurrenceIndex?: number
): ResourceCodecReplaceResult
}
interface WorkflowSearchResourceKindDefinition {
label: string
constrained: boolean
normalizeReplacement?: (replacement: string) => string
}
interface WorkflowSearchSubBlockResourceDefinition {
kind: StructuredWorkflowSearchResourceKind
codec: WorkflowSearchResourceCodec
}
function createResourceMeta({
kind,
rawValue,
subBlockConfig,
selectorContext,
}: {
kind: StructuredWorkflowSearchResourceKind
rawValue: string
subBlockConfig: Pick<SubBlockConfig, 'serviceId' | 'selectorKey' | 'requiredScopes'>
selectorContext?: SelectorContext
}): WorkflowSearchResourceMeta {
const resource: WorkflowSearchResourceMeta = {
kind,
providerId: subBlockConfig.serviceId,
serviceId: subBlockConfig.serviceId,
selectorKey: subBlockConfig.selectorKey,
selectorContext:
selectorContext && Object.keys(selectorContext).length > 0 ? selectorContext : undefined,
requiredScopes: subBlockConfig.requiredScopes,
key: rawValue,
}
resource.resourceGroupKey = buildWorkflowSearchResourceGroupKey(resource)
return resource
}
function splitCommaResourceValue(value: unknown): string[] {
if (typeof value === 'string') {
return value
.split(',')
.map((part) => part.trim())
.filter(Boolean)
}
if (Array.isArray(value)) {
return value.flatMap((item) => splitCommaResourceValue(item))
}
return []
}
function replaceCommaResourceValue(
value: unknown,
rawValue: string,
replacement: string,
targetOccurrenceIndex?: number
): ResourceCodecReplaceResult {
let occurrenceIndex = 0
let replaced = false
const shouldReplace = (item: string) => {
if (!item) return false
const currentOccurrenceIndex = occurrenceIndex
occurrenceIndex += 1
if (item !== rawValue) return false
const matchesTarget =
targetOccurrenceIndex === undefined || currentOccurrenceIndex === targetOccurrenceIndex
if (matchesTarget) replaced = true
return matchesTarget
}
const replaceItem = (item: unknown): unknown => {
if (typeof item === 'string') return shouldReplace(item) ? replacement : item
if (Array.isArray(item)) return item.map(replaceItem)
return item
}
if (typeof value === 'string') {
const parts = value.split(',').map((part) => part.trim())
if (parts.length > 1) {
const nextValue = parts.map(replaceItem).join(',')
if (targetOccurrenceIndex !== undefined && !replaced) {
return { success: false, reason: 'Target resource changed since search' }
}
return { success: true, nextValue }
}
const nextValue = shouldReplace(value) ? replacement : value
if (targetOccurrenceIndex !== undefined && !replaced) {
return { success: false, reason: 'Target resource changed since search' }
}
return { success: true, nextValue }
}
if (Array.isArray(value)) {
const nextValue = value.map(replaceItem)
if (targetOccurrenceIndex !== undefined && !replaced) {
return { success: false, reason: 'Target resource changed since search' }
}
return { success: true, nextValue }
}
return { success: false, reason: 'Target resource is no longer replaceable' }
}
function getFileResourceKey(value: unknown): string | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const record = value as Record<string, unknown>
const key = record.key ?? record.path ?? record.name
return typeof key === 'string' && key.trim().length > 0 ? key : null
}
function parseSerializedResourceValue(value: unknown): { value: unknown; serialized: boolean } {
if (typeof value !== 'string') return { value, serialized: false }
const trimmed = value.trim()
if (
!(
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
)
) {
return { value, serialized: false }
}
try {
return { value: JSON.parse(trimmed), serialized: true }
} catch {
return { value, serialized: false }
}
}
function parseFileReplacement(replacement: string): ResourceCodecReplaceResult {
try {
const parsed: unknown = JSON.parse(replacement)
if (!getFileResourceKey(parsed)) {
return { success: false, reason: 'Replacement file is no longer valid' }
}
return { success: true, nextValue: parsed }
} catch {
return { success: false, reason: 'Replacement file is no longer valid' }
}
}
const scalarResourceCodec: WorkflowSearchResourceCodec = {
parse({ value, kind, subBlockConfig, selectorContext }) {
return splitCommaResourceValue(value).map((rawValue) => ({
kind,
rawValue,
searchText: rawValue,
resource: createResourceMeta({ kind, rawValue, subBlockConfig, selectorContext }),
}))
},
contains(value, rawValue) {
return splitCommaResourceValue(value).includes(rawValue)
},
replace: replaceCommaResourceValue,
}
const fileUploadResourceCodec: WorkflowSearchResourceCodec = {
parse({ value, kind, subBlockConfig, selectorContext }) {
const parsed = parseSerializedResourceValue(value).value
const values = Array.isArray(parsed) ? parsed : parsed ? [parsed] : []
return values.flatMap((item) => {
const rawValue = getFileResourceKey(item)
if (!rawValue) return []
const name = (item as Record<string, unknown>).name
return [
{
kind,
rawValue,
searchText: typeof name === 'string' ? name : rawValue,
resource: createResourceMeta({ kind, rawValue, subBlockConfig, selectorContext }),
},
]
})
},
contains(value, rawValue) {
const parsed = parseSerializedResourceValue(value).value
if (Array.isArray(parsed))
return parsed.some((item) => fileUploadResourceCodec.contains(item, rawValue))
return getFileResourceKey(parsed) === rawValue
},
replace(value, rawValue, replacement, targetOccurrenceIndex) {
const parsed = parseSerializedResourceValue(value)
let occurrenceIndex = 0
let replaced = false
const shouldReplace = (item: unknown) => {
const itemKey = getFileResourceKey(item)
if (!itemKey) return false
const currentOccurrenceIndex = occurrenceIndex
occurrenceIndex += 1
if (itemKey !== rawValue) return false
const matchesTarget =
targetOccurrenceIndex === undefined || currentOccurrenceIndex === targetOccurrenceIndex
if (matchesTarget) replaced = true
return matchesTarget
}
const replaceItem = (item: unknown): ResourceCodecReplaceResult => {
if (!shouldReplace(item)) return { success: true, nextValue: item }
return parseFileReplacement(replacement)
}
if (Array.isArray(parsed.value)) {
const nextValue: unknown[] = []
for (const item of parsed.value) {
const result = replaceItem(item)
if (!result.success) return result
nextValue.push(result.nextValue)
}
if (targetOccurrenceIndex !== undefined && !replaced) {
return { success: false, reason: 'Target resource changed since search' }
}
return { success: true, nextValue: parsed.serialized ? JSON.stringify(nextValue) : nextValue }
}
const result = replaceItem(parsed.value)
if (!result.success) return result
if (targetOccurrenceIndex !== undefined && !replaced) {
return { success: false, reason: 'Target resource changed since search' }
}
if (!parsed.serialized) return result
return { success: true, nextValue: JSON.stringify(result.nextValue) }
},
}
const WORKFLOW_SEARCH_RESOURCE_KINDS: Record<
Exclude<WorkflowSearchMatchKind, 'text'>,
WorkflowSearchResourceKindDefinition
> = {
environment: {
label: 'environment variable',
constrained: true,
normalizeReplacement: (replacement) => {
const trimmed = replacement.trim()
if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) return trimmed
return `{{${trimmed}}}`
},
},
'workflow-reference': {
label: 'workflow reference',
constrained: false,
},
'oauth-credential': {
label: 'OAuth credential',
constrained: true,
},
'knowledge-base': {
label: 'knowledge base',
constrained: true,
},
'knowledge-document': {
label: 'knowledge document',
constrained: true,
},
workflow: {
label: 'workflow',
constrained: true,
},
'mcp-server': {
label: 'MCP server',
constrained: true,
},
'mcp-tool': {
label: 'MCP tool',
constrained: true,
},
table: {
label: 'table',
constrained: true,
},
file: {
label: 'file',
constrained: true,
},
'selector-resource': {
label: 'selector resource',
constrained: true,
},
}
const WORKFLOW_SEARCH_SUBBLOCK_RESOURCES: Partial<
Record<SubBlockType, WorkflowSearchSubBlockResourceDefinition>
> = {
'oauth-input': { kind: 'oauth-credential', codec: scalarResourceCodec },
'knowledge-base-selector': { kind: 'knowledge-base', codec: scalarResourceCodec },
'document-selector': { kind: 'knowledge-document', codec: scalarResourceCodec },
'workflow-selector': { kind: 'workflow', codec: scalarResourceCodec },
'mcp-server-selector': { kind: 'mcp-server', codec: scalarResourceCodec },
'mcp-tool-selector': { kind: 'mcp-tool', codec: scalarResourceCodec },
'table-selector': { kind: 'table', codec: scalarResourceCodec },
'file-selector': { kind: 'file', codec: scalarResourceCodec },
'file-upload': { kind: 'file', codec: fileUploadResourceCodec },
'channel-selector': { kind: 'selector-resource', codec: scalarResourceCodec },
'user-selector': { kind: 'selector-resource', codec: scalarResourceCodec },
'sheet-selector': { kind: 'selector-resource', codec: scalarResourceCodec },
'folder-selector': { kind: 'selector-resource', codec: scalarResourceCodec },
'project-selector': { kind: 'selector-resource', codec: scalarResourceCodec },
}
export function getWorkflowSearchResourceKindDefinition(
kind: WorkflowSearchMatchKind
): WorkflowSearchResourceKindDefinition | null {
return kind === 'text' ? null : WORKFLOW_SEARCH_RESOURCE_KINDS[kind]
}
export function isConstrainedWorkflowSearchResourceKind(kind: WorkflowSearchMatchKind): boolean {
return getWorkflowSearchResourceKindDefinition(kind)?.constrained ?? false
}
export function getWorkflowSearchResourceKindLabel(kind: WorkflowSearchMatchKind): string {
return getWorkflowSearchResourceKindDefinition(kind)?.label ?? 'resource'
}
export function normalizeWorkflowSearchResourceReplacement(
match: WorkflowSearchMatch,
replacement: string
): string {
return (
getWorkflowSearchResourceKindDefinition(match.kind)?.normalizeReplacement?.(replacement) ??
replacement
)
}
export function getWorkflowSearchSubBlockResourceDefinition(
subBlockConfig?: Pick<SubBlockConfig, 'type'>
): WorkflowSearchSubBlockResourceDefinition | null {
if (!subBlockConfig) return null
return WORKFLOW_SEARCH_SUBBLOCK_RESOURCES[subBlockConfig.type] ?? null
}
export function getWorkflowSearchSubBlockResourceKind(
subBlockConfig?: Pick<SubBlockConfig, 'type'>
): StructuredWorkflowSearchResourceKind | null {
return getWorkflowSearchSubBlockResourceDefinition(subBlockConfig)?.kind ?? null
}
export function parseWorkflowSearchSubBlockResources(
value: unknown,
subBlockConfig?: Pick<SubBlockConfig, 'type' | 'serviceId' | 'selectorKey' | 'requiredScopes'>,
selectorContext?: SelectorContext
): StructuredResourceReference[] {
const definition = getWorkflowSearchSubBlockResourceDefinition(subBlockConfig)
if (!definition || !subBlockConfig) return []
return definition.codec.parse({
value,
kind: definition.kind,
subBlockConfig,
selectorContext,
})
}
export function workflowSearchResourceValueContains(
match: WorkflowSearchMatch,
value: unknown
): boolean {
return (
getWorkflowSearchSubBlockResourceDefinition({ type: match.subBlockType })?.codec.contains(
value,
match.rawValue
) ?? false
)
}
export function replaceWorkflowSearchResourceValue(
match: WorkflowSearchMatch,
value: unknown,
replacement: string
): ResourceCodecReplaceResult {
const codec = getWorkflowSearchSubBlockResourceDefinition({ type: match.subBlockType })?.codec
if (!codec) return { success: false, reason: 'Target resource is no longer replaceable' }
return codec.replace(value, match.rawValue, replacement, match.structuredOccurrenceIndex)
}
@@ -1,46 +1,16 @@
import { replacementOptionMatchesResourceMatch } from '@/lib/workflows/search-replace/resource-resolvers'
import {
getWorkflowSearchResourceKindLabel,
isConstrainedWorkflowSearchResourceKind,
normalizeWorkflowSearchResourceReplacement,
} from '@/lib/workflows/search-replace/resources/registry'
import { replacementOptionMatchesResourceMatch } from '@/lib/workflows/search-replace/resources/resolvers'
import type {
WorkflowSearchMatch,
WorkflowSearchMatchKind,
WorkflowSearchReplacementOption,
} from '@/lib/workflows/search-replace/types'
const CONSTRAINED_RESOURCE_KINDS = new Set<WorkflowSearchMatchKind>([
'environment',
'oauth-credential',
'knowledge-base',
'knowledge-document',
'workflow',
'mcp-server',
'mcp-tool',
'table',
'file',
'selector-resource',
])
const RESOURCE_KIND_LABELS: Partial<Record<WorkflowSearchMatchKind, string>> = {
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}}}`
return isConstrainedWorkflowSearchResourceKind(match.kind)
}
export function getCompatibleResourceReplacementOptions(
@@ -81,7 +51,7 @@ export function getWorkflowSearchReplacementIssue({
}
const [firstMatch] = constrainedMatches
const normalizedReplacement = normalizeResourceReplacement(firstMatch, replacement)
const normalizedReplacement = normalizeWorkflowSearchResourceReplacement(firstMatch, replacement)
const compatibleOptions = getCompatibleResourceReplacementOptions(
constrainedMatches,
resourceOptions
@@ -92,6 +62,6 @@ export function getWorkflowSearchReplacementIssue({
if (hasResolvableReplacement) return null
const label = RESOURCE_KIND_LABELS[firstMatch.kind] ?? 'resource'
const label = getWorkflowSearchResourceKindLabel(firstMatch.kind)
return `Choose a valid ${label} replacement.`
}
@@ -45,6 +45,60 @@ export const SEARCH_REPLACE_BLOCK_CONFIGS: Record<string, { subBlocks: SubBlockC
{ id: 'headers', title: 'Headers', type: 'table' },
],
},
slack: {
subBlocks: [
{
id: 'authMethod',
title: 'Authentication Method',
type: 'dropdown',
},
{
id: 'credential',
title: 'Slack Account',
type: 'oauth-input',
serviceId: 'slack',
canonicalParamId: 'oauthCredential',
condition: { field: 'authMethod', value: 'oauth' },
},
{
id: 'text',
title: 'Message',
type: 'long-input',
},
{
id: 'channel',
title: 'Channel',
type: 'channel-selector',
serviceId: 'slack',
selectorKey: 'slack.channels',
dependsOn: ['credential'],
},
{
id: 'attachmentFiles',
title: 'Attachments',
type: 'file-upload',
canonicalParamId: 'files',
condition: { field: 'operation', value: 'send' },
mode: 'basic',
},
],
},
workflow_input: {
subBlocks: [
{
id: 'workflowId',
title: 'Workflow',
type: 'workflow-selector',
selectorKey: 'sim.workflows',
},
{
id: 'inputMapping',
title: 'Inputs',
type: 'input-mapping',
dependsOn: ['workflowId'],
},
],
},
}
export function createSearchReplaceWorkflowFixture(): WorkflowSearchWorkflow {
@@ -0,0 +1,34 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS,
workflowSearchSubflowFieldMatchesExpected,
} from '@/lib/workflows/search-replace/subflow-fields'
describe('workflowSearchSubflowFieldMatchesExpected', () => {
it('detects stale loop and parallel field values before replace apply', () => {
expect(
workflowSearchSubflowFieldMatchesExpected(
{ type: 'loop', data: { loopType: 'for', count: 5 } },
WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations,
'5'
)
).toBe(true)
expect(
workflowSearchSubflowFieldMatchesExpected(
{ type: 'loop', data: { loopType: 'for', count: 10 } },
WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations,
'5'
)
).toBe(false)
expect(
workflowSearchSubflowFieldMatchesExpected(
{ type: 'parallel', data: { parallelType: 'collection', collection: '{{items}}' } },
WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items,
'{{items}}'
)
).toBe(true)
})
})
@@ -126,6 +126,15 @@ export function getWorkflowSearchSubflowField(
return getWorkflowSearchSubflowFields(block).find((field) => field.id === fieldId)
}
export function workflowSearchSubflowFieldMatchesExpected(
block: WorkflowSearchSubflowBlock,
fieldId: WorkflowSearchSubflowFieldId,
expectedValue: unknown
): boolean {
const field = getWorkflowSearchSubflowField(block, fieldId)
return Boolean(field && String(field.value) === String(expectedValue))
}
export function parseWorkflowSearchSubflowReplacement({
blockType,
fieldId,
+7 -16
View File
@@ -62,6 +62,7 @@ export interface WorkflowSearchMatch {
searchText: string
range?: WorkflowSearchRange
structuredOccurrenceIndex?: number
dependentValuePaths?: WorkflowSearchValuePath[]
resource?: WorkflowSearchResourceMeta
editable: boolean
navigable: boolean
@@ -92,22 +93,12 @@ export interface WorkflowSearchIndexerOptions {
readonlyReason?: string
workspaceId?: string
workflowId?: string
blockConfigs?: Record<string, { subBlocks?: SubBlockConfig[] } | undefined>
}
export interface IndexedSubBlockContext {
block: WorkflowSearchBlockState
blockConfig?: { subBlocks?: SubBlockConfig[] }
subBlockConfig?: SubBlockConfig
subBlockId: string
canonicalSubBlockId: string
protected: boolean
isSnapshotView?: boolean
}
export interface WorkflowSearchReplacementTarget {
matchId: string
replacement: string
blockConfigs?: Record<
string,
| { subBlocks?: SubBlockConfig[]; triggers?: { enabled?: boolean }; category?: string }
| undefined
>
credentialTypeById?: Record<string, string | undefined>
}
export interface WorkflowSearchReplacementOption {
@@ -32,6 +32,54 @@ export interface CanonicalValueSelection {
advancedSourceId?: string
}
interface TriggerVisibilityBlockConfig {
category?: string
triggers?: {
enabled?: boolean
}
}
export function parseDependsOn(dependsOn: SubBlockConfig['dependsOn']): {
allFields: string[]
anyFields: string[]
allDependsOnFields: string[]
} {
if (!dependsOn) {
return { allFields: [], anyFields: [], allDependsOnFields: [] }
}
if (Array.isArray(dependsOn)) {
return { allFields: dependsOn, anyFields: [], allDependsOnFields: dependsOn }
}
const allFields = dependsOn.all || []
const anyFields = dependsOn.any || []
return {
allFields,
anyFields,
allDependsOnFields: [...allFields, ...anyFields],
}
}
export function normalizeDependencyValue(rawValue: unknown): unknown {
if (rawValue === null || rawValue === undefined) return null
if (typeof rawValue === 'object') {
if (Array.isArray(rawValue)) {
if (rawValue.length === 0) return null
return rawValue.map((item) => normalizeDependencyValue(item))
}
const record = rawValue as Record<string, unknown>
if ('value' in record) return normalizeDependencyValue(record.value)
if ('id' in record) return record.id
return record
}
return rawValue
}
/**
* Build a flat map of subblock values keyed by subblock id.
*/
@@ -262,6 +310,38 @@ export function isSubBlockVisibleForMode(
return true
}
export function isTriggerModeSubBlock(subBlock: Pick<SubBlockConfig, 'mode'>): boolean {
return subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced'
}
export function isTriggerConfigSubBlock(subBlock: Pick<SubBlockConfig, 'type'>): boolean {
return String(subBlock.type) === 'trigger-config'
}
export function shouldUseSubBlockForTriggerModeCanonicalIndex(
subBlock: Pick<SubBlockConfig, 'mode' | 'type'>
): boolean {
return isTriggerModeSubBlock(subBlock) || isTriggerConfigSubBlock(subBlock)
}
export function isPureTriggerBlockConfig(blockConfig?: TriggerVisibilityBlockConfig): boolean {
return Boolean(blockConfig?.triggers?.enabled && blockConfig.category === 'triggers')
}
export function isSubBlockVisibleForTriggerMode(
subBlock: Pick<SubBlockConfig, 'mode' | 'type'>,
displayTriggerMode: boolean,
blockConfig?: TriggerVisibilityBlockConfig
): boolean {
if (isTriggerConfigSubBlock(subBlock)) {
return displayTriggerMode || isPureTriggerBlockConfig(blockConfig)
}
if (isTriggerModeSubBlock(subBlock)) return displayTriggerMode
if (displayTriggerMode) return false
return true
}
/**
* Resolve the dependency value for a dependsOn key, honoring canonical swaps.
*/
@@ -0,0 +1,11 @@
const SYNTHETIC_TOOL_SUBBLOCK_RE = /-tool-\d+-/
/**
* Returns true for ToolSubBlockRenderer mirror subblocks.
*
* These IDs follow `{subBlockId}-tool-{index}-{paramId}` and duplicate values
* already stored in the aggregate `tool-input` subblock.
*/
export function isSyntheticToolSubBlockId(subBlockId: string): boolean {
return SYNTHETIC_TOOL_SUBBLOCK_RE.test(subBlockId)
}
@@ -0,0 +1,70 @@
export interface StoredToolSchema {
description?: string
properties?: Record<string, unknown>
required?: string[]
function?: {
name?: string
parameters?: {
properties?: Record<string, unknown>
required?: string[]
}
}
}
/**
* Represents a tool selected and configured in a workflow tool-input field.
*/
export interface StoredTool {
type: string
title?: string
toolId?: string
params?: Record<string, string>
isExpanded?: boolean
customToolId?: string
schema?: StoredToolSchema
code?: string
operation?: string
usageControl?: 'auto' | 'force' | 'none'
}
export interface ParsedStoredTool extends Omit<StoredTool, 'params'> {
params?: Record<string, unknown>
}
export function parseStoredToolInputValue(value: unknown): ParsedStoredTool[] {
if (!Array.isArray(value)) return []
return value.flatMap((tool) => {
if (!tool || typeof tool !== 'object' || Array.isArray(tool)) return []
const record = tool as Record<string, unknown>
if (typeof record.type !== 'string') return []
const params =
record.params && typeof record.params === 'object' && !Array.isArray(record.params)
? (record.params as Record<string, unknown>)
: undefined
return [
{
type: record.type,
title: typeof record.title === 'string' ? record.title : undefined,
toolId: typeof record.toolId === 'string' ? record.toolId : undefined,
operation: typeof record.operation === 'string' ? record.operation : undefined,
params,
customToolId: typeof record.customToolId === 'string' ? record.customToolId : undefined,
code: typeof record.code === 'string' ? record.code : undefined,
usageControl:
record.usageControl === 'auto' ||
record.usageControl === 'force' ||
record.usageControl === 'none'
? record.usageControl
: undefined,
isExpanded: typeof record.isExpanded === 'boolean' ? record.isExpanded : undefined,
schema:
record.schema && typeof record.schema === 'object' && !Array.isArray(record.schema)
? (record.schema as StoredToolSchema)
: undefined,
},
]
})
}
+47 -14
View File
@@ -6,10 +6,15 @@ import {
evaluateSubBlockCondition,
isCanonicalPair,
isSubBlockHidden,
isTriggerModeSubBlock,
resolveCanonicalMode,
type SubBlockCondition,
} from '@/lib/workflows/subblocks/visibility'
import type { SubBlockConfig as BlockSubBlockConfig, GenerationType } from '@/blocks/types'
import type {
BlockConfig as AppBlockConfig,
SubBlockConfig as BlockSubBlockConfig,
GenerationType,
} from '@/blocks/types'
import { safeAssign } from '@/tools/safe-assign'
import { isEmptyTagValue } from '@/tools/shared/tags'
import type { OAuthConfig, ParameterVisibility, ToolConfig } from '@/tools/types'
@@ -49,6 +54,7 @@ export interface UIComponentConfig {
title?: string
value?: unknown
serviceId?: string
selectorKey?: BlockSubBlockConfig['selectorKey']
requiredScopes?: string[]
mimeType?: string
columns?: string[]
@@ -104,10 +110,7 @@ export interface SubBlockConfig {
dependsOn?: string[]
}
export interface BlockConfig {
type: string
subBlocks?: SubBlockConfig[]
}
type ToolInputBlockConfig = Pick<AppBlockConfig, 'type' | 'subBlocks' | 'tools'>
export interface SchemaProperty {
type: string
@@ -157,15 +160,15 @@ export interface ToolWithParameters {
optionalParameters: ToolParameterConfig[] // Nice to have, shown to user
}
let blockConfigCache: Record<string, BlockConfig> | null = null
let blockConfigCache: Record<string, ToolInputBlockConfig> | null = null
function getBlockConfigurations(): Record<string, BlockConfig> {
function getBlockConfigurations(): Record<string, ToolInputBlockConfig> {
if (!blockConfigCache) {
try {
const { getAllBlocks } = require('@/blocks')
const allBlocks = getAllBlocks()
blockConfigCache = {}
allBlocks.forEach((block: BlockConfig) => {
allBlocks.forEach((block: AppBlockConfig) => {
blockConfigCache![block.type] = block
})
} catch (error) {
@@ -176,13 +179,39 @@ function getBlockConfigurations(): Record<string, BlockConfig> {
return blockConfigCache
}
/**
* Gets the correct tool ID for a block operation.
*/
export function getToolIdForOperation(blockType: string, operation?: string): string | undefined {
const block = getBlockConfigurations()[blockType]
if (!block?.tools?.access) return undefined
if (block.tools.access.length === 1) {
return block.tools.access[0]
}
if (operation && block.tools.config?.tool) {
try {
return block.tools.config.tool({ operation })
} catch (error) {
logger.error('Error selecting tool for operation:', error)
}
}
if (operation && block.tools.access.includes(operation)) {
return operation
}
return block.tools.access[0]
}
function resolveSubBlockForParam(
paramId: string,
subBlocks: SubBlockConfig[],
subBlocks: BlockSubBlockConfig[],
valuesWithOperation: Record<string, unknown>,
paramType: string
): BlockSubBlockConfig | undefined {
const blockSubBlocks = subBlocks as BlockSubBlockConfig[]
const blockSubBlocks = subBlocks
// First pass: find subblock with matching condition
let fallbackMatch: BlockSubBlockConfig | undefined
@@ -252,6 +281,7 @@ export function getToolParametersConfig(
uiComponent: {
type: 'workflow-selector',
placeholder: 'Select workflow to execute',
selectorKey: 'sim.workflows',
},
},
{
@@ -268,6 +298,7 @@ export function getToolParametersConfig(
value: '',
not: true, // Show when workflowId is not empty
},
dependsOn: ['workflowId'],
},
},
]
@@ -286,7 +317,7 @@ export function getToolParametersConfig(
}
// Get block configuration for UI component information
let blockConfig: BlockConfig | null = null
let blockConfig: ToolInputBlockConfig | null = null
if (blockType) {
const blockConfigs = getBlockConfigurations()
blockConfig = blockConfigs[blockType] || null
@@ -337,6 +368,7 @@ export function getToolParametersConfig(
title: subBlock.title,
value: subBlock.value,
serviceId: subBlock.serviceId,
selectorKey: subBlock.selectorKey,
requiredScopes: subBlock.requiredScopes,
mimeType: subBlock.mimeType,
columns: subBlock.columns,
@@ -955,7 +987,8 @@ export function getSubBlocksForToolInput(
toolId: string,
blockType: string,
currentValues?: Record<string, unknown>,
canonicalModeOverrides?: CanonicalModeOverrides
canonicalModeOverrides?: CanonicalModeOverrides,
blockConfigOverride?: Pick<ToolInputBlockConfig, 'subBlocks'>
): SubBlocksForToolInput | null {
try {
const toolConfig = getTool(toolId)
@@ -965,7 +998,7 @@ export function getSubBlocksForToolInput(
}
const blockConfigs = getBlockConfigurations()
const blockConfig = blockConfigs[blockType]
const blockConfig = blockConfigOverride ?? blockConfigs[blockType]
if (!blockConfig?.subBlocks?.length) {
return null
}
@@ -999,7 +1032,7 @@ export function getSubBlocksForToolInput(
if (EXCLUDED_SUBBLOCK_TYPES.has(sb.type)) continue
// Skip trigger-mode-only subblocks
if (sb.mode === 'trigger' || sb.mode === 'trigger-advanced') continue
if (isTriggerModeSubBlock(sb)) continue
// Hide tool API key fields when running on hosted Sim or when env var is set
if (isSubBlockHidden(sb)) continue