Skip to content

Commit f8470a4

Browse files
committed
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.
1 parent b385131 commit f8470a4

11 files changed

Lines changed: 474 additions & 147 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
})

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

Lines changed: 29 additions & 33 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 type { SelectorKey } from '@/lib/selectors/manifest'
66
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
77
import type {
@@ -10,6 +10,11 @@ import type {
1010
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
1111
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/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
@@ -68,50 +73,41 @@ 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
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/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { CanonicalModeToggle } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle'
12
export { CheckboxList } from './checkbox-list'
23
export { Code } from './code'
34
export { ComboBox } from './combobox'

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import {
1212
getCodeEditorProps,
1313
handleKeyboardActivation,
1414
highlight,
15+
IconSwitch,
1516
Input,
1617
Label,
1718
languages,
18-
Tooltip,
1919
} from '@sim/emcn'
20-
import { ArrowLeftRight, Plus, Trash } from '@sim/emcn/icons'
20+
import { Plus, Trash, TypeJson, Upload } from '@sim/emcn/icons'
2121
import Editor from 'react-simple-code-editor'
2222
import {
2323
createDefaultInputFormatField,
@@ -84,6 +84,11 @@ const BOOLEAN_OPTIONS: ComboboxOption[] = [
8484
{ label: 'false', value: 'false' },
8585
]
8686

87+
const FILE_MODE_OPTIONS = [
88+
{ value: 'upload', label: 'File uploader', icon: Upload },
89+
{ value: 'json', label: 'JSON', icon: TypeJson },
90+
] as const
91+
8792
/**
8893
* Validates and sanitizes field names by removing control characters and quotes
8994
*/
@@ -158,41 +163,24 @@ export function FieldFormat({
158163
}
159164

160165
/**
161-
* Renders the ⇄ toggle that switches a file field between the uploader and the
162-
* raw JSON editor. Matches the canonical sub-block mode toggle. Hidden when the
163-
* value can't be safely represented by the uploader.
166+
* Switches a file field between the uploader and raw JSON editor, only when
167+
* the value can be safely represented by the uploader.
164168
*/
165169
const renderFileModeToggle = (field: Field) => {
166170
const { mode, canUseUploader } = getFileFieldMode(field)
167171
if (!canUseUploader) return null
168-
const label = mode === 'upload' ? 'Switch to JSON' : 'Switch to file uploader'
169172
return (
170-
<Tooltip.Root>
171-
<Tooltip.Trigger asChild>
172-
<button
173-
type='button'
174-
className='flex size-[12px] shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
175-
onClick={() =>
176-
setFileFieldModes((prev) => ({
177-
...prev,
178-
[field.id]: mode === 'upload' ? 'json' : 'upload',
179-
}))
180-
}
181-
disabled={isReadOnly}
182-
aria-label={label}
183-
>
184-
<ArrowLeftRight
185-
className={cn(
186-
'h-[12px]! w-[12px]!',
187-
mode === 'json' ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'
188-
)}
189-
/>
190-
</button>
191-
</Tooltip.Trigger>
192-
<Tooltip.Content side='top'>
193-
<p>{label}</p>
194-
</Tooltip.Content>
195-
</Tooltip.Root>
173+
<IconSwitch
174+
options={FILE_MODE_OPTIONS}
175+
value={mode}
176+
onValueChange={(nextMode) =>
177+
setFileFieldModes((prev) => ({ ...prev, [field.id]: nextMode }))
178+
}
179+
disabled={isReadOnly}
180+
showTooltips
181+
aria-label='File input mode'
182+
className='-my-1'
183+
/>
196184
)
197185
}
198186

0 commit comments

Comments
 (0)