From e0101fe8cb2f11ade62c9989d396935ab1705f23 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:02:13 -0700 Subject: [PATCH 1/8] fix(editor): use compact mode switches across input controls Restore the two-choice icon switch with smaller geometry and consistent label-row spacing. Use it for canonical subblocks, Start file inputs, boolean assignments, and knowledge connector fields. Keep connector radio controls outside field labels and preserve disabled and selected-state behavior. --- .../connector-config-fields.test.tsx | 109 ++++++++++++++ .../connector-config-fields.tsx | 62 ++++---- .../components/canonical-mode-toggle.tsx | 29 ++++ .../field-header/field-header.test.tsx | 12 +- .../components/field-header/field-header.tsx | 39 +---- .../components/sub-block/components/index.ts | 1 + .../components/starter/input-format.tsx | 54 +++---- .../variables-input/variables-input.tsx | 55 +++---- .../src/components/chip-modal/chip-modal.tsx | 30 ++-- .../icon-switch/icon-switch.test.tsx | 138 ++++++++++++++++++ .../components/icon-switch/icon-switch.tsx | 97 ++++++++++++ packages/emcn/src/components/index.ts | 1 + 12 files changed, 485 insertions(+), 142 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle.tsx create mode 100644 packages/emcn/src/components/icon-switch/icon-switch.test.tsx create mode 100644 packages/emcn/src/components/icon-switch/icon-switch.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.test.tsx new file mode 100644 index 00000000000..9a8682ba7ed --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.test.tsx @@ -0,0 +1,109 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields' +import { + type ConfigFieldValue, + useConnectorConfigFields, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' +import { gmailConnectorMeta } from '@/connectors/gmail/meta' + +vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field', () => ({ + ConnectorSelectorField: ({ value }: { value: ConfigFieldValue }) => ( + {Array.isArray(value) ? value.join(',') : value} + ), +})) + +const CONNECTOR = { + ...gmailConnectorMeta, + configFields: gmailConnectorMeta.configFields.filter( + (field) => field.canonicalParamId === 'label' + ), +} + +interface HarnessProps { + disabled?: boolean +} + +function Harness({ disabled = false }: HarnessProps) { + const config = useConnectorConfigFields({ + connectorConfig: CONNECTOR, + initialSourceConfig: { labelSelector: ['INBOX', 'IMPORTANT'], label: ['STARRED'] }, + }) + return ( + + ) +} + +let root: Root +let container: HTMLDivElement + +beforeEach(() => { + vi.useFakeTimers() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.useRealTimers() +}) + +function radio(label: string): HTMLInputElement { + const input = container.querySelector( + `input[type="radio"][aria-label="${label}"]` + ) + if (!input) throw new Error(`Missing mode option: ${label}`) + return input +} + +describe('connector input mode switch', () => { + it("preserves each mode's stored values when switching to manual input and back", () => { + act(() => root.render()) + expect(radio('Selector').checked).toBe(true) + + act(() => radio('Manual input').click()) + expect(radio('Manual input').checked).toBe(true) + expect(container.querySelector('input:not([type="radio"])')?.value).toBe( + 'STARRED' + ) + + act(() => radio('Manual input').click()) + expect(radio('Manual input').checked).toBe(true) + + act(() => radio('Selector').click()) + expect(radio('Selector').checked).toBe(true) + expect(container.querySelector('[data-testid="selector-value"]')?.textContent).toBe( + 'INBOX,IMPORTANT' + ) + }) + + it('keeps the switch outside the field label and ignores clicks on the title', () => { + act(() => root.render()) + expect(container.querySelector('[role="radiogroup"]')?.closest('label')).toBeNull() + act(() => container.querySelector('label')?.click()) + expect(radio('Selector').checked).toBe(true) + }) + + it('prevents mode changes while submission disables the fields', () => { + act(() => root.render()) + expect(radio('Selector').disabled).toBe(true) + expect(radio('Manual input').disabled).toBe(true) + act(() => radio('Manual input').click()) + expect(radio('Selector').checked).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx index 901c72ea064..2808680a078 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx @@ -1,7 +1,7 @@ 'use client' -import { Button, ChipCombobox, ChipInput, ChipModalField, Tooltip } from '@sim/emcn' -import { ArrowLeftRight, CircleInfo } from '@sim/emcn/icons' +import { Button, ChipCombobox, ChipInput, ChipModalField, IconSwitch, Tooltip } from '@sim/emcn' +import { CircleInfo, List, TypeText } from '@sim/emcn/icons' import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import type { ConfigFieldMap, @@ -10,6 +10,11 @@ import type { import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types' import type { SelectorKey } from '@/hooks/selectors/types' +const MODE_OPTIONS = [ + { value: 'basic', label: 'Selector', icon: List }, + { value: 'advanced', label: 'Manual input', icon: TypeText }, +] as const + export interface ConnectorConfigFieldsProps { /** Registry definition whose `configFields` drive the rendered rows. */ connectorConfig: ConnectorMeta @@ -68,50 +73,41 @@ export function ConnectorConfigFields({ * Cancelling the click's default action keeps label clicks * inert without affecting the buttons' own handlers. */ - event.preventDefault()} - > - - - {field.title} - {field.required && *} - - {field.description && ( - - - - - {field.description} - - )} + event.preventDefault()}> + + {field.title} + {field.required && *} - {hasCanonicalPair && canonicalId && ( + {field.description && ( - - {field.mode === 'basic' ? 'Switch to manual input' : 'Switch to selector'} - + {field.description} )} } + titleAdornment={ + hasCanonicalPair && canonicalId ? ( + onToggleCanonicalMode(canonicalId)} + disabled={disabled} + showTooltips + aria-label={`${field.title} input mode`} + className='-my-1' + /> + ) : undefined + } > {field.type === 'selector' && field.selectorKey ? ( void +} + +const MODE_OPTIONS = [ + { value: 'basic', label: 'Selector', icon: List }, + { value: 'advanced', label: 'Variable', icon: VariableIcon }, +] as const + +export function CanonicalModeToggle({ mode, disabled, onToggle }: CanonicalModeToggleProps) { + return ( + onToggle?.()} + disabled={disabled} + showTooltips + aria-label='Input mode' + className='-my-1' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.test.tsx index 0c62d5155f4..8407c592dd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.test.tsx @@ -41,7 +41,17 @@ describe('SubBlockFieldHeader', () => { expect(container?.textContent).toContain('Response format') expect(container?.querySelector('[aria-label="Required"]')).not.toBeNull() - expect(container?.querySelector('[aria-label="Switch to manual ID"]')).not.toBeNull() + expect( + container?.querySelector('input[type="radio"][aria-label="Selector"]') + ?.checked + ).toBe(true) + + const variableOption = container?.querySelector( + 'input[type="radio"][aria-label="Variable"]' + ) + if (!variableOption) throw new Error('Canonical mode switch did not render') + act(() => variableOption.click()) + expect(onToggle).toHaveBeenCalledOnce() const copyButton = container?.querySelector('[aria-label="Copy value"]') if (!copyButton) throw new Error('Copy action did not render') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.tsx index 1515a1a1282..33ac8424bd4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.tsx @@ -1,13 +1,7 @@ import type { FocusEvent, ReactNode, RefObject } from 'react' import { Button, ChipInput, Label, Tooltip } from '@sim/emcn' -import { - ArrowLeftRight, - ArrowUp, - Check, - Clipboard, - SquareArrowUpRight, - TriangleAlert, -} from '@sim/emcn/icons' +import { ArrowUp, Check, Clipboard, SquareArrowUpRight, TriangleAlert } from '@sim/emcn/icons' +import { CanonicalModeToggle } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle' interface FieldHeaderWandAction { isSearchActive: boolean @@ -62,9 +56,6 @@ export function SubBlockFieldHeader({ copyAction, externalLinkAction, }: SubBlockFieldHeaderProps) { - const canonicalTooltip = - canonicalAction?.mode === 'advanced' ? 'Switch to selector' : 'Switch to manual ID' - const handleWandBlur = (event: FocusEvent) => { if (event.relatedTarget instanceof HTMLElement && event.relatedTarget.closest('button')) return wandAction?.onSearchBlur() @@ -172,27 +163,11 @@ export function SubBlockFieldHeader({ ) : null} {canonicalAction ? ( - - - - - {canonicalTooltip} - + ) : null} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index cdda2da961d..141235e438c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -1,4 +1,5 @@ export { BooleanControl } from './boolean-control' +export { CanonicalModeToggle } from './canonical-mode-toggle' export { CheckboxList } from './checkbox-list' export { Code } from './code' export { ComboBox } from './combobox' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index e43fb61aa7b..95005090420 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -6,17 +6,16 @@ import { Code, type ComboboxOption, calculateGutterWidth, - cn, Expandable, ExpandableContent, getCodeEditorProps, handleKeyboardActivation, highlight, + IconSwitch, Label, languages, - Tooltip, } from '@sim/emcn' -import { ArrowLeftRight, Plus, Trash } from '@sim/emcn/icons' +import { Plus, Trash, TypeJson, Upload } from '@sim/emcn/icons' import Editor from 'react-simple-code-editor' import { createDefaultInputFormatField, @@ -84,6 +83,11 @@ const BOOLEAN_OPTIONS: ComboboxOption[] = [ { label: 'false', value: 'false' }, ] +const FILE_MODE_OPTIONS = [ + { value: 'upload', label: 'File uploader', icon: Upload }, + { value: 'json', label: 'JSON', icon: TypeJson }, +] as const + /** * Validates and sanitizes field names by removing control characters and quotes */ @@ -158,42 +162,24 @@ export function FieldFormat({ } /** - * Renders the ⇄ toggle that switches a file field between the uploader and the - * raw JSON editor. Matches the canonical sub-block mode toggle. Hidden when the - * value can't be safely represented by the uploader. + * Switches a file field between the uploader and raw JSON editor, only when + * the value can be safely represented by the uploader. */ const renderFileModeToggle = (field: Field) => { const { mode, canUseUploader } = getFileFieldMode(field) if (!canUseUploader) return null - const label = mode === 'upload' ? 'Switch to JSON' : 'Switch to file uploader' return ( - - - - - -

