Skip to content

Commit 8dd1785

Browse files
j15zclaude
andauthored
improvement(editor): replace mode toggle buttons with a switch (#7825)
* 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. * fix(emcn): align compact switch corner radii * fix(emcn): fill compact switch segments to the border * fix(emcn): use white surface for unselected mode * fix(emcn): restore inset mode switch squares * improvement(emcn): match mode switch corners and inset to chip fields * fix(editor): keep mode switch rows at their original height The old toggle buttons set the title row height (20px in the editor, 18px in the connector modal). The switch only occupies 14px, so Value rows in the Start block Files input and Variables block, and connector titles with a mode switch, collapsed to the label height. Give those rows the same minimum height the field header already uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(knowledge): keep connector mode switch focused across mode changes --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 087a21d commit 8dd1785

12 files changed

Lines changed: 513 additions & 145 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/** @vitest-environment jsdom */
2+
import { act } from 'react'
3+
import { createRoot, type Root } from 'react-dom/client'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields'
6+
import {
7+
type ConfigFieldValue,
8+
useConnectorConfigFields,
9+
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
10+
import { gmailConnectorMeta } from '@/connectors/gmail/meta'
11+
12+
vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field', () => ({
13+
ConnectorSelectorField: ({ value }: { value: ConfigFieldValue }) => (
14+
<span data-testid='selector-value'>{Array.isArray(value) ? value.join(',') : value}</span>
15+
),
16+
}))
17+
18+
const CONNECTOR = {
19+
...gmailConnectorMeta,
20+
configFields: gmailConnectorMeta.configFields.filter(
21+
(field) => field.canonicalParamId === 'label'
22+
),
23+
}
24+
25+
interface HarnessProps {
26+
disabled?: boolean
27+
}
28+
29+
function Harness({ disabled = false }: HarnessProps) {
30+
const config = useConnectorConfigFields({
31+
connectorConfig: CONNECTOR,
32+
initialSourceConfig: { labelSelector: ['INBOX', 'IMPORTANT'], label: ['STARRED'] },
33+
})
34+
return (
35+
<ConnectorConfigFields
36+
connectorConfig={CONNECTOR}
37+
sourceConfig={config.sourceConfig}
38+
credentialId={null}
39+
canonicalGroups={config.canonicalGroups}
40+
canonicalModes={config.canonicalModes}
41+
isFieldVisible={config.isFieldVisible}
42+
onFieldChange={config.handleFieldChange}
43+
onToggleCanonicalMode={config.toggleCanonicalMode}
44+
disabled={disabled}
45+
/>
46+
)
47+
}
48+
49+
let root: Root
50+
let container: HTMLDivElement
51+
52+
beforeEach(() => {
53+
vi.useFakeTimers()
54+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
55+
container = document.createElement('div')
56+
document.body.appendChild(container)
57+
root = createRoot(container)
58+
})
59+
60+
afterEach(() => {
61+
act(() => root.unmount())
62+
container.remove()
63+
vi.useRealTimers()
64+
})
65+
66+
function radio(label: string): HTMLInputElement {
67+
const input = container.querySelector<HTMLInputElement>(
68+
`input[type="radio"][aria-label="${label}"]`
69+
)
70+
if (!input) throw new Error(`Missing mode option: ${label}`)
71+
return input
72+
}
73+
74+
describe('connector input mode switch', () => {
75+
it("preserves each mode's stored values when switching to manual input and back", () => {
76+
act(() => root.render(<Harness />))
77+
expect(radio('Selector').checked).toBe(true)
78+
79+
act(() => radio('Manual input').click())
80+
expect(radio('Manual input').checked).toBe(true)
81+
expect(container.querySelector<HTMLInputElement>('input:not([type="radio"])')?.value).toBe(
82+
'STARRED'
83+
)
84+
85+
act(() => radio('Manual input').click())
86+
expect(radio('Manual input').checked).toBe(true)
87+
88+
act(() => radio('Selector').click())
89+
expect(radio('Selector').checked).toBe(true)
90+
expect(container.querySelector('[data-testid="selector-value"]')?.textContent).toBe(
91+
'INBOX,IMPORTANT'
92+
)
93+
})
94+
95+
it('keeps the switch outside the field label and ignores clicks on the title', () => {
96+
act(() => root.render(<Harness />))
97+
expect(container.querySelector('[role="radiogroup"]')?.closest('label')).toBeNull()
98+
act(() => container.querySelector('label')?.click())
99+
expect(radio('Selector').checked).toBe(true)
100+
})
101+
102+
it('prevents mode changes while submission disables the fields', () => {
103+
act(() => root.render(<Harness disabled />))
104+
expect(radio('Selector').disabled).toBe(true)
105+
expect(radio('Manual input').disabled).toBe(true)
106+
act(() => radio('Manual input').click())
107+
expect(radio('Selector').checked).toBe(true)
108+
})
109+
110+
it('keeps focus on the switch through a keyboard mode round trip', () => {
111+
act(() => root.render(<Harness />))
112+
const selector = radio('Selector')
113+
const manual = radio('Manual input')
114+
115+
act(() => {
116+
manual.focus()
117+
manual.click()
118+
})
119+
expect(radio('Manual input')).toBe(manual)
120+
expect(manual.checked).toBe(true)
121+
expect(document.activeElement).toBe(manual)
122+
123+
act(() => {
124+
selector.focus()
125+
selector.click()
126+
})
127+
expect(radio('Selector')).toBe(selector)
128+
expect(selector.checked).toBe(true)
129+
expect(document.activeElement).toBe(selector)
130+
})
131+
})

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

