Skip to content

Commit 2405fc8

Browse files
icecrasher321claude
andcommitted
fix(workspace-forking): stop double-labelling a custom block's inputs, and derive their controls from the canvas
Two problems with how a repointed custom block's inputs render in the sync modal. The field title printed twice. The row wrapper already draws the label and its required marker for every dependent field — `DependentFieldSelector` takes a `title` only to phrase its placeholder and renders a bare combobox. The custom-block branch used `ChipModalField`, which owns a label of its own, so every input showed its name twice. It now renders bare controls like its sibling does. The control was chosen by re-reading the raw field type instead of asking the function that already answers this. `subBlockTypeForField` decides what a Start field becomes on the canvas; the modal had a parallel switch that had already drifted, rendering a `file[]` input — an upload on the canvas — as a plain text box, which would write a bare string into a field expecting file references. `subBlockTypeForField` is now exported and the modal derives from it, so the two cannot disagree about what a field IS; the modal only decides how that kind draws. A file input is explicitly `unsupported` rather than falling through: it renders disabled, saying it is set in the workflow, instead of inviting a value that cannot work. A test walks every type a Start field can declare and asserts the modal's choice follows the canvas's, so a type added later surfaces here rather than silently becoming a text box. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5bc2955 commit 2405fc8

4 files changed

Lines changed: 97 additions & 33 deletions

File tree

apps/sim/blocks/custom/build-config.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,13 @@ export function assembleCustomBlockInputMapping(params: Record<string, unknown>)
9696
}
9797

