mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Vikhyath Mondreti
parent
eb871fc0de
commit
57f00e2a96
+2
-4
@@ -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'
|
||||
|
||||
+5
-1
@@ -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>
|
||||
|
||||
+41
-48
@@ -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={
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const WORKFLOW_SEARCH_HIGHLIGHT_CLASS =
|
||||
'rounded-sm bg-orange-400 shadow-[3px_0_0_#fb923c,-3px_0_0_#fb923c]'
|
||||
+19
-4
@@ -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}
|
||||
|
||||
+4
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+11
-9
@@ -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 (
|
||||
<>
|
||||
|
||||
+22
-4
@@ -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>
|
||||
)}
|
||||
|
||||
+1
-30
@@ -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,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'
|
||||
|
||||
+2
-52
@@ -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>
|
||||
|
||||
+26
-3
@@ -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()}
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+4
-7
@@ -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
-20
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
+19
-2
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
+22
-24
@@ -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'>
|
||||
|
||||
+25
-12
@@ -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 && (
|
||||
|
||||
@@ -14,6 +14,7 @@ export const WorkflowBlock: BlockConfig = {
|
||||
id: 'workflowId',
|
||||
title: 'Select Workflow',
|
||||
type: 'workflow-selector',
|
||||
selectorKey: 'sim.workflows',
|
||||
placeholder: 'Search workflows...',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface SelectorContext {
|
||||
awsSecretAccessKey?: string
|
||||
awsRegion?: string
|
||||
logGroupName?: string
|
||||
mcpServerId?: string
|
||||
}
|
||||
|
||||
export interface SelectorQueryArgs {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
+8
-73
@@ -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)
|
||||
}
|
||||
+9
-39
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user