Skip to content
Merged
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,131 @@
/** @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)
})

it('keeps focus on the switch through a keyboard mode round trip', () => {
act(() => root.render(<Harness />))
const selector = radio('Selector')
const manual = radio('Manual input')

act(() => {
manual.focus()
manual.click()
})
expect(radio('Manual input')).toBe(manual)
expect(manual.checked).toBe(true)
expect(document.activeElement).toBe(manual)

act(() => {
selector.focus()
selector.click()
})
expect(radio('Selector')).toBe(selector)
expect(selector.checked).toBe(true)
expect(document.activeElement).toBe(selector)
})
})
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 { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
import type {
ConfigFieldMap,
Expand All @@ -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
Expand Down Expand Up @@ -59,7 +64,7 @@ export function ConnectorConfigFields({

return (
<ChipModalField
key={field.id}
key={hasCanonicalPair && canonicalId ? `canonical:${canonicalId}` : field.id}
type='custom'
title={
/**
Expand All @@ -68,53 +73,45 @@ 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)}
Comment thread
j15z marked this conversation as resolved.
disabled={disabled}
showTooltips
aria-label={`${field.title} input mode`}
className='-my-1'
/>
) : undefined
}
>
{field.type === 'selector' && field.selectorKey ? (
<ConnectorSelectorField
key={field.id}
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}
value={sourceConfig[field.id] ?? (field.multi ? [] : '')}
onChange={(value: ConfigFieldValue) => onFieldChange(field.id, value)}
Expand All @@ -126,6 +123,7 @@ export function ConnectorConfigFields({
/>
) : field.type === 'dropdown' && field.options ? (
<ChipCombobox
key={field.id}
options={field.options.map((opt) => ({
label: opt.label,
value: opt.id,
Expand All @@ -140,6 +138,7 @@ export function ConnectorConfigFields({
/>
) : (
<ChipInput
key={field.id}
value={
Array.isArray(sourceConfig[field.id])
? (sourceConfig[field.id] as string[]).join(', ')
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
Expand Up @@ -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<HTMLInputElement>('input[type="radio"][aria-label="Selector"]')
?.checked
).toBe(true)

const variableOption = container?.querySelector<HTMLInputElement>(
'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<HTMLButtonElement>('[aria-label="Copy value"]')
if (!copyButton) throw new Error('Copy action did not render')
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<HTMLInputElement>) => {
if (event.relatedTarget instanceof HTMLElement && event.relatedTarget.closest('button')) return
wandAction?.onSearchBlur()
Expand Down Expand Up @@ -172,27 +163,11 @@ export function SubBlockFieldHeader({
</Tooltip.Root>
) : null}
{canonicalAction ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='quiet'
size='icon'
onClick={canonicalAction.onToggle}
disabled={canonicalAction.disabled}
aria-label={canonicalTooltip}
>
<ArrowLeftRight
className={
canonicalAction.mode === 'advanced'
? 'size-[14px] text-[var(--text-primary)]'
: 'size-[14px]'
}
/>
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{canonicalTooltip}</Tooltip.Content>
</Tooltip.Root>
<CanonicalModeToggle
mode={canonicalAction.mode}
onToggle={canonicalAction.onToggle}
disabled={canonicalAction.disabled}
/>
) : null}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Loading
Loading