chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getSubBlocksDependingOnChange } from '@/blocks/utils'
|
||||
|
||||
export interface DependentClear {
|
||||
subBlockId: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export function getWorkflowSearchDependentClears(
|
||||
allSubBlocks: SubBlockConfig[],
|
||||
changedSubBlockId: string
|
||||
): DependentClear[] {
|
||||
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
@@ -0,0 +1,334 @@
|
||||
import { replaceJsonStringLeafRange } from '@/lib/workflows/search-replace/json-value-fields'
|
||||
import {
|
||||
getWorkflowSearchReplacementIssue,
|
||||
normalizeWorkflowSearchResourceReplacement,
|
||||
replaceWorkflowSearchResourceValue,
|
||||
workflowSearchResourceValueContains,
|
||||
} from '@/lib/workflows/search-replace/resources'
|
||||
import {
|
||||
getWorkflowSearchSubflowField,
|
||||
parseWorkflowSearchSubflowReplacement,
|
||||
} from '@/lib/workflows/search-replace/subflow-fields'
|
||||
import type {
|
||||
WorkflowSearchBlockState,
|
||||
WorkflowSearchMatch,
|
||||
WorkflowSearchReplacementOption,
|
||||
WorkflowSearchReplacePlan,
|
||||
WorkflowSearchReplaceSubflowUpdate,
|
||||
WorkflowSearchReplaceUpdate,
|
||||
} from '@/lib/workflows/search-replace/types'
|
||||
import {
|
||||
getValueAtPath,
|
||||
pathToKey,
|
||||
setValueAtPath,
|
||||
} from '@/lib/workflows/search-replace/value-walker'
|
||||
|
||||
interface BuildWorkflowSearchReplacePlanParams {
|
||||
blocks: Record<string, WorkflowSearchBlockState>
|
||||
matches: WorkflowSearchMatch[]
|
||||
selectedMatchIds: Set<string>
|
||||
replacementByMatchId?: Record<string, string>
|
||||
defaultReplacement?: string
|
||||
resourceReplacementOptions?: WorkflowSearchReplacementOption[]
|
||||
}
|
||||
|
||||
function normalizeReplacement(match: WorkflowSearchMatch, replacement: string): string {
|
||||
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 clearDependentValues(value: unknown, paths: WorkflowSearchMatch['dependentValuePaths']) {
|
||||
return (paths ?? []).reduce((currentValue, path) => setValueAtPath(currentValue, path, ''), value)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return touchedPathsByField
|
||||
}
|
||||
|
||||
function getResourceReplacementScope(
|
||||
value: unknown,
|
||||
match: WorkflowSearchMatch
|
||||
): { value: unknown; path: WorkflowSearchMatch['valuePath'] } | null {
|
||||
for (let prefixLength = match.valuePath.length; prefixLength >= 0; prefixLength -= 1) {
|
||||
const path = match.valuePath.slice(0, prefixLength)
|
||||
const candidateValue = path.length === 0 ? value : getValueAtPath(value, path)
|
||||
if (workflowSearchResourceValueContains(match, candidateValue)) {
|
||||
return { value: candidateValue, path }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
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(
|
||||
match: WorkflowSearchMatch,
|
||||
replacementByMatchId: Record<string, string> | undefined,
|
||||
defaultReplacement: string | undefined
|
||||
): string | undefined {
|
||||
const replacement = replacementByMatchId?.[match.id] ?? defaultReplacement
|
||||
if (replacement === undefined) return undefined
|
||||
return normalizeReplacement(match, replacement)
|
||||
}
|
||||
|
||||
export function buildWorkflowSearchReplacePlan({
|
||||
blocks,
|
||||
matches,
|
||||
selectedMatchIds,
|
||||
replacementByMatchId,
|
||||
defaultReplacement,
|
||||
resourceReplacementOptions,
|
||||
}: BuildWorkflowSearchReplacePlanParams): WorkflowSearchReplacePlan {
|
||||
const skipped: WorkflowSearchReplacePlan['skipped'] = []
|
||||
const conflicts: WorkflowSearchReplacePlan['conflicts'] = []
|
||||
const updatesByField = new Map<string, WorkflowSearchReplaceUpdate>()
|
||||
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
|
||||
const subBlockCompare = a.subBlockId.localeCompare(b.subBlockId)
|
||||
if (subBlockCompare !== 0) return subBlockCompare
|
||||
const pathCompare = pathToKey(a.valuePath).localeCompare(pathToKey(b.valuePath))
|
||||
if (pathCompare !== 0) return pathCompare
|
||||
const occurrenceCompare =
|
||||
(b.structuredOccurrenceIndex ?? 0) - (a.structuredOccurrenceIndex ?? 0)
|
||||
if (occurrenceCompare !== 0) return occurrenceCompare
|
||||
return (b.range?.start ?? 0) - (a.range?.start ?? 0)
|
||||
})
|
||||
|
||||
for (const match of orderedMatches) {
|
||||
const replacement = getReplacement(match, replacementByMatchId, defaultReplacement)
|
||||
if (replacement === undefined || replacement === match.rawValue) {
|
||||
skipped.push({ matchId: match.id, reason: 'No replacement value provided' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!match.editable) {
|
||||
skipped.push({ matchId: match.id, reason: match.reason ?? 'Match is not editable' })
|
||||
continue
|
||||
}
|
||||
|
||||
const replacementIssue = getWorkflowSearchReplacementIssue({
|
||||
matches: [match],
|
||||
replacement,
|
||||
resourceOptions: resourceReplacementOptions,
|
||||
})
|
||||
if (replacementIssue) {
|
||||
conflicts.push({ matchId: match.id, reason: replacementIssue })
|
||||
continue
|
||||
}
|
||||
|
||||
const block = blocks[match.blockId]
|
||||
if (!block) {
|
||||
conflicts.push({ matchId: match.id, reason: 'Block no longer exists' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (match.target.kind === 'subflow') {
|
||||
if (block.type !== 'loop' && block.type !== 'parallel') {
|
||||
conflicts.push({ matchId: match.id, reason: 'Subflow block no longer exists' })
|
||||
continue
|
||||
}
|
||||
|
||||
const currentField = getWorkflowSearchSubflowField(block, match.target.fieldId)
|
||||
if (!currentField) {
|
||||
conflicts.push({ matchId: match.id, reason: 'Subflow field is no longer available' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentField.editable) {
|
||||
conflicts.push({
|
||||
matchId: match.id,
|
||||
reason: currentField.reason ?? 'Subflow field is not editable',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const updateKey = `${match.blockId}:${match.target.fieldId}`
|
||||
const existingUpdate = subflowUpdatesByField.get(updateKey)
|
||||
const previousValue = existingUpdate?.previousValue ?? currentField.value
|
||||
const currentValue = String(existingUpdate?.nextValue ?? currentField.value)
|
||||
|
||||
if (!match.range) {
|
||||
conflicts.push({ matchId: match.id, reason: 'Subflow target is no longer text' })
|
||||
continue
|
||||
}
|
||||
|
||||
const currentRawValue = currentValue.slice(match.range.start, match.range.end)
|
||||
if (currentRawValue !== match.rawValue) {
|
||||
conflicts.push({ matchId: match.id, reason: 'Subflow target changed since search' })
|
||||
continue
|
||||
}
|
||||
|
||||
const nextTextValue = replaceRange(
|
||||
currentValue,
|
||||
match.range.start,
|
||||
match.range.end,
|
||||
replacement
|
||||
)
|
||||
const parsedReplacement = parseWorkflowSearchSubflowReplacement({
|
||||
blockType: block.type,
|
||||
fieldId: match.target.fieldId,
|
||||
replacement: nextTextValue,
|
||||
})
|
||||
if (!parsedReplacement.success) {
|
||||
conflicts.push({ matchId: match.id, reason: parsedReplacement.reason })
|
||||
continue
|
||||
}
|
||||
|
||||
subflowUpdatesByField.set(updateKey, {
|
||||
blockId: match.blockId,
|
||||
blockType: block.type,
|
||||
fieldId: match.target.fieldId,
|
||||
previousValue,
|
||||
nextValue: parsedReplacement.value,
|
||||
matchIds: [...(existingUpdate?.matchIds ?? []), match.id],
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const subBlock = block?.subBlocks?.[match.subBlockId]
|
||||
if (!subBlock) {
|
||||
conflicts.push({ matchId: match.id, reason: 'Block or subblock no longer exists' })
|
||||
continue
|
||||
}
|
||||
|
||||
const updateKey = `${match.blockId}:${match.subBlockId}`
|
||||
const existingUpdate = updatesByField.get(updateKey)
|
||||
const previousValue: unknown = existingUpdate?.previousValue ?? subBlock.value
|
||||
let nextValue: unknown = existingUpdate?.nextValue ?? subBlock.value
|
||||
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' })
|
||||
continue
|
||||
}
|
||||
|
||||
const currentRawValue = currentLeaf.slice(match.range.start, match.range.end)
|
||||
if (currentRawValue !== match.rawValue) {
|
||||
conflicts.push({ matchId: match.id, reason: 'Target text changed since search' })
|
||||
continue
|
||||
}
|
||||
|
||||
nextValue = setValueAtPath(
|
||||
nextValue,
|
||||
match.valuePath,
|
||||
replaceRange(currentLeaf, match.range.start, match.range.end, replacement)
|
||||
)
|
||||
nextValue = clearDependentValues(nextValue, dependentValuePathsToClear)
|
||||
} else {
|
||||
const replacementScope = getResourceReplacementScope(nextValue, match)
|
||||
if (!replacementScope) {
|
||||
conflicts.push({ matchId: match.id, reason: 'Target resource changed since search' })
|
||||
continue
|
||||
}
|
||||
|
||||
const resourceReplacement = replaceWorkflowSearchResourceValue(
|
||||
match,
|
||||
replacementScope.value,
|
||||
replacement
|
||||
)
|
||||
if (!resourceReplacement.success) {
|
||||
conflicts.push({
|
||||
matchId: match.id,
|
||||
reason: resourceReplacement.reason ?? 'Target resource is no longer replaceable',
|
||||
})
|
||||
continue
|
||||
}
|
||||
const replacedValue = resourceReplacement.nextValue
|
||||
nextValue =
|
||||
replacementScope.path.length === 0
|
||||
? replacedValue
|
||||
: setValueAtPath(nextValue, replacementScope.path, replacedValue)
|
||||
nextValue = clearDependentValues(nextValue, dependentValuePathsToClear)
|
||||
}
|
||||
|
||||
updatesByField.set(updateKey, {
|
||||
blockId: match.blockId,
|
||||
subBlockId: match.subBlockId,
|
||||
previousValue,
|
||||
nextValue,
|
||||
matchIds: [...(existingUpdate?.matchIds ?? []), match.id],
|
||||
})
|
||||
}
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
return { updates: [], subflowUpdates: [], skipped, conflicts }
|
||||
}
|
||||
|
||||
return {
|
||||
updates: [...updatesByField.values()].filter(
|
||||
(update) => update.previousValue !== update.nextValue
|
||||
),
|
||||
subflowUpdates: [...subflowUpdatesByField.values()].filter((update) => {
|
||||
if (typeof update.nextValue === 'number')
|
||||
return String(update.nextValue) !== update.previousValue
|
||||
return update.nextValue !== update.previousValue
|
||||
}),
|
||||
skipped,
|
||||
conflicts,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './references'
|
||||
export * from './registry'
|
||||
export * from './resolvers'
|
||||
export * from './validation'
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
getWorkflowSearchSubBlockResourceKind,
|
||||
parseWorkflowSearchSubBlockResources,
|
||||
type StructuredResourceReference,
|
||||
} from '@/lib/workflows/search-replace/resources/registry'
|
||||
import type {
|
||||
WorkflowSearchRange,
|
||||
WorkflowSearchResourceMeta,
|
||||
} from '@/lib/workflows/search-replace/types'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
|
||||
export interface ParsedInlineReference {
|
||||
kind: 'environment' | 'workflow-reference'
|
||||
rawValue: string
|
||||
searchText: string
|
||||
range: WorkflowSearchRange
|
||||
resource: WorkflowSearchResourceMeta
|
||||
}
|
||||
|
||||
export function getResourceKindForSubBlock(
|
||||
subBlockConfig?: Pick<SubBlockConfig, 'type'>
|
||||
): StructuredResourceReference['kind'] | null {
|
||||
return getWorkflowSearchSubBlockResourceKind(subBlockConfig)
|
||||
}
|
||||
|
||||
export function parseInlineReferences(value: string): ParsedInlineReference[] {
|
||||
const references: ParsedInlineReference[] = []
|
||||
|
||||
const envPattern = createEnvVarPattern()
|
||||
for (const match of value.matchAll(envPattern)) {
|
||||
const rawValue = match[0]
|
||||
const key = String(match[1] ?? '').trim()
|
||||
const start = match.index ?? 0
|
||||
references.push({
|
||||
kind: 'environment',
|
||||
rawValue,
|
||||
searchText: key,
|
||||
range: { start, end: start + rawValue.length },
|
||||
resource: {
|
||||
kind: 'environment',
|
||||
token: rawValue,
|
||||
key,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const referencePattern = createReferencePattern()
|
||||
for (const match of value.matchAll(referencePattern)) {
|
||||
const rawValue = match[0]
|
||||
const reference = String(match[1] ?? '').trim()
|
||||
const start = match.index ?? 0
|
||||
references.push({
|
||||
kind: 'workflow-reference',
|
||||
rawValue,
|
||||
searchText: reference,
|
||||
range: { start, end: start + rawValue.length },
|
||||
resource: {
|
||||
kind: 'workflow-reference',
|
||||
token: rawValue,
|
||||
key: reference,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return references.sort((a, b) => a.range.start - b.range.start)
|
||||
}
|
||||
|
||||
export function parseStructuredResourceReferences(
|
||||
value: unknown,
|
||||
subBlockConfig?: Pick<SubBlockConfig, 'type' | 'serviceId' | 'selectorKey' | 'requiredScopes'>,
|
||||
selectorContext?: SelectorContext
|
||||
): StructuredResourceReference[] {
|
||||
return parseWorkflowSearchSubBlockResources(value, subBlockConfig, selectorContext)
|
||||
}
|
||||
|
||||
export function matchesSearchText(
|
||||
candidate: string,
|
||||
query: string | undefined,
|
||||
caseSensitive = false
|
||||
): boolean {
|
||||
if (!query) return true
|
||||
const source = caseSensitive ? candidate : candidate.toLowerCase()
|
||||
const target = caseSensitive ? query : query.toLowerCase()
|
||||
return source.includes(target)
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import type { SubBlockType } from '@sim/workflow-types/blocks'
|
||||
import { buildWorkflowSearchResourceGroupKey } from '@/lib/workflows/search-replace/resources/resolvers'
|
||||
import type {
|
||||
WorkflowSearchMatch,
|
||||
WorkflowSearchMatchKind,
|
||||
WorkflowSearchResourceMeta,
|
||||
WorkflowSearchValuePath,
|
||||
} 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' | 'multiSelect' | 'multiple'
|
||||
>
|
||||
selectorContext?: SelectorContext
|
||||
}
|
||||
|
||||
export interface StructuredResourceReference {
|
||||
kind: StructuredWorkflowSearchResourceKind
|
||||
rawValue: string
|
||||
searchText: string
|
||||
valuePath?: WorkflowSearchValuePath
|
||||
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 }) {
|
||||
const values = splitCommaResourceValue(value)
|
||||
return values.map((rawValue, index) => ({
|
||||
kind,
|
||||
rawValue,
|
||||
searchText: rawValue,
|
||||
valuePath: subBlockConfig.multiSelect || values.length > 1 ? [index] : [],
|
||||
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, index) => {
|
||||
const rawValue = getFileResourceKey(item)
|
||||
if (!rawValue) return []
|
||||
const name = (item as Record<string, unknown>).name
|
||||
return [
|
||||
{
|
||||
kind,
|
||||
rawValue,
|
||||
searchText: typeof name === 'string' ? name : rawValue,
|
||||
valuePath: subBlockConfig.multiple || values.length > 1 ? [index, 'name'] : [],
|
||||
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' | 'multiSelect' | 'multiple'
|
||||
>,
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
dedupeOverlappingWorkflowSearchMatches,
|
||||
workflowSearchMatchMatchesQuery,
|
||||
} from '@/lib/workflows/search-replace/resources/resolvers'
|
||||
import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
|
||||
|
||||
function createMatch(overrides: Partial<WorkflowSearchMatch>): WorkflowSearchMatch {
|
||||
return {
|
||||
id: 'match',
|
||||
blockId: 'block-1',
|
||||
blockName: 'Block',
|
||||
blockType: 'function',
|
||||
subBlockId: 'code',
|
||||
canonicalSubBlockId: 'code',
|
||||
subBlockType: 'code',
|
||||
valuePath: [],
|
||||
target: { kind: 'subblock' },
|
||||
kind: 'text',
|
||||
rawValue: '',
|
||||
searchText: '',
|
||||
editable: true,
|
||||
navigable: true,
|
||||
protected: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('dedupeOverlappingWorkflowSearchMatches', () => {
|
||||
it('keeps the narrower text hit when a partial literal query overlaps an inline reference', () => {
|
||||
const textMatch = createMatch({
|
||||
id: 'text-partial',
|
||||
kind: 'text',
|
||||
rawValue: '<start.h',
|
||||
searchText: "return '<start.hello>'",
|
||||
range: { start: 8, end: 16 },
|
||||
})
|
||||
const referenceMatch = createMatch({
|
||||
id: 'workflow-reference',
|
||||
kind: 'workflow-reference',
|
||||
rawValue: '<start.hello>',
|
||||
searchText: 'start.hello',
|
||||
range: { start: 8, end: 21 },
|
||||
resource: { kind: 'workflow-reference', token: '<start.hello>', key: 'start.hello' },
|
||||
})
|
||||
|
||||
expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([textMatch])
|
||||
})
|
||||
|
||||
it('keeps the inline reference when it covers the same span as a text hit', () => {
|
||||
const textMatch = createMatch({
|
||||
id: 'text-full',
|
||||
kind: 'text',
|
||||
rawValue: '<start.hello>',
|
||||
searchText: "return '<start.hello>'",
|
||||
range: { start: 8, end: 21 },
|
||||
})
|
||||
const referenceMatch = createMatch({
|
||||
id: 'workflow-reference',
|
||||
kind: 'workflow-reference',
|
||||
rawValue: '<start.hello>',
|
||||
searchText: 'start.hello',
|
||||
range: { start: 8, end: 21 },
|
||||
resource: { kind: 'workflow-reference', token: '<start.hello>', key: 'start.hello' },
|
||||
})
|
||||
|
||||
expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([
|
||||
referenceMatch,
|
||||
])
|
||||
})
|
||||
|
||||
it('uses kind priority rather than iteration order for equal-span non-text matches', () => {
|
||||
const workflowReferenceMatch = createMatch({
|
||||
id: 'workflow-reference',
|
||||
kind: 'workflow-reference',
|
||||
rawValue: '{{API_KEY}}',
|
||||
searchText: 'API_KEY',
|
||||
range: { start: 0, end: 11 },
|
||||
resource: { kind: 'workflow-reference', token: '{{API_KEY}}', key: 'API_KEY' },
|
||||
})
|
||||
const environmentMatch = createMatch({
|
||||
id: 'environment',
|
||||
kind: 'environment',
|
||||
rawValue: '{{API_KEY}}',
|
||||
searchText: 'API_KEY',
|
||||
range: { start: 0, end: 11 },
|
||||
resource: { kind: 'environment', token: '{{API_KEY}}', key: 'API_KEY' },
|
||||
})
|
||||
|
||||
expect(
|
||||
dedupeOverlappingWorkflowSearchMatches([workflowReferenceMatch, environmentMatch])
|
||||
).toEqual([workflowReferenceMatch])
|
||||
expect(
|
||||
dedupeOverlappingWorkflowSearchMatches([environmentMatch, workflowReferenceMatch])
|
||||
).toEqual([workflowReferenceMatch])
|
||||
})
|
||||
|
||||
it('does not collapse matches from different fields', () => {
|
||||
const firstMatch = createMatch({
|
||||
id: 'first',
|
||||
range: { start: 0, end: 4 },
|
||||
valuePath: ['first'],
|
||||
})
|
||||
const secondMatch = createMatch({
|
||||
id: 'second',
|
||||
range: { start: 0, end: 4 },
|
||||
valuePath: ['second'],
|
||||
})
|
||||
|
||||
expect(dedupeOverlappingWorkflowSearchMatches([firstMatch, secondMatch])).toEqual([
|
||||
firstMatch,
|
||||
secondMatch,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflowSearchMatchMatchesQuery', () => {
|
||||
it('does not keep structured resource matches alive from only block or field label text', () => {
|
||||
const selectorMatch = createMatch({
|
||||
id: 'selector-resource',
|
||||
blockName: 'Testy',
|
||||
fieldTitle: 'Select Presentation',
|
||||
subBlockId: 'presentationId',
|
||||
subBlockType: 'file-selector',
|
||||
kind: 'file',
|
||||
rawValue: 'opaque-presentation-id',
|
||||
searchText: 'opaque-presentation-id',
|
||||
range: undefined,
|
||||
resource: { kind: 'file', key: 'opaque-presentation-id' },
|
||||
})
|
||||
|
||||
expect(
|
||||
workflowSearchMatchMatchesQuery({ ...selectorMatch, displayLabel: 'Gucci Case' }, 'Test')
|
||||
).toBe(false)
|
||||
expect(
|
||||
workflowSearchMatchMatchesQuery({ ...selectorMatch, displayLabel: 'Gucci Case' }, 'Gucci')
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
WorkflowSearchMatch,
|
||||
WorkflowSearchMatchKind,
|
||||
WorkflowSearchReplacementOption,
|
||||
WorkflowSearchResourceMeta,
|
||||
WorkflowSearchValuePath,
|
||||
} from '@/lib/workflows/search-replace/types'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
|
||||
const OVERLAPPING_MATCH_KIND_PRIORITY: Record<WorkflowSearchMatchKind, number> = {
|
||||
text: 0,
|
||||
environment: 1,
|
||||
'workflow-reference': 2,
|
||||
'oauth-credential': 3,
|
||||
'knowledge-base': 3,
|
||||
'knowledge-document': 3,
|
||||
workflow: 3,
|
||||
'mcp-server': 3,
|
||||
'mcp-tool': 3,
|
||||
table: 3,
|
||||
file: 3,
|
||||
'selector-resource': 3,
|
||||
}
|
||||
|
||||
export function stableStringifyWorkflowSearchValue(value: unknown): string {
|
||||
if (!value || typeof value !== 'object') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringifyWorkflowSearchValue(item)).join(',')}]`
|
||||
}
|
||||
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringifyWorkflowSearchValue(item)}`)
|
||||
.join(',')}}`
|
||||
}
|
||||
|
||||
export function buildWorkflowSearchResourceGroupKey(
|
||||
resource: Pick<
|
||||
WorkflowSearchResourceMeta,
|
||||
'kind' | 'providerId' | 'serviceId' | 'selectorKey' | 'selectorContext'
|
||||
>
|
||||
): string {
|
||||
const provider = resource.providerId ?? resource.serviceId ?? ''
|
||||
const selectorKey = resource.selectorKey ?? ''
|
||||
const selectorContext = resource.selectorContext
|
||||
? stableStringifyWorkflowSearchValue(resource.selectorContext)
|
||||
: ''
|
||||
|
||||
return [resource.kind, provider, selectorKey, selectorContext].join(':')
|
||||
}
|
||||
|
||||
export function getWorkflowSearchMatchResourceGroupKey(match: WorkflowSearchMatch): string {
|
||||
return (
|
||||
match.resource?.resourceGroupKey ??
|
||||
buildWorkflowSearchResourceGroupKey({
|
||||
kind: match.kind as WorkflowSearchResourceMeta['kind'],
|
||||
providerId: match.resource?.providerId,
|
||||
serviceId: match.resource?.serviceId,
|
||||
selectorKey: match.resource?.selectorKey,
|
||||
selectorContext: match.resource?.selectorContext,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function selectorContextMatches(
|
||||
left: SelectorContext | undefined,
|
||||
right: SelectorContext | undefined
|
||||
): boolean {
|
||||
return (
|
||||
stableStringifyWorkflowSearchValue(left ?? {}) ===
|
||||
stableStringifyWorkflowSearchValue(right ?? {})
|
||||
)
|
||||
}
|
||||
|
||||
export function replacementOptionMatchesResourceMatch(
|
||||
option: WorkflowSearchReplacementOption,
|
||||
match: WorkflowSearchMatch
|
||||
): boolean {
|
||||
if (option.kind !== match.kind) return false
|
||||
|
||||
const optionGroupKey =
|
||||
option.resourceGroupKey ??
|
||||
buildWorkflowSearchResourceGroupKey({
|
||||
kind: option.kind as WorkflowSearchResourceMeta['kind'],
|
||||
providerId: option.providerId,
|
||||
serviceId: option.serviceId,
|
||||
selectorKey: option.selectorKey,
|
||||
selectorContext: option.selectorContext,
|
||||
})
|
||||
|
||||
return optionGroupKey === getWorkflowSearchMatchResourceGroupKey(match)
|
||||
}
|
||||
|
||||
export function getWorkflowSearchCompatibleResourceMatches(
|
||||
activeMatch: WorkflowSearchMatch | null,
|
||||
matches: WorkflowSearchMatch[]
|
||||
): WorkflowSearchMatch[] {
|
||||
if (!activeMatch?.resource) return []
|
||||
const activeGroupKey = getWorkflowSearchMatchResourceGroupKey(activeMatch)
|
||||
return matches.filter(
|
||||
(match) =>
|
||||
match.editable &&
|
||||
Boolean(match.resource) &&
|
||||
getWorkflowSearchMatchResourceGroupKey(match) === activeGroupKey
|
||||
)
|
||||
}
|
||||
|
||||
function searchValuePathKey(path: WorkflowSearchValuePath): string {
|
||||
return path.map((segment) => `${typeof segment}:${String(segment)}`).join('/')
|
||||
}
|
||||
|
||||
function getRangeMatchScopeKey(match: WorkflowSearchMatch): string | null {
|
||||
if (!match.range) return null
|
||||
if (match.target.kind !== 'subblock') return null
|
||||
return [match.blockId, match.subBlockId, searchValuePathKey(match.valuePath)].join(':')
|
||||
}
|
||||
|
||||
function rangesOverlap(
|
||||
left: NonNullable<WorkflowSearchMatch['range']>,
|
||||
right: NonNullable<WorkflowSearchMatch['range']>
|
||||
): boolean {
|
||||
return left.start < right.end && right.start < left.end
|
||||
}
|
||||
|
||||
function getRangeLength(match: WorkflowSearchMatch): number {
|
||||
return match.range ? match.range.end - match.range.start : Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
function shouldPreferOverlappingMatch(
|
||||
candidate: WorkflowSearchMatch,
|
||||
current: WorkflowSearchMatch
|
||||
): boolean {
|
||||
const candidateLength = getRangeLength(candidate)
|
||||
const currentLength = getRangeLength(current)
|
||||
if (candidateLength !== currentLength) return candidateLength < currentLength
|
||||
|
||||
const candidatePriority = OVERLAPPING_MATCH_KIND_PRIORITY[candidate.kind]
|
||||
const currentPriority = OVERLAPPING_MATCH_KIND_PRIORITY[current.kind]
|
||||
if (candidatePriority !== currentPriority) return candidatePriority > currentPriority
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function dedupeOverlappingWorkflowSearchMatches<T extends WorkflowSearchMatch>(
|
||||
matches: T[]
|
||||
): T[] {
|
||||
const deduped: T[] = []
|
||||
|
||||
for (const match of matches) {
|
||||
const scopeKey = getRangeMatchScopeKey(match)
|
||||
const matchRange = match.range
|
||||
const existingIndex =
|
||||
scopeKey && matchRange
|
||||
? deduped.findIndex(
|
||||
(candidate) =>
|
||||
getRangeMatchScopeKey(candidate) === scopeKey &&
|
||||
candidate.range &&
|
||||
rangesOverlap(candidate.range, matchRange)
|
||||
)
|
||||
: -1
|
||||
|
||||
if (existingIndex === -1) {
|
||||
deduped.push(match)
|
||||
continue
|
||||
}
|
||||
|
||||
if (shouldPreferOverlappingMatch(match, deduped[existingIndex])) {
|
||||
deduped[existingIndex] = match
|
||||
}
|
||||
}
|
||||
|
||||
return deduped
|
||||
}
|
||||
|
||||
export function workflowSearchMatchMatchesQuery(
|
||||
match: WorkflowSearchMatch & { displayLabel?: string },
|
||||
query: string,
|
||||
caseSensitive = false
|
||||
): boolean {
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) return false
|
||||
if (match.kind === 'text') return true
|
||||
|
||||
const normalize = (value: string) => (caseSensitive ? value : value.toLowerCase())
|
||||
const searchable =
|
||||
match.resource?.kind === 'workflow-reference' || match.resource?.kind === 'environment'
|
||||
? [match.displayLabel, match.rawValue, match.searchText]
|
||||
: [match.displayLabel]
|
||||
const searchableText = searchable.filter(Boolean).join(' ')
|
||||
|
||||
return normalize(searchableText).includes(normalize(trimmedQuery))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
getWorkflowSearchResourceKindLabel,
|
||||
isConstrainedWorkflowSearchResourceKind,
|
||||
normalizeWorkflowSearchResourceReplacement,
|
||||
} from '@/lib/workflows/search-replace/resources/registry'
|
||||
import { replacementOptionMatchesResourceMatch } from '@/lib/workflows/search-replace/resources/resolvers'
|
||||
import type {
|
||||
WorkflowSearchMatch,
|
||||
WorkflowSearchReplacementOption,
|
||||
} from '@/lib/workflows/search-replace/types'
|
||||
|
||||
export function isConstrainedResourceMatch(match: WorkflowSearchMatch): boolean {
|
||||
return isConstrainedWorkflowSearchResourceKind(match.kind)
|
||||
}
|
||||
|
||||
export function getCompatibleResourceReplacementOptions(
|
||||
matches: WorkflowSearchMatch[],
|
||||
resourceOptions: WorkflowSearchReplacementOption[]
|
||||
): WorkflowSearchReplacementOption[] {
|
||||
const constrainedMatches = matches.filter(isConstrainedResourceMatch)
|
||||
if (constrainedMatches.length === 0) return []
|
||||
|
||||
const kinds = new Set(constrainedMatches.map((match) => match.kind))
|
||||
if (kinds.size !== 1) return []
|
||||
|
||||
return resourceOptions.filter((option) =>
|
||||
constrainedMatches.every((match) => replacementOptionMatchesResourceMatch(option, match))
|
||||
)
|
||||
}
|
||||
|
||||
export function getWorkflowSearchReplacementIssue({
|
||||
matches,
|
||||
replacement,
|
||||
resourceOptions = [],
|
||||
}: {
|
||||
matches: WorkflowSearchMatch[]
|
||||
replacement: string
|
||||
resourceOptions?: WorkflowSearchReplacementOption[]
|
||||
}): string | null {
|
||||
const editableMatches = matches.filter((match) => match.editable)
|
||||
const constrainedMatches = editableMatches.filter(isConstrainedResourceMatch)
|
||||
if (constrainedMatches.length === 0) return null
|
||||
|
||||
if (editableMatches.length !== constrainedMatches.length) {
|
||||
return 'Replace references separately from text matches.'
|
||||
}
|
||||
|
||||
const kinds = new Set(constrainedMatches.map((match) => match.kind))
|
||||
if (kinds.size !== 1) {
|
||||
return 'Replace one reference type at a time.'
|
||||
}
|
||||
|
||||
const [firstMatch] = constrainedMatches
|
||||
const normalizedReplacement = normalizeWorkflowSearchResourceReplacement(firstMatch, replacement)
|
||||
const compatibleOptions = getCompatibleResourceReplacementOptions(
|
||||
constrainedMatches,
|
||||
resourceOptions
|
||||
)
|
||||
const hasResolvableReplacement = compatibleOptions.some(
|
||||
(option) => option.value === normalizedReplacement
|
||||
)
|
||||
|
||||
if (hasResolvableReplacement) return null
|
||||
|
||||
const label = getWorkflowSearchResourceKindLabel(firstMatch.kind)
|
||||
return `Choose a valid ${label} replacement.`
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { WorkflowSearchWorkflow } from '@/lib/workflows/search-replace/types'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
export const SEARCH_REPLACE_BLOCK_CONFIGS: Record<string, { subBlocks: SubBlockConfig[] }> = {
|
||||
agent: {
|
||||
subBlocks: [
|
||||
{ id: 'systemPrompt', title: 'System Prompt', type: 'long-input' },
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'Credential',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'gmail',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
},
|
||||
{
|
||||
id: 'label',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
selectorKey: 'gmail.labels',
|
||||
dependsOn: ['credential'],
|
||||
},
|
||||
],
|
||||
},
|
||||
knowledge: {
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'knowledgeBaseIds',
|
||||
title: 'Knowledge Bases',
|
||||
type: 'knowledge-base-selector',
|
||||
canonicalParamId: 'knowledgeBaseId',
|
||||
},
|
||||
{
|
||||
id: 'documentId',
|
||||
title: 'Document',
|
||||
type: 'document-selector',
|
||||
serviceId: 'knowledge',
|
||||
selectorKey: 'knowledge.documents',
|
||||
dependsOn: ['knowledgeBaseIds'],
|
||||
},
|
||||
],
|
||||
},
|
||||
api: {
|
||||
subBlocks: [
|
||||
{ id: 'body', title: 'Body', type: 'code' },
|
||||
{ id: 'headers', title: 'Headers', type: 'table' },
|
||||
],
|
||||
},
|
||||
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 {
|
||||
return {
|
||||
blocks: {
|
||||
'agent-1': {
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
name: 'Agent 1',
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
outputs: {},
|
||||
subBlocks: {
|
||||
systemPrompt: {
|
||||
id: 'systemPrompt',
|
||||
type: 'long-input',
|
||||
value: 'Email {{OLD_SECRET}} and then email again. Use <start.output>.',
|
||||
},
|
||||
credential: {
|
||||
id: 'credential',
|
||||
type: 'oauth-input',
|
||||
value: 'gmail-credential-old',
|
||||
},
|
||||
label: {
|
||||
id: 'label',
|
||||
type: 'folder-selector',
|
||||
value: 'INBOX',
|
||||
},
|
||||
},
|
||||
},
|
||||
'knowledge-1': {
|
||||
id: 'knowledge-1',
|
||||
type: 'knowledge',
|
||||
name: 'Knowledge 1',
|
||||
position: { x: 200, y: 0 },
|
||||
enabled: true,
|
||||
outputs: {},
|
||||
subBlocks: {
|
||||
knowledgeBaseIds: {
|
||||
id: 'knowledgeBaseIds',
|
||||
type: 'knowledge-base-selector',
|
||||
value: 'kb-old,kb-second',
|
||||
},
|
||||
documentId: {
|
||||
id: 'documentId',
|
||||
type: 'document-selector',
|
||||
value: 'doc-old',
|
||||
},
|
||||
},
|
||||
},
|
||||
'api-1': {
|
||||
id: 'api-1',
|
||||
type: 'api',
|
||||
name: 'API 1',
|
||||
position: { x: 400, y: 0 },
|
||||
enabled: true,
|
||||
outputs: {},
|
||||
subBlocks: {
|
||||
body: {
|
||||
id: 'body',
|
||||
type: 'code',
|
||||
value: { content: 'email in nested body' },
|
||||
},
|
||||
headers: {
|
||||
id: 'headers',
|
||||
type: 'table',
|
||||
value: [
|
||||
{ id: 'row-1', cells: { Key: 'Authorization', Value: 'Bearer {{OLD_SECRET}}' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
'locked-1': {
|
||||
id: 'locked-1',
|
||||
type: 'agent',
|
||||
name: 'Locked Agent',
|
||||
position: { x: 600, y: 0 },
|
||||
enabled: true,
|
||||
locked: true,
|
||||
outputs: {},
|
||||
subBlocks: {
|
||||
systemPrompt: {
|
||||
id: 'systemPrompt',
|
||||
type: 'long-input',
|
||||
value: 'email from locked block',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getWorkflowSearchBlocks } from '@/lib/workflows/search-replace/state'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
describe('getWorkflowSearchBlocks', () => {
|
||||
const blocks = {
|
||||
block1: {
|
||||
id: 'block1',
|
||||
type: 'function',
|
||||
name: 'Function 1',
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
outputs: {},
|
||||
subBlocks: {
|
||||
code: { id: 'code', type: 'code', value: '' },
|
||||
},
|
||||
},
|
||||
} as Record<string, BlockState>
|
||||
|
||||
it('uses merged live subblock values for normal workflow search', () => {
|
||||
const result = getWorkflowSearchBlocks({
|
||||
blocks,
|
||||
isSnapshotView: false,
|
||||
subblockValues: {
|
||||
block1: { code: 'Hello' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.block1.subBlocks.code.value).toBe('Hello')
|
||||
})
|
||||
|
||||
it('does not merge snapshot blocks', () => {
|
||||
const result = getWorkflowSearchBlocks({
|
||||
blocks,
|
||||
isSnapshotView: true,
|
||||
subblockValues: {
|
||||
block1: { code: 'Hello' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(blocks)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
interface GetWorkflowSearchBlocksOptions {
|
||||
blocks: Record<string, BlockState>
|
||||
isSnapshotView?: boolean
|
||||
subblockValues?: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
export function getWorkflowSearchBlocks({
|
||||
blocks,
|
||||
isSnapshotView,
|
||||
subblockValues,
|
||||
}: GetWorkflowSearchBlocksOptions): Record<string, BlockState> {
|
||||
if (isSnapshotView || !subblockValues) return blocks
|
||||
return mergeSubblockStateWithValues(blocks, subblockValues)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { SubBlockType } from '@sim/workflow-types/blocks'
|
||||
|
||||
export const WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS = {
|
||||
type: 'subflowType',
|
||||
iterations: 'subflowIterations',
|
||||
items: 'subflowItems',
|
||||
condition: 'subflowCondition',
|
||||
batchSize: 'subflowBatchSize',
|
||||
} as const
|
||||
|
||||
export type WorkflowSearchSubflowFieldId =
|
||||
(typeof WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS)[keyof typeof WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS]
|
||||
|
||||
export type WorkflowSearchSubflowEditableValue = string | number
|
||||
|
||||
interface WorkflowSearchSubflowBlock {
|
||||
type: string
|
||||
data?: {
|
||||
loopType?: string
|
||||
parallelType?: string
|
||||
count?: unknown
|
||||
batchSize?: unknown
|
||||
collection?: unknown
|
||||
whileCondition?: unknown
|
||||
doWhileCondition?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkflowSearchSubflowField {
|
||||
id: WorkflowSearchSubflowFieldId
|
||||
title: string
|
||||
type: SubBlockType
|
||||
value: string
|
||||
editable: boolean
|
||||
valueKind: 'number' | 'text' | 'enum'
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export function getWorkflowSearchSubflowFields(
|
||||
block: WorkflowSearchSubflowBlock
|
||||
): WorkflowSearchSubflowField[] {
|
||||
if (block.type === 'loop') {
|
||||
const loopType = block.data?.loopType ?? 'for'
|
||||
const fields: WorkflowSearchSubflowField[] = [
|
||||
{
|
||||
id: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.type,
|
||||
title: 'Loop Type',
|
||||
type: 'combobox',
|
||||
value: String(loopType),
|
||||
editable: false,
|
||||
valueKind: 'enum',
|
||||
reason: 'Subflow type is changed from the block sidebar',
|
||||
},
|
||||
]
|
||||
|
||||
if (loopType === 'for') {
|
||||
fields.push({
|
||||
id: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations,
|
||||
title: 'Loop Iterations',
|
||||
type: 'short-input',
|
||||
value: String(block.data?.count ?? 5),
|
||||
editable: true,
|
||||
valueKind: 'number',
|
||||
})
|
||||
} else if (loopType === 'forEach') {
|
||||
fields.push({
|
||||
id: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items,
|
||||
title: 'Collection Items',
|
||||
type: 'code',
|
||||
value: String(block.data?.collection ?? ''),
|
||||
editable: true,
|
||||
valueKind: 'text',
|
||||
})
|
||||
} else {
|
||||
fields.push({
|
||||
id: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.condition,
|
||||
title: 'While Condition',
|
||||
type: 'code',
|
||||
value: String(
|
||||
loopType === 'doWhile'
|
||||
? (block.data?.doWhileCondition ?? '')
|
||||
: (block.data?.whileCondition ?? '')
|
||||
),
|
||||
editable: true,
|
||||
valueKind: 'text',
|
||||
})
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
if (block.type === 'parallel') {
|
||||
const parallelType = block.data?.parallelType ?? 'count'
|
||||
return [
|
||||
{
|
||||
id: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.type,
|
||||
title: 'Parallel Type',
|
||||
type: 'combobox',
|
||||
value: String(parallelType),
|
||||
editable: false,
|
||||
valueKind: 'enum',
|
||||
reason: 'Subflow type is changed from the block sidebar',
|
||||
},
|
||||
{
|
||||
id:
|
||||
parallelType === 'count'
|
||||
? WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations
|
||||
: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items,
|
||||
title: parallelType === 'count' ? 'Parallel Iterations' : 'Parallel Items',
|
||||
type: parallelType === 'count' ? 'short-input' : 'code',
|
||||
value:
|
||||
parallelType === 'count'
|
||||
? String(block.data?.count ?? 5)
|
||||
: String(block.data?.collection ?? ''),
|
||||
editable: true,
|
||||
valueKind: parallelType === 'count' ? 'number' : 'text',
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.batchSize,
|
||||
title: 'Parallel Batch Size',
|
||||
type: 'short-input',
|
||||
value: String(block.data?.batchSize ?? 20),
|
||||
editable: true,
|
||||
valueKind: 'number',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function getWorkflowSearchSubflowField(
|
||||
block: WorkflowSearchSubflowBlock,
|
||||
fieldId: WorkflowSearchSubflowFieldId
|
||||
) {
|
||||
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,
|
||||
replacement,
|
||||
}: {
|
||||
blockType: 'loop' | 'parallel'
|
||||
fieldId: WorkflowSearchSubflowFieldId
|
||||
replacement: string
|
||||
}):
|
||||
| { success: true; value: WorkflowSearchSubflowEditableValue }
|
||||
| { success: false; reason: string } {
|
||||
if (
|
||||
fieldId !== WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations &&
|
||||
fieldId !== WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.batchSize
|
||||
) {
|
||||
return { success: true, value: replacement }
|
||||
}
|
||||
|
||||
const trimmed = replacement.trim()
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return { success: false, reason: 'Subflow iteration count must be a whole number' }
|
||||
}
|
||||
|
||||
const count = Number.parseInt(trimmed, 10)
|
||||
const maxBatchSize = 20
|
||||
if (
|
||||
count < 1 ||
|
||||
(fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.batchSize && count > maxBatchSize)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
reason:
|
||||
fieldId === WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.batchSize
|
||||
? `Parallel batch size must be between 1 and ${maxBatchSize}`
|
||||
: 'Subflow iteration count must be greater than 0',
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, value: count }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { SubBlockType } from '@sim/workflow-types/blocks'
|
||||
import type {
|
||||
WorkflowSearchSubflowEditableValue,
|
||||
WorkflowSearchSubflowFieldId,
|
||||
} from '@/lib/workflows/search-replace/subflow-fields'
|
||||
import type { StoredCustomToolRecord } from '@/lib/workflows/subblocks/display'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export type WorkflowSearchMode = 'text' | 'resource' | 'all'
|
||||
|
||||
export type WorkflowSearchMatchKind =
|
||||
| 'text'
|
||||
| 'environment'
|
||||
| 'workflow-reference'
|
||||
| 'oauth-credential'
|
||||
| 'knowledge-base'
|
||||
| 'knowledge-document'
|
||||
| 'workflow'
|
||||
| 'mcp-server'
|
||||
| 'mcp-tool'
|
||||
| 'table'
|
||||
| 'file'
|
||||
| 'selector-resource'
|
||||
|
||||
export type WorkflowSearchValuePath = Array<string | number>
|
||||
|
||||
export interface WorkflowSearchRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface WorkflowSearchResourceMeta {
|
||||
kind: Exclude<WorkflowSearchMatchKind, 'text'>
|
||||
providerId?: string
|
||||
serviceId?: string
|
||||
selectorKey?: string
|
||||
selectorContext?: SelectorContext
|
||||
resourceGroupKey?: string
|
||||
requiredScopes?: string[]
|
||||
token?: string
|
||||
key?: string
|
||||
}
|
||||
|
||||
export type WorkflowSearchTarget =
|
||||
| { kind: 'subblock' }
|
||||
| { kind: 'subflow'; fieldId: WorkflowSearchSubflowFieldId }
|
||||
| { kind: 'block-name' }
|
||||
|
||||
export interface WorkflowSearchMatch {
|
||||
id: string
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
subBlockId: string
|
||||
canonicalSubBlockId: string
|
||||
subBlockType: SubBlockType
|
||||
fieldTitle?: string
|
||||
valuePath: WorkflowSearchValuePath
|
||||
target: WorkflowSearchTarget
|
||||
kind: WorkflowSearchMatchKind
|
||||
rawValue: string
|
||||
searchText: string
|
||||
range?: WorkflowSearchRange
|
||||
structuredOccurrenceIndex?: number
|
||||
dependentValuePaths?: WorkflowSearchValuePath[]
|
||||
resource?: WorkflowSearchResourceMeta
|
||||
editable: boolean
|
||||
navigable: boolean
|
||||
protected: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface WorkflowSearchSubBlockState extends Omit<SubBlockState, 'value'> {
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export interface WorkflowSearchBlockState extends Omit<BlockState, 'subBlocks'> {
|
||||
subBlocks: Record<string, WorkflowSearchSubBlockState>
|
||||
}
|
||||
|
||||
export interface WorkflowSearchWorkflow {
|
||||
blocks: Record<string, WorkflowSearchBlockState>
|
||||
}
|
||||
|
||||
export interface WorkflowSearchIndexerOptions {
|
||||
workflow: WorkflowSearchWorkflow
|
||||
query?: string
|
||||
mode?: WorkflowSearchMode
|
||||
caseSensitive?: boolean
|
||||
includeResourceMatchesWithoutQuery?: boolean
|
||||
isSnapshotView?: boolean
|
||||
isReadOnly?: boolean
|
||||
readonlyReason?: string
|
||||
workspaceId?: string
|
||||
workflowId?: string
|
||||
blockConfigs?: Record<
|
||||
string,
|
||||
| {
|
||||
name?: string
|
||||
subBlocks?: SubBlockConfig[]
|
||||
triggers?: { enabled?: boolean }
|
||||
category?: string
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
credentialTypeById?: Record<string, string | undefined>
|
||||
/** Custom-tool records so indexed tool names match the rendered chips. */
|
||||
customTools?: StoredCustomToolRecord[]
|
||||
/** Live MCP tool names keyed by composite tool id, same as the chip renderers. */
|
||||
mcpToolNamesById?: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
export interface WorkflowSearchReplacementOption {
|
||||
kind: WorkflowSearchMatchKind
|
||||
value: string
|
||||
label: string
|
||||
providerId?: string
|
||||
serviceId?: string
|
||||
selectorKey?: string
|
||||
selectorContext?: SelectorContext
|
||||
resourceGroupKey?: string
|
||||
}
|
||||
|
||||
export interface WorkflowSearchReplaceUpdate {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
previousValue: unknown
|
||||
nextValue: unknown
|
||||
matchIds: string[]
|
||||
}
|
||||
|
||||
export interface WorkflowSearchReplaceSubflowUpdate {
|
||||
blockId: string
|
||||
blockType: 'loop' | 'parallel'
|
||||
fieldId: WorkflowSearchSubflowFieldId
|
||||
previousValue: string
|
||||
nextValue: WorkflowSearchSubflowEditableValue
|
||||
matchIds: string[]
|
||||
}
|
||||
|
||||
interface WorkflowSearchReplaceSkipped {
|
||||
matchId: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface WorkflowSearchReplacePlan {
|
||||
updates: WorkflowSearchReplaceUpdate[]
|
||||
subflowUpdates: WorkflowSearchReplaceSubflowUpdate[]
|
||||
skipped: WorkflowSearchReplaceSkipped[]
|
||||
conflicts: WorkflowSearchReplaceSkipped[]
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { WorkflowSearchValuePath } from '@/lib/workflows/search-replace/types'
|
||||
|
||||
export interface WalkedStringValue {
|
||||
path: WorkflowSearchValuePath
|
||||
value: string
|
||||
originalValue: unknown
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function walkStringValues(
|
||||
value: unknown,
|
||||
path: WorkflowSearchValuePath = []
|
||||
): WalkedStringValue[] {
|
||||
if (typeof value === 'string') {
|
||||
return value.length > 0 ? [{ path, value, originalValue: value }] : []
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return [{ path, value: String(value), originalValue: value }]
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item, index) => walkStringValues(item, [...path, index]))
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
return Object.entries(value).flatMap(([key, item]) => walkStringValues(item, [...path, key]))
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function getValueAtPath(value: unknown, path: WorkflowSearchValuePath): unknown {
|
||||
return path.reduce<unknown>((current, segment) => {
|
||||
if (Array.isArray(current) && typeof segment === 'number') {
|
||||
return current[segment]
|
||||
}
|
||||
if (isRecord(current) && typeof segment === 'string') {
|
||||
return current[segment]
|
||||
}
|
||||
return undefined
|
||||
}, value)
|
||||
}
|
||||
|
||||
export function setValueAtPath(
|
||||
value: unknown,
|
||||
path: WorkflowSearchValuePath,
|
||||
nextValue: unknown
|
||||
): unknown {
|
||||
if (path.length === 0) return nextValue
|
||||
|
||||
const [segment, ...remaining] = path
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const copy = [...value]
|
||||
if (typeof segment !== 'number') return value
|
||||
copy[segment] = setValueAtPath(copy[segment], remaining, nextValue)
|
||||
return copy
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
if (typeof segment !== 'string') return value
|
||||
return {
|
||||
...value,
|
||||
[segment]: setValueAtPath(value[segment], remaining, nextValue),
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export function pathToKey(path: WorkflowSearchValuePath): string {
|
||||
return path.map((segment) => String(segment).replaceAll('.', '\\.')).join('.')
|
||||
}
|
||||
Reference in New Issue
Block a user