Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Badge } from '@/components/emcn'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
Expand Down Expand Up @@ -43,7 +45,7 @@ interface DropdownProps {
subBlockId: string
) => Promise<Array<{ label: string; id: string }>>
/** Field dependencies that trigger option refetch when changed */
dependsOn?: string[]
dependsOn?: SubBlockConfig['dependsOn']
}

/**
Expand All @@ -67,23 +69,25 @@ export function Dropdown({
placeholder = 'Select an option...',
multiSelect = false,
fetchOptions,
dependsOn = [],
dependsOn,
}: DropdownProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string | string[]>(blockId, subBlockId) as [
string | string[] | null | undefined,
(value: string | string[]) => void,
]

const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])

const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const dependencyValues = useSubBlockStore(
useCallback(
(state) => {
if (dependsOn.length === 0 || !activeWorkflowId) return []
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = workflowValues[blockId] || {}
return dependsOn.map((depKey) => blockValues[depKey] ?? null)
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
},
[dependsOn, activeWorkflowId, blockId]
[dependsOnFields, activeWorkflowId, blockId]
)
)

Expand Down Expand Up @@ -301,7 +305,7 @@ export function Dropdown({
* This ensures options are refetched with new dependency values (e.g., new credentials)
*/
useEffect(() => {
if (fetchOptions && dependsOn.length > 0) {
if (fetchOptions && dependsOnFields.length > 0) {
const currentDependencyValuesStr = JSON.stringify(dependencyValues)
const previousDependencyValuesStr = previousDependencyValuesRef.current

Expand All @@ -314,7 +318,7 @@ export function Dropdown({

previousDependencyValuesRef.current = currentDependencyValuesStr
}
}, [dependencyValues, fetchOptions, dependsOn.length])
}, [dependencyValues, fetchOptions, dependsOnFields.length])

/**
* Effect to fetch options when needed (on mount, when enabled, or when dependencies change)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { isDependency } from '@/blocks/utils'
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
Expand Down Expand Up @@ -92,7 +93,7 @@ export function FileSelectorInput({
!selectorResolution.context.domain
const missingProject =
selectorResolution?.key === 'jira.issues' &&
subBlock.dependsOn?.includes('projectId') &&
isDependency(subBlock.dependsOn, 'projectId') &&
!selectorResolution.context.projectId
const missingPlan =
selectorResolution?.key === 'microsoft.planner' && !selectorResolution.context.planId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,40 @@ import type { SubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/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
* - Returns a stable disabled flag to pass to inputs and to guard effects
* - Supports both AND (all) and OR (any) dependency logic
*/
export function useDependsOnGate(
blockId: string,
Expand All @@ -21,8 +51,14 @@ export function useDependsOnGate(

const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)

// Use only explicit dependsOn from block config. No inference.
const dependsOn: string[] = (subBlock.dependsOn as string[] | undefined) || []
// Parse dependsOn config to get all/any field lists
const { allFields, anyFields, allDependsOnFields } = useMemo(
() => parseDependsOn(subBlock.dependsOn),
[subBlock.dependsOn]
)

// 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
Expand All @@ -47,33 +83,64 @@ export function useDependsOnGate(
return rawValue
}

const dependencyValues = useSubBlockStore((state) => {
if (dependsOn.length === 0) return [] as any[]
// Get values for all dependency fields (both all and any)
const dependencyValuesMap = useSubBlockStore((state) => {
if (allDependsOnFields.length === 0) return {} as Record<string, unknown>

// If previewContextValues are provided (e.g., tool parameters), use those first
if (previewContextValues) {
return dependsOn.map((depKey) => normalizeDependencyValue(previewContextValues[depKey]))
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
map[key] = normalizeDependencyValue(previewContextValues[key])
}
return map
}

if (!activeWorkflowId) {
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
map[key] = null
}
return map
}

if (!activeWorkflowId) return dependsOn.map(() => null)
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = (workflowValues as any)[blockId] || {}
return dependsOn.map((depKey) => normalizeDependencyValue((blockValues as any)[depKey]))
}) as any[]
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
map[key] = normalizeDependencyValue((blockValues as any)[key])
}
return map
})

// For backward compatibility, also provide array of values
const dependencyValues = useMemo(
() => allDependsOnFields.map((key) => dependencyValuesMap[key]),
[allDependsOnFields, dependencyValuesMap]
) as any[]

const isValueSatisfied = (value: unknown): boolean => {
if (value === null || value === undefined) return false
if (typeof value === 'string') return value.trim().length > 0
if (Array.isArray(value)) return value.length > 0
return value !== ''
}

const depsSatisfied = useMemo(() => {
if (dependsOn.length === 0) return true
return dependencyValues.every((value) => {
if (value === null || value === undefined) return false
if (typeof value === 'string') return value.trim().length > 0
if (Array.isArray(value)) return value.length > 0
return value !== ''
})
}, [dependencyValues, dependsOn])
// Check all fields (AND logic) - all must be satisfied
const allSatisfied =
allFields.length === 0 || allFields.every((key) => isValueSatisfied(dependencyValuesMap[key]))

