Skip to content

Commit cafad14

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 cbf9e99 commit cafad14

10 files changed

Lines changed: 454 additions & 141 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: 30 additions & 36 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 { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors'
66
import type { ResourceScope } from '@/lib/core/resource-scope'
77
import type { Credential } from '@/lib/oauth/types'
@@ -15,6 +15,11 @@ import type {
1515
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
1616
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
1717

18+
const MODE_OPTIONS = [
19+
{ value: 'basic', label: 'Selector', icon: List },
20+
{ value: 'advanced', label: 'Manual input', icon: TypeText },
21+
] as const
22+
1823
export interface ConnectorConfigFieldsProps {
1924
scope?: ResourceScope
2025
accessMode?: ConnectorAccessMode
@@ -90,54 +95,43 @@ export function ConnectorConfigFields({
9095
* Cancelling the click's default action keeps label clicks
9196
* inert without affecting the buttons' own handlers.
9297
*/
93-
<span
94-
className='flex w-full items-center justify-between'
95-
onClick={(event) => event.preventDefault()}
96-
>
97-
<span className='flex items-center gap-1'>
98-
<span>
99-
{title}
100-
{isConnectorFieldRequired(field, connectorConfig, accessMode) && (
101-
<span className='ml-0.5'>*</span>
102-
)}
103-
</span>
104-
{description && (
105-
<Tooltip.Root>
106-
<Tooltip.Trigger asChild>
107-
<Button
108-
type='button'
109-
variant='ghost'
110-
size='icon'
111-
aria-label={`About ${title}`}
112-
>
113-
<CircleInfo className='size-[14px]' />
114-
</Button>
115-
</Tooltip.Trigger>
116-
<Tooltip.Content side='top'>{description}</Tooltip.Content>
117-
</Tooltip.Root>
98+
<span className='flex items-center gap-1' onClick={(event) => event.preventDefault()}>
99+
<span>
100+
{title}
101+
{isConnectorFieldRequired(field, connectorConfig, accessMode) && (
102+
<span className='ml-0.5'>*</span>
118103
)}
119104
</span>
120-
{hasCanonicalPair && canonicalId && (
105+
{description && (
121106
<Tooltip.Root>
122107
<Tooltip.Trigger asChild>
123108
<Button
124109
type='button'
125-
variant='quiet'
110+
variant='ghost'
126111
size='icon'
127-
disabled={disabled}
128-
aria-label={`Switch ${title} to ${field.mode === 'basic' ? 'manual input' : 'selector'}`}
129-
onClick={() => onToggleCanonicalMode(canonicalId)}
112+
aria-label={`About ${title}`}
130113
>
131-
<ArrowLeftRight className='size-[14px]' />
114+
<CircleInfo className='size-[14px]' />
132115
</Button>
133116
</Tooltip.Trigger>
134-
<Tooltip.Content side='top'>
135-
{field.mode === 'basic' ? 'Switch to manual input' : 'Switch to selector'}
136-
</Tooltip.Content>
117+
<Tooltip.Content side='top'>{description}</Tooltip.Content>
137118
</Tooltip.Root>
138119
)}
139120
</span>
140121
}
122+
titleActions={
123+
hasCanonicalPair && canonicalId ? (
124+
<IconSwitch
125+
options={MODE_OPTIONS}
126+
value={field.mode === 'advanced' ? 'advanced' : 'basic'}
127+
onValueChange={() => onToggleCanonicalMode(canonicalId)}
128+
disabled={disabled}
129+
showTooltips
130+
aria-label={`${title} input mode`}
131+
className='-my-1'
132+
/>
133+
) : undefined
134+
}
141135
>
142136
{field.type === 'selector' && field.selectorKey ? (
143137
<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)