3-
import { Button, ChipCombobox, ChipInput, ChipModalField, Tooltip } from '@sim/emcn'
4-
import { ArrowLeftRight, CircleInfo } from '@sim/emcn/icons'
3+
import { Button, ChipCombobox, ChipInput, ChipModalField, IconSwitch, Tooltip } from '@sim/emcn'
4+
import { CircleInfo, List, TypeText } from '@sim/emcn/icons'
55
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
66
import type {
77
ConfigFieldMap,
@@ -10,6 +10,11 @@ import type {
1010
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
1111
import type { SelectorKey } from '@/hooks/selectors/types'
1212

13+
const MODE_OPTIONS = [
14+
{ value: 'basic', label: 'Selector', icon: List },
15+
{ value: 'advanced', label: 'Manual input', icon: TypeText },
16+
] as const
17+
1318
export interface ConnectorConfigFieldsProps {
1419
/** Registry definition whose `configFields` drive the rendered rows. */
1520
connectorConfig: ConnectorMeta
@@ -59,7 +64,7 @@ export function ConnectorConfigFields({
5964

6065
return (
6166
<ChipModalField
62-
key={field.id}
67+
key={hasCanonicalPair && canonicalId ? `canonical:${canonicalId}` : field.id}
6368
type='custom'
6469
title={
6570
/**
@@ -68,53 +73,45 @@ export function ConnectorConfigFields({
6873
* Cancelling the click's default action keeps label clicks
6974
* inert without affecting the buttons' own handlers.
7075
*/
71-
<span
72-
className='flex w-full items-center justify-between'
73-
onClick={(event) => event.preventDefault()}
74-
>
75-
<span className='flex items-center gap-1'>
76-
<span>
77-
{field.title}
78-
{field.required && <span className='ml-0.5'>*</span>}
79-
</span>
80-
{field.description && (
81-
<Tooltip.Root>
82-
<Tooltip.Trigger asChild>
83-
<Button
84-
type='button'
85-
variant='ghost'
86-
className='flex size-[14px] cursor-help items-center justify-center p-0 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-secondary)]'
87-
aria-label={`About ${field.title}`}
88-
>
89-
<CircleInfo className='size-[12px]' />
90-
</Button>
91-
</Tooltip.Trigger>
92-
<Tooltip.Content side='top'>{field.description}</Tooltip.Content>
93-
</Tooltip.Root>
94-
)}
76+
<span className='flex items-center gap-1' onClick={(event) => event.preventDefault()}>
77+
<span>
78+
{field.title}
79+
{field.required && <span className='ml-0.5'>*</span>}
9580
</span>
96-
{hasCanonicalPair && canonicalId && (
81+
{field.description && (
9782
<Tooltip.Root>
9883
<Tooltip.Trigger asChild>
9984
<Button
10085
type='button'
10186
variant='ghost'
102-
className='flex size-[18px] items-center justify-center rounded-[3px] p-0 text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-secondary)]'
103-
onClick={() => onToggleCanonicalMode(canonicalId)}
87+
className='flex size-[14px] cursor-help items-center justify-center p-0 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-secondary)]'
88+
aria-label={`About ${field.title}`}
10489
>
105-
<ArrowLeftRight className='size-[12px]' />
90+
<CircleInfo className='size-[12px]' />
10691
</Button>
10792
</Tooltip.Trigger>
108-
<Tooltip.Content side='top'>
109-
{field.mode === 'basic' ? 'Switch to manual input' : 'Switch to selector'}
110-
</Tooltip.Content>
93+
<Tooltip.Content side='top'>{field.description}</Tooltip.Content>
11194
</Tooltip.Root>
11295
)}
11396
</span>
11497
}
98+
titleAdornment={
99+
hasCanonicalPair && canonicalId ? (
100+
<IconSwitch
101+
options={MODE_OPTIONS}
102+
value={field.mode === 'advanced' ? 'advanced' : 'basic'}
103+
onValueChange={() => onToggleCanonicalMode(canonicalId)}
104+
disabled={disabled}
105+
showTooltips
106+
aria-label={`${field.title} input mode`}
107+
className='-my-1'
108+
/>
109+
) : undefined
110+
}
115111
>
116112
{field.type === 'selector' && field.selectorKey ? (
117113
<ConnectorSelectorField
114+
key={field.id}
118115
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}
119116
value={sourceConfig[field.id] ?? (field.multi ? [] : '')}
120117
onChange={(value: ConfigFieldValue) => onFieldChange(field.id, value)}
@@ -126,6 +123,7 @@ export function ConnectorConfigFields({
126123
/>
127124
) : field.type === 'dropdown' && field.options ? (
128125
<ChipCombobox
126+
key={field.id}
129127
options={field.options.map((opt) => ({
130128
label: opt.label,
131129
value: opt.id,
@@ -140,6 +138,7 @@ export function ConnectorConfigFields({
140138
/>
141139
) : (
142140
<ChipInput
141+
key={field.id}
143142
value={
144143
Array.isArray(sourceConfig[field.id])
145144
? (sourceConfig[field.id] as string[]).join(', ')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { IconSwitch } from '@sim/emcn'
2+
import { List } from '@sim/emcn/icons'
3+
import { VariableIcon } from '@/components/icons'
4+
import type { CanonicalMode } from '@/lib/workflows/subblocks/visibility'
5+
6+
interface CanonicalModeToggleProps {
7+
mode: CanonicalMode
8+
disabled?: boolean
9+
onToggle?: () => void
10+
}
11+
12+
const MODE_OPTIONS = [
13+
{ value: 'basic', label: 'Selector', icon: List },
14+
{ value: 'advanced', label: 'Variable', icon: VariableIcon },
15+
] as const
16+
17+
export function CanonicalModeToggle({ mode, disabled, onToggle }: CanonicalModeToggleProps) {
18+
return (
19+
<IconSwitch
20+
options={MODE_OPTIONS}
21+
value={mode}
22+
onValueChange={() => onToggle?.()}
23+
disabled={disabled}
24+
showTooltips
25+
aria-label='Input mode'
26+
className='-my-1'
27+
/>
28+
)
29+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.test.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,17 @@ describe('SubBlockFieldHeader', () => {
4141

4242
expect(container?.textContent).toContain('Response format')
4343
expect(container?.querySelector('[aria-label="Required"]')).not.toBeNull()
44-
expect(container?.querySelector('[aria-label="Switch to manual ID"]')).not.toBeNull()
44+
expect(
45+
container?.querySelector<HTMLInputElement>('input[type="radio"][aria-label="Selector"]')
46+
?.checked
47+
).toBe(true)
48+
49+
const variableOption = container?.querySelector<HTMLInputElement>(
50+
'input[type="radio"][aria-label="Variable"]'
51+
)
52+
if (!variableOption) throw new Error('Canonical mode switch did not render')
53+
act(() => variableOption.click())
54+
expect(onToggle).toHaveBeenCalledOnce()
4555

4656
const copyButton = container?.querySelector<HTMLButtonElement>('[aria-label="Copy value"]')
4757
if (!copyButton) throw new Error('Copy action did not render')

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.tsx

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
import type { FocusEvent, ReactNode, RefObject } from 'react'
22
import { Button, ChipInput, Label, Tooltip } from '@sim/emcn'
3-
import {
4-
ArrowLeftRight,
5-
ArrowUp,
6-
Check,
7-
Clipboard,
8-
SquareArrowUpRight,
9-
TriangleAlert,
10-
} from '@sim/emcn/icons'
3+
import { ArrowUp, Check, Clipboard, SquareArrowUpRight, TriangleAlert } from '@sim/emcn/icons'
4+
import { CanonicalModeToggle } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle'
115

126
interface FieldHeaderWandAction {
137
isSearchActive: boolean
@@ -62,9 +56,6 @@ export function SubBlockFieldHeader({
6256
copyAction,
6357
externalLinkAction,
6458
}: SubBlockFieldHeaderProps) {
65-
const canonicalTooltip =
66-
canonicalAction?.mode === 'advanced' ? 'Switch to selector' : 'Switch to manual ID'
67-
6859
const handleWandBlur = (event: FocusEvent<HTMLInputElement>) => {
6960
if (event.relatedTarget instanceof HTMLElement && event.relatedTarget.closest('button')) return
7061
wandAction?.onSearchBlur()
@@ -172,27 +163,11 @@ export function SubBlockFieldHeader({
172163
</Tooltip.Root>
173164
) : null}
174165
{canonicalAction ? (
175-
<Tooltip.Root>
176-
<Tooltip.Trigger asChild>
177-
<Button
178-
type='button'
179-
variant='quiet'
180-
size='icon'
181-
onClick={canonicalAction.onToggle}
182-
disabled={canonicalAction.disabled}
183-
aria-label={canonicalTooltip}
184-
>
185-
<ArrowLeftRight
186-
className={
187-
canonicalAction.mode === 'advanced'
188-
? 'size-[14px] text-[var(--text-primary)]'
189-
: 'size-[14px]'
190-
}
191-
/>
192-
</Button>
193-
</Tooltip.Trigger>
194-
<Tooltip.Content side='top'>{canonicalTooltip}</Tooltip.Content>
195-
</Tooltip.Root>
166+
<CanonicalModeToggle
167+
mode={canonicalAction.mode}
168+
onToggle={canonicalAction.onToggle}
169+
disabled={canonicalAction.disabled}
170+
/>
196171
) : null}
197172
</div>
198173
</div>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { BooleanControl } from './boolean-control'
2+
export { CanonicalModeToggle } from './canonical-mode-toggle'
23
export { CheckboxList } from './checkbox-list'
34
export { Code } from './code'
45
export { ComboBox } from './combobox'

0 commit comments

Comments
 (0)