// Check any fields (OR logic) - at least one must be satisfied
const anySatisfied =
anyFields.length === 0 || anyFields.some((key) => isValueSatisfied(dependencyValuesMap[key]))

return allSatisfied && anySatisfied
}, [allFields, anyFields, dependencyValuesMap])

// Block everything except the credential field itself until dependencies are set
const blocked =
!isPreview && dependsOn.length > 0 && !depsSatisfied && subBlock.type !== 'oauth-input'
!isPreview && allDependsOnFields.length > 0 && !depsSatisfied && subBlock.type !== 'oauth-input'

const finalDisabled = disabledProp || isPreview || blocked

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { isDependency } from '@/blocks/utils'
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
Expand Down Expand Up @@ -92,7 +93,7 @@ export function FileSelectorInput({
!selectorResolution.context.domain
const missingProject =
selectorResolution?.key === 'jira.issues' &&
subBlock.dependsOn?.includes('projectId') &&
isDependency(subBlock.dependsOn, 'projectId') &&
!selectorResolution?.context.projectId
const missingPlan =
selectorResolution?.key === 'microsoft.planner' && !selectorResolution?.context.planId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp'
import { useCredentialName } from '@/hooks/queries/oauth-credentials'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
Expand Down Expand Up @@ -261,8 +262,9 @@ const SubBlockRow = ({
)

const dependencyValues = useMemo(() => {
if (!subBlock?.dependsOn?.length) return {}
return subBlock.dependsOn.reduce<Record<string, string>>((accumulator, dependency) => {
const fields = getDependsOnFields(subBlock?.dependsOn)
if (!fields.length) return {}
return fields.reduce<Record<string, string>>((accumulator, dependency) => {
const dependencyValue = getStringValue(dependency)
if (dependencyValue) {
accumulator[dependency] = dependencyValue
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/blocks/blocks/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
'reactions:write',
],
placeholder: 'Select Slack workspace',
dependsOn: ['authMethod'],
condition: {
field: 'authMethod',
value: 'oauth',
Expand All @@ -78,6 +79,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
type: 'short-input',
placeholder: 'Enter your Slack bot token (xoxb-...)',
password: true,
dependsOn: ['authMethod'],
condition: {
field: 'authMethod',
value: 'bot_token',
Expand All @@ -91,7 +93,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
serviceId: 'slack',
placeholder: 'Select Slack channel',
mode: 'basic',
dependsOn: ['credential', 'authMethod'],
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
condition: {
field: 'operation',
value: ['list_channels', 'list_users', 'get_user'],
Expand Down
12 changes: 9 additions & 3 deletions apps/sim/blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,15 @@ export interface SubBlockConfig {
placeholder?: string // Custom placeholder for the prompt input
maintainHistory?: boolean // Whether to maintain conversation history
}
// Declarative dependency hints for cross-field clearing or invalidation
// Example: dependsOn: ['credential'] means this field should be cleared when credential changes
dependsOn?: string[]
/**
* Declarative dependency hints for cross-field clearing or invalidation.
* Supports two formats:
* - Simple array: `['credential']` - all fields must have values (AND logic)
* - Object with all/any: `{ all: ['authMethod'], any: ['credential', 'botToken'] }`
* - `all`: all listed fields must have values (AND logic)
* - `any`: at least one field must have a value (OR logic)
*/
dependsOn?: string[] | { all?: string[]; any?: string[] }
// Copyable-text specific: Use webhook URL from webhook management hook
useWebhookUrl?: boolean
// Trigger-save specific: The trigger ID for validation and saving
Expand Down
22 changes: 21 additions & 1 deletion apps/sim/blocks/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,24 @@
import type { BlockOutput, OutputFieldDefinition } from '@/blocks/types'
import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types'

/**
* Checks if a field is included in the dependsOn config.
* Handles both simple array format and object format with all/any fields.
*/
export function isDependency(dependsOn: SubBlockConfig['dependsOn'], field: string): boolean {
if (!dependsOn) return false
if (Array.isArray(dependsOn)) return dependsOn.includes(field)
return dependsOn.all?.includes(field) || dependsOn.any?.includes(field) || false
}

/**
* Gets all dependency fields as a flat array.
* Handles both simple array format and object format with all/any fields.
*/
export function getDependsOnFields(dependsOn: SubBlockConfig['dependsOn']): string[] {
if (!dependsOn) return []
if (Array.isArray(dependsOn)) return dependsOn
return [...(dependsOn.all || []), ...(dependsOn.any || [])]
}

export function resolveOutputType(
outputs: Record<string, OutputFieldDefinition>
Expand Down