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
19 changes: 19 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store'
import { collectForkCustomBlockReconfigs } from '@/ee/workspace-forking/lib/mapping/custom-block-reconfigs'
import {
collectForkDependentReconfigs,
collectForkResourceUsages,
Expand Down Expand Up @@ -127,7 +128,25 @@ export const GET = withRouteHandler(
// that's exactly what the first sync copies verbatim, so the pre-fill is honest and
// configuring it ahead of the first sync is possible (the deterministic target ids
// already exist).
// Custom-block inputs join the same list: repointing a block makes every one of its
// inputs reconfigurable (see `collectForkCustomBlockReconfigs`), and they store, pre-fill,
// gate Sync, and apply through this identical channel.
const customBlockReconfigs = await collectForkCustomBlockReconfigs({
items: plan.items,
sourceStates,
resolveTargetBlockId: resolveBlockId,
resolve: plan.resolver,
targetWorkspaceId: plan.targetWorkspaceId,
})

const dependentReconfigs = [
...customBlockReconfigs.map((field) => ({
...field,
currentValue:
storedByKey.get(
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
) ?? field.currentValue,
Comment thread
icecrasher321 marked this conversation as resolved.
})),
...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId).map((field) => ({
...field,
currentValue:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
CUSTOM_BLOCK_BOOLEAN_FALSE,
CUSTOM_BLOCK_BOOLEAN_TRUE,
CUSTOM_BLOCK_BOOLEAN_UNSET,
customBlockBooleanOptions,
customBlockInputControl,
} from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control'

describe('customBlockInputControl', () => {
it('matches how the canvas renders each field type', () => {
// Mirrors `subBlockTypeForField`: a field configured here must behave the way it will
// once the block is open in the editor.
expect(customBlockInputControl('boolean')).toBe('switch')
expect(customBlockInputControl('object')).toBe('textarea')
expect(customBlockInputControl('array')).toBe('textarea')
expect(customBlockInputControl('string')).toBe('input')
expect(customBlockInputControl('number')).toBe('input')
})

it('falls back to a plain input for an unknown or absent type', () => {
expect(customBlockInputControl('something-new')).toBe('input')
expect(customBlockInputControl(undefined)).toBe('input')
})
})

describe('customBlockBooleanOptions', () => {
it('lets an OPTIONAL flag return to the workflow default', () => {
// Without this a single click permanently pins the flag: a two-segment switch has no
// transition back to "nothing selected", so every later sync would keep overriding the
// child's declared default.
const options = customBlockBooleanOptions(false)

expect(options.map((o) => o.value)).toEqual([
CUSTOM_BLOCK_BOOLEAN_TRUE,
CUSTOM_BLOCK_BOOLEAN_FALSE,
CUSTOM_BLOCK_BOOLEAN_UNSET,
])
})

it('offers a REQUIRED flag only real values', () => {
// The Sync gate demands a value, so "unset" is not a state it can end in — offering it
// would present a choice that cannot be submitted.
const options = customBlockBooleanOptions(true)

expect(options.map((o) => o.value)).toEqual([
CUSTOM_BLOCK_BOOLEAN_TRUE,
CUSTOM_BLOCK_BOOLEAN_FALSE,
])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Which control the sync modal renders for a repointed custom block's input, derived from the
* field type its Start block declares.
*
* Mirrors `subBlockTypeForField` in `@/blocks/custom/build-config`, which decides the same thing
* for the canvas — a field the user configures here must read and behave the way it will once
* the block is open in the editor.
*/
export type CustomBlockInputControl = 'switch' | 'textarea' | 'input'

export function customBlockInputControl(fieldType: string | undefined): CustomBlockInputControl {
switch (fieldType) {
// Stored as a real boolean on the canvas (its sub-block is a `switch`), so it must be
// toggled here rather than typed — a text field would persist the string `'true'`.
case 'boolean':
return 'switch'
// Authored as JSON and parsed by the executor before the child receives it.
case 'object':
case 'array':
return 'textarea'
default:
return 'input'
}
}

/**
* The two string values a boolean input round-trips through the string-valued dependent store.
* `replaceCustomBlockInputs` turns them back into a real boolean on apply, because the canvas
* stores a `switch` sub-block as one.
*/
export const CUSTOM_BLOCK_BOOLEAN_TRUE = 'true'
export const CUSTOM_BLOCK_BOOLEAN_FALSE = 'false'

/**
* The unset value. Distinct from `false`: it means the sync writes no value at all, so the
* target workflow's Start field keeps whatever default it declares.
*/
export const CUSTOM_BLOCK_BOOLEAN_UNSET = ''

const BOOLEAN_VALUE_OPTIONS = [
{ value: CUSTOM_BLOCK_BOOLEAN_TRUE, label: 'True' },
{ value: CUSTOM_BLOCK_BOOLEAN_FALSE, label: 'False' },
] as const

const BOOLEAN_OPTIONAL_OPTIONS = [
...BOOLEAN_VALUE_OPTIONS,
// Trails the two real values: choosing one is the common action, returning to the default
// is the escape hatch. Without it a single click would permanently pin an optional flag,
// since a two-segment switch has no transition back to "nothing selected".
{ value: CUSTOM_BLOCK_BOOLEAN_UNSET, label: 'Default' },
] as const

/**
* Segments for a boolean input. An OPTIONAL field gets a third `Default` segment so the user
* can stop overriding the child workflow's declared default; a REQUIRED one does not, because
* the Sync gate demands a value and "unset" is not a state it can end in.
*/
export function customBlockBooleanOptions(required: boolean) {
return required ? BOOLEAN_VALUE_OPTIONS : BOOLEAN_OPTIONAL_OPTIONS
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,27 @@ describe('effectiveDependentValue', () => {
expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, false)).toBe('INBOX')
})

it('keeps a custom-block input\'s stored value even though its parent always "changed"', () => {
// These fields exist BECAUSE the block's type was repointed, so `parentChanged` is always
// true — but the stored value is the user's configuration for that exact target (the
// storage key namespaces it by target type), not a stale pick against an old parent.
// Blanking it here desyncs the rendered value from the Sync gate and the submitted
// payload: required fields look filled but keep Sync disabled, and optional ones submit
// empty and wipe the stored mapping.
const customBlockField = field({
parentKind: 'custom-block',
parentSourceId: 'custom_block_uat0001',
currentValue: 'configured for the target',
})

expect(effectiveDependentValue(customBlockField, {}, true)).toBe('configured for the target')
})

it('still blanks a custom-block input the user explicitly cleared', () => {
const f = field({ parentKind: 'custom-block', currentValue: 'stored' })
expect(effectiveDependentValue(f, { [dependentKey(f)]: null }, true)).toBe('')
})

it('returns blank when the parent changed (the stored value no longer resolves)', () => {
expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, true)).toBe('')
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,13 @@ export function effectiveDependentValue(
const repicked = reconfig[dependentKey(field)]
if (repicked === null) return ''
if (repicked !== undefined) return repicked
return parentChanged ? '' : field.currentValue
// A custom block's inputs exist BECAUSE its type was repointed, so `parentChanged` is always
// true for them — but their stored value IS the user's configuration for that exact target
// (the storage key namespaces it by target type), not a stale pick against an old parent, so
// it must survive. The rule lives here rather than at the render site so the displayed value,
// the Sync gate, and the submitted payload can never disagree about what a field holds.
if (parentChanged && field.parentKind !== 'custom-block') return ''
return field.currentValue
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ChevronDown,
Chip,
ChipCombobox,
ChipModalField,
ChipSwitch,
CollapsibleCard,
cn,
Expand All @@ -32,6 +33,10 @@ import {
forkBlockerResolution,
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
import {
customBlockBooleanOptions,
customBlockInputControl,
} from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control'
import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector'
import {
applyDependentRepick,
Expand Down Expand Up @@ -205,12 +210,47 @@ function DependentSelector({
reconfig,
setReconfig,
}: DependentSelectorProps) {
// `effectiveDependentValue` owns the custom-block carve-out, so the value shown here is the
// same one the Sync gate and the submitted payload see.
const isCustomBlockInput = field.parentKind === 'custom-block'
const effectiveValueIn = (f: ForkDependentReconfig, state: DependentReconfigState) =>
copying
copying && !isCustomBlockInput
? effectiveCopyDependentValue(f, state)
: effectiveDependentValue(f, state, parentChanged)
const baselineValueFor = (f: ForkDependentReconfig) => effectiveValueIn(f, {})
const effectiveValue = (f: ForkDependentReconfig) => effectiveValueIn(f, reconfig)
if (isCustomBlockInput) {
// Not a selector: there is no parent resource to browse and no options to fetch, just the
// target block's own declared input. Rendered as a plain field so the user types the value
// the repointed block should run with. Structured types get a textarea because their value
// is JSON, matching how `subBlockTypeForField` renders them on the canvas.
const setValue = (value: string) =>
setReconfig((current) => ({ ...current, [dependentKey(field)]: value }))
const value = effectiveValue(field)
const shared = { title: field.title, required: field.required }
switch (customBlockInputControl(field.fieldType)) {
case 'switch':
return (
<ChipModalField {...shared} type='custom'>
<ChipSwitch
options={customBlockBooleanOptions(field.required)}
// Passed through unmapped: an unset field is `''`, which matches neither
// segment, so the switch renders with nothing selected. Coercing it to False
// would show a required flag as configured while the Sync gate still reads it
// as empty — the display-versus-gate split this whole carve-out exists to avoid.
value={value}
onChange={setValue}
aria-label={field.title}
/>
</ChipModalField>
)
case 'textarea':
Comment thread
icecrasher321 marked this conversation as resolved.
return <ChipModalField {...shared} type='textarea' value={value} onChange={setValue} />
default:
return <ChipModalField {...shared} type='input' value={value} onChange={setValue} />
}
}

const { providedValues, providedContextKeys } = blockChainState(block, field, effectiveValue)
// Disabled until every in-block parent it depends on has a value, so a child never queries
// a stale upstream value.
Expand All @@ -228,7 +268,7 @@ function DependentSelector({
...providedValues,
// Owning workspace, for workspace-scoped selectors like table.columns.
workspaceId: copying ? sourceWorkspaceId : workspaceId,
[field.parentContextKey]: parentValue,
...(field.parentContextKey ? { [field.parentContextKey]: parentValue } : {}),
}}
enabled={parentValue !== '' && ready}
value={effectiveValue(field)}
Expand Down
Loading
Loading