9898
/** Map a Start input field type to the editor sub-block type used to collect it. */
99-
function subBlockTypeForField(fieldType: string): SubBlockType {
99+
/**
100+
* The sub-block a Start input field becomes on the canvas. Exported so any surface that has to
101+
* render or reason about a custom block's inputs derives the field's KIND from here instead of
102+
* re-deriving it — the fork sync modal renders its own controls but must agree with this about
103+
* what each field is.
104+
*/
105+
export function subBlockTypeForField(fieldType: string): SubBlockType {
100106
switch (fieldType) {
101107
case 'boolean':
102108
return 'switch'

apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5+
import { subBlockTypeForField } from '@/blocks/custom/build-config'
56
import {
67
CUSTOM_BLOCK_BOOLEAN_FALSE,
78
CUSTOM_BLOCK_BOOLEAN_TRUE,
@@ -21,6 +22,28 @@ describe('customBlockInputControl', () => {
2122
expect(customBlockInputControl('number')).toBe('input')
2223
})
2324

25+
it('refuses to offer a file input rather than rendering a text box for it', () => {
26+
// `file[]` is an upload on the canvas. A text box would write a plain string into a field
27+
// that expects file references — worse than not offering it, because it looks configured.
28+
expect(customBlockInputControl('file[]')).toBe('unsupported')
29+
})
30+
31+
it('stays in step with the canvas mapping for every declared field type', () => {
32+
// The union a Start field can declare. Deriving from `subBlockTypeForField` means a type
33+
// added there surfaces here instead of silently falling through to a text box — which is
34+
// exactly how `file[]` came to be mis-rendered.
35+
const byCanvasKind = {
36+
switch: 'switch',
37+
code: 'textarea',
38+
'file-upload': 'unsupported',
39+
} as const
40+
41+
for (const fieldType of ['string', 'number', 'boolean', 'object', 'array', 'file[]']) {
42+
const canvasKind = subBlockTypeForField(fieldType) as keyof typeof byCanvasKind
43+
expect(customBlockInputControl(fieldType)).toBe(byCanvasKind[canvasKind] ?? 'input')
44+
}
45+
})
46+
2447
it('falls back to a plain input for an unknown or absent type', () => {
2548
expect(customBlockInputControl('something-new')).toBe('input')
2649
expect(customBlockInputControl(undefined)).toBe('input')

apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
1+
import { subBlockTypeForField } from '@/blocks/custom/build-config'
2+
13
/**
2-
* Which control the sync modal renders for a repointed custom block's input, derived from the
3-
* field type its Start block declares.
4+
* Which control the sync modal renders for a repointed custom block's input.
5+
*
6+
* Derived from `subBlockTypeForField` — the same function that decides what the field becomes
7+
* on the canvas — rather than re-reading the raw field type. The modal cannot reuse the canvas
8+
* sub-block renderer (that one is bound to the workflow store, by workflow and block id), so it
9+
* draws its own controls; taking the field's KIND from one place is what stops the two drifting
10+
* when a field type is added. Re-deriving it is how `file[]` came to render as a text box.
411
*
5-
* Mirrors `subBlockTypeForField` in `@/blocks/custom/build-config`, which decides the same thing
6-
* for the canvas — a field the user configures here must read and behave the way it will once
7-
* the block is open in the editor.
12+
* `unsupported` is a real outcome, not a fallback: a `file[]` input is an upload on the canvas,
13+
* and there is nothing meaningful to type for it here. A text box would write a plain string
14+
* into a field that expects file references.
815
*/
9-
export type CustomBlockInputControl = 'switch' | 'textarea' | 'input'
16+
export type CustomBlockInputControl = 'switch' | 'textarea' | 'input' | 'unsupported'
1017

1118
export function customBlockInputControl(fieldType: string | undefined): CustomBlockInputControl {
12-
switch (fieldType) {
13-
// Stored as a real boolean on the canvas (its sub-block is a `switch`), so it must be
14-
// toggled here rather than typed — a text field would persist the string `'true'`.
15-
case 'boolean':
19+
switch (subBlockTypeForField(fieldType ?? '')) {
20+
// Stored as a real boolean on the canvas, so it must be toggled rather than typed — a text
21+
// field would persist the string `'true'`.
22+
case 'switch':
1623
return 'switch'
17-
// Authored as JSON and parsed by the executor before the child receives it.
18-
case 'object':
19-
case 'array':
24+
// A JSON editor on the canvas. The modal has no editor, but the value is the same JSON
25+
// string either way and the executor parses it before the child receives it.
26+
case 'code':
2027
return 'textarea'
28+
case 'file-upload':
29+
return 'unsupported'
2130
default:
2231
return 'input'
2332
}
@@ -58,3 +67,6 @@ const BOOLEAN_OPTIONAL_OPTIONS = [
5867
export function customBlockBooleanOptions(required: boolean) {
5968
return required ? BOOLEAN_VALUE_OPTIONS : BOOLEAN_OPTIONAL_OPTIONS
6069
}
70+
71+
/** Shown in place of a control for a field the sync modal cannot configure. */
72+
export const CUSTOM_BLOCK_UNSUPPORTED_HINT = 'Set in the workflow — files cannot be configured here'

apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import {
66
ChevronDown,
77
Chip,
88
ChipCombobox,
9-
ChipModalField,
9+
ChipInput,
1010
ChipSwitch,
11+
ChipTextarea,
1112
CollapsibleCard,
1213
cn,
1314
FieldDivider,
@@ -34,6 +35,7 @@ import {
3435
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
3536
import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
3637
import {
38+
CUSTOM_BLOCK_UNSUPPORTED_HINT,
3739
customBlockBooleanOptions,
3840
customBlockInputControl,
3941
} from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control'
@@ -221,33 +223,54 @@ function DependentSelector({
221223
const effectiveValue = (f: ForkDependentReconfig) => effectiveValueIn(f, reconfig)
222224
if (isCustomBlockInput) {
223225
// Not a selector: there is no parent resource to browse and no options to fetch, just the
224-
// target block's own declared input. Rendered as a plain field so the user types the value
225-
// the repointed block should run with. Structured types get a textarea because their value
226-
// is JSON, matching how `subBlockTypeForField` renders them on the canvas.
226+
// target block's own declared input. Renders a BARE control, like `DependentFieldSelector`
227+
// does — the row wrapper above already draws the field's label and required marker, so a
228+
// labelled `ChipModalField` printed the title twice.
227229
const setValue = (value: string) =>
228230
setReconfig((current) => ({ ...current, [dependentKey(field)]: value }))
229231
const value = effectiveValue(field)
230-
const shared = { title: field.title, required: field.required }
231232
switch (customBlockInputControl(field.fieldType)) {
232233
case 'switch':
233234
return (
234-
<ChipModalField {...shared} type='custom'>
235-
<ChipSwitch
236-
options={customBlockBooleanOptions(field.required)}
237-
// Passed through unmapped: an unset field is `''`, which matches neither
238-
// segment, so the switch renders with nothing selected. Coercing it to False
239-
// would show a required flag as configured while the Sync gate still reads it
240-
// as empty — the display-versus-gate split this whole carve-out exists to avoid.
241-
value={value}
242-
onChange={setValue}
243-
aria-label={field.title}
244-
/>
245-
</ChipModalField>
235+
<ChipSwitch
236+
options={customBlockBooleanOptions(field.required)}
237+
// Passed through unmapped: an unset field is `''`, which matches neither segment,
238+
// so the switch renders with nothing selected. Coercing it to False would show a
239+
// required flag as configured while the Sync gate still reads it as empty.
240+
value={value}
241+
onChange={setValue}
242+
aria-label={field.title}
243+
/>
246244
)
247245
case 'textarea':
248-
return <ChipModalField {...shared} type='textarea' value={value} onChange={setValue} />
246+
return (
247+
<ChipTextarea
248+
className='w-full'
249+
value={value}
250+
onChange={(event) => setValue(event.target.value)}
251+
rows={3}
252+
placeholder={`Enter ${field.title} as JSON`}
253+
/>
254+
)
255+
case 'unsupported':
256+
return (
257+
<ChipInput
258+
className='w-full'
259+
value=''
260+
onChange={() => {}}
261+
disabled
262+
placeholder={CUSTOM_BLOCK_UNSUPPORTED_HINT}
263+
/>
264+
)
249265
default:
250-
return <ChipModalField {...shared} type='input' value={value} onChange={setValue} />
266+
return (
267+
<ChipInput
268+
className='w-full'
269+
value={value}
270+
onChange={(event) => setValue(event.target.value)}
271+
placeholder={`Enter ${field.title}`}
272+
/>
273+
)
251274
}
252275
}
253276

0 commit comments

Comments
 (0)