{label}

-
-
+ + setFileFieldModes((prev) => ({ ...prev, [field.id]: nextMode })) + } + disabled={isReadOnly} + showTooltips + aria-label='File input mode' + className='-my-1' + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx index 5be9308c629..7d946381b08 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx @@ -6,12 +6,13 @@ import { CollapsibleCard, type ComboboxOption, cn, + IconSwitch, Label, - Tooltip, } from '@sim/emcn' -import { ArrowLeftRight, Plus, Trash } from '@sim/emcn/icons' +import { List, Plus, Trash } from '@sim/emcn/icons' import { generateId } from '@sim/utils/id' import { useParams } from 'next/navigation' +import { VariableIcon } from '@/components/icons' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { ReferenceTextarea, @@ -64,6 +65,11 @@ const BOOLEAN_OPTIONS: ComboboxOption[] = [ { label: 'false', value: 'false' }, ] +const BOOLEAN_MODE_OPTIONS = [ + { value: 'selector', label: 'Selector', icon: List }, + { value: 'manual', label: 'Variable', icon: VariableIcon }, +] as const + /** * Values representable by the boolean selector; anything else (e.g. a block * reference) requires the manual input. @@ -462,37 +468,20 @@ export function VariablesInput({
{assignment.type === 'boolean' && ( - - - - - -

{isManualBoolean ? 'Switch to selector' : 'Switch to manual value'}

-
-
+ + setManualBooleanModes((prev) => ({ + ...prev, + [assignment.id]: mode === 'manual', + })) + } + disabled={isReadOnly} + showTooltips + aria-label='Boolean input mode' + className='-my-1' + /> )}
{assignment.type === 'boolean' && !isManualBoolean ? ( diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx index 61e239d3fc5..0bf178ad1d6 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx @@ -387,6 +387,8 @@ export type ChipModalDropdownOption = ChipDropdownOption interface ChipModalFieldBaseProps { /** Field title rendered above the control. Replaces the legacy `label` slot. */ title: React.ReactNode + /** Trailing title-row control, rendered outside the label so it stays independently interactive. */ + titleAdornment?: React.ReactNode /** * Renders a `*` marker after the title and sets `aria-required` on the * underlying control. @@ -622,24 +624,34 @@ function ChipModalField(props: ChipModalFieldProps) { const submitRef = React.useContext(ChipModalSubmitContext) const errorId = `${id}-error` const hintId = `${id}-hint` - const { title, required, error, hint, flush = false, className } = props + const { title, titleAdornment, required, error, hint, flush = false, className } = props const associatesLabel = props.type === 'input' || props.type === 'email' || props.type === 'textarea' || props.type === 'copy' || props.type === 'emails' + const label = ( + + ) return (
- + {titleAdornment ? ( +
+ {label} + {titleAdornment} +
+ ) : ( + label + )} {renderChipModalControl(props, id, errorId, hintId, submitRef)} {error && props.type !== 'emails' ? (
+ {options.map((option) => { + const Icon = option.icon + const selected = option.value === value + const optionId = `${groupName}-${option.value}` + const input = ( + onValueChange(option.value)} + disabled={disabled} + aria-label={option.label} + className='peer m-0 size-[16px] cursor-pointer appearance-none rounded-[3px] bg-transparent transition-colors checked:bg-[var(--surface-active)] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[var(--text-icon)] disabled:cursor-not-allowed' + /> + ) + + return ( + + ) + })} +
+ ) +} diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index 240dc803be5..c914ace6ad9 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -128,6 +128,7 @@ export { } from './dropdown-menu/dropdown-menu' export { Expandable, ExpandableContent } from './expandable/expandable' export { DashedDividerLine, FieldDivider } from './field-divider/field-divider' +export { IconSwitch, type IconSwitchOption, type IconSwitchProps } from './icon-switch/icon-switch' export { Info } from './info/info' export { InfoCard, From 56152f47086e3b8c98e042df0100f1f38b7431f9 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:27:07 -0700 Subject: [PATCH 2/8] fix(emcn): align compact switch corner radii --- packages/emcn/src/components/icon-switch/icon-switch.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/emcn/src/components/icon-switch/icon-switch.tsx b/packages/emcn/src/components/icon-switch/icon-switch.tsx index 87ead272619..a52a0fc5e05 100644 --- a/packages/emcn/src/components/icon-switch/icon-switch.tsx +++ b/packages/emcn/src/components/icon-switch/icon-switch.tsx @@ -43,7 +43,7 @@ export function IconSwitch({ role='radiogroup' aria-label={ariaLabel} className={cn( - 'inline-flex w-fit shrink-0 items-center gap-0.5 rounded-[5px] border border-[var(--border)] bg-[var(--surface-3)] p-[1px]', + 'inline-flex w-fit shrink-0 items-center gap-0.5 rounded-sm border border-[var(--border)] bg-[var(--surface-3)] p-[1px]', disabled && 'opacity-50', className )} @@ -62,7 +62,7 @@ export function IconSwitch({ onChange={() => onValueChange(option.value)} disabled={disabled} aria-label={option.label} - className='peer m-0 size-[16px] cursor-pointer appearance-none rounded-[3px] bg-transparent transition-colors checked:bg-[var(--surface-active)] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[var(--text-icon)] disabled:cursor-not-allowed' + className='peer m-0 size-[16px] cursor-pointer appearance-none rounded-[calc(theme(borderRadius.sm)-1px-var(--border-width,1px))] bg-transparent transition-colors checked:bg-[var(--surface-active)] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[var(--text-icon)] disabled:cursor-not-allowed' /> ) From 6ddc1f9194183212d4b60cd8d0706545a863a76d Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:29:22 -0700 Subject: [PATCH 3/8] fix(emcn): fill compact switch segments to the border --- packages/emcn/src/components/icon-switch/icon-switch.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/emcn/src/components/icon-switch/icon-switch.tsx b/packages/emcn/src/components/icon-switch/icon-switch.tsx index a52a0fc5e05..bcbc85cf730 100644 --- a/packages/emcn/src/components/icon-switch/icon-switch.tsx +++ b/packages/emcn/src/components/icon-switch/icon-switch.tsx @@ -43,7 +43,7 @@ export function IconSwitch({ role='radiogroup' aria-label={ariaLabel} className={cn( - 'inline-flex w-fit shrink-0 items-center gap-0.5 rounded-sm border border-[var(--border)] bg-[var(--surface-3)] p-[1px]', + 'inline-flex w-fit shrink-0 items-center rounded-sm border border-[var(--border)] bg-[var(--surface-3)]', disabled && 'opacity-50', className )} @@ -62,7 +62,7 @@ export function IconSwitch({ onChange={() => onValueChange(option.value)} disabled={disabled} aria-label={option.label} - className='peer m-0 size-[16px] cursor-pointer appearance-none rounded-[calc(theme(borderRadius.sm)-1px-var(--border-width,1px))] bg-transparent transition-colors checked:bg-[var(--surface-active)] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[var(--text-icon)] disabled:cursor-not-allowed' + className='peer m-0 size-[18px] cursor-pointer appearance-none rounded-[inherit] bg-transparent transition-colors checked:bg-[var(--surface-active)] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[var(--text-icon)] disabled:cursor-not-allowed' /> ) @@ -70,7 +70,10 @@ export function IconSwitch({