Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 }) => (
<span data-testid='selector-value'>{Array.isArray(value) ? value.join(',') : value}</span>
),
}))

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 (
<ConnectorConfigFields
connectorConfig={CONNECTOR}
sourceConfig={config.sourceConfig}
credentialId={null}
canonicalGroups={config.canonicalGroups}
canonicalModes={config.canonicalModes}
isFieldVisible={config.isFieldVisible}
onFieldChange={config.handleFieldChange}
onToggleCanonicalMode={config.toggleCanonicalMode}
disabled={disabled}
/>
)
}

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<HTMLInputElement>(
`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(<Harness />))
expect(radio('Selector').checked).toBe(true)

act(() => radio('Manual input').click())
expect(radio('Manual input').checked).toBe(true)
expect(container.querySelector<HTMLInputElement>('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(<Harness />))
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(<Harness disabled />))
expect(radio('Selector').disabled).toBe(true)
expect(radio('Manual input').disabled).toBe(true)
act(() => radio('Manual input').click())
expect(radio('Selector').checked).toBe(true)
})
})
Original file line number Diff line number Diff line change
@@ -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 type { SelectorKey } from '@/lib/selectors/manifest'
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
import type {
Expand All @@ -10,6 +10,11 @@ import type {
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/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
Expand Down Expand Up @@ -68,50 +73,41 @@ export function ConnectorConfigFields({
* Cancelling the click's default action keeps label clicks
* inert without affecting the buttons' own handlers.
*/
<span
className='flex w-full items-center justify-between'
onClick={(event) => event.preventDefault()}
>
<span className='flex items-center gap-1'>
<span>
{field.title}
{field.required && <span className='ml-0.5'>*</span>}
</span>
{field.description && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
className='flex size-[14px] cursor-help items-center justify-center p-0 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-secondary)]'
aria-label={`About ${field.title}`}
>
<CircleInfo className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{field.description}</Tooltip.Content>
</Tooltip.Root>
)}
<span className='flex items-center gap-1' onClick={(event) => event.preventDefault()}>
<span>
{field.title}
{field.required && <span className='ml-0.5'>*</span>}
</span>
{hasCanonicalPair && canonicalId && (
{field.description && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
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)]'
onClick={() => onToggleCanonicalMode(canonicalId)}
className='flex size-[14px] cursor-help items-center justify-center p-0 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-secondary)]'
aria-label={`About ${field.title}`}
>
<ArrowLeftRight className='size-[12px]' />
<CircleInfo className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{field.mode === 'basic' ? 'Switch to manual input' : 'Switch to selector'}
</Tooltip.Content>
<Tooltip.Content side='top'>{field.description}</Tooltip.Content>
</Tooltip.Root>
)}
</span>
}
titleAdornment={
hasCanonicalPair && canonicalId ? (
<IconSwitch
options={MODE_OPTIONS}
value={field.mode === 'advanced' ? 'advanced' : 'basic'}
onValueChange={() => onToggleCanonicalMode(canonicalId)}
disabled={disabled}
showTooltips
aria-label={`${field.title} input mode`}
className='-my-1'
/>
) : undefined
}
>
{field.type === 'selector' && field.selectorKey ? (
<ConnectorSelectorField
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { IconSwitch } from '@sim/emcn'
import { List } from '@sim/emcn/icons'
import { VariableIcon } from '@/components/icons'
import type { CanonicalMode } from '@/lib/workflows/subblocks/visibility'

interface CanonicalModeToggleProps {
mode: CanonicalMode
disabled?: boolean
onToggle?: () => 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 (
<IconSwitch
options={MODE_OPTIONS}
value={mode}
onValueChange={() => onToggle?.()}
disabled={disabled}
showTooltips
aria-label='Input mode'
className='-my-1'
/>
)
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { CanonicalModeToggle } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/canonical-mode-toggle'
export { CheckboxList } from './checkbox-list'
export { Code } from './code'
export { ComboBox } from './combobox'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import {
getCodeEditorProps,
handleKeyboardActivation,
highlight,
IconSwitch,
Input,
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,
Expand Down Expand Up @@ -84,6 +84,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
*/
Expand Down Expand Up @@ -158,41 +163,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 (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='flex size-[12px] shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
onClick={() =>
setFileFieldModes((prev) => ({
...prev,
[field.id]: mode === 'upload' ? 'json' : 'upload',
}))
}
disabled={isReadOnly}
aria-label={label}
>
<ArrowLeftRight
className={cn(
'h-[12px]! w-[12px]!',
mode === 'json' ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'
)}
/>
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>{label}</p>
</Tooltip.Content>
</Tooltip.Root>
<IconSwitch
options={FILE_MODE_OPTIONS}
value={mode}
onValueChange={(nextMode) =>
setFileFieldModes((prev) => ({ ...prev, [field.id]: nextMode }))
}
disabled={isReadOnly}
showTooltips
aria-label='File input mode'
className='-my-1'
/>
)
}

Expand Down
Loading
Loading