diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.test.tsx new file mode 100644 index 00000000000..5a431614119 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.test.tsx @@ -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 }) => ( + {Array.isArray(value) ? value.join(',') : value} + ), +})) + +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 ( + + ) +} + +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( + `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()) + expect(radio('Selector').checked).toBe(true) + + act(() => radio('Manual input').click()) + expect(radio('Manual input').checked).toBe(true) + expect(container.querySelector('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()) + 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()) + 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()) + 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) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx index 901c72ea064..100c6b37e04 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx @@ -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, @@ -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 @@ -59,7 +64,7 @@ export function ConnectorConfigFields({ return ( event.preventDefault()} - > - - - {field.title} - {field.required && *} - - {field.description && ( - - - - - {field.description} - - )} + event.preventDefault()}> + + {field.title} + {field.required && *} - {hasCanonicalPair && canonicalId && ( + {field.description && ( - - {field.mode === 'basic' ? 'Switch to manual input' : 'Switch to selector'} - + {field.description} )} } + titleAdornment={ + hasCanonicalPair && canonicalId ? ( + onToggleCanonicalMode(canonicalId)} + disabled={disabled} + showTooltips + aria-label={`${field.title} input mode`} + className='-my-1' + /> + ) : undefined + } > {field.type === 'selector' && field.selectorKey ? ( onFieldChange(field.id, value)} @@ -126,6 +123,7 @@ export function ConnectorConfigFields({ /> ) : field.type === 'dropdown' && field.options ? ( ({ label: opt.label, value: opt.id, @@ -140,6 +138,7 @@ export function ConnectorConfigFields({ /> ) : ( 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 ( + onToggle?.()} + disabled={disabled} + showTooltips + aria-label='Input mode' + className='-my-1' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.test.tsx index 0c62d5155f4..8407c592dd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.test.tsx @@ -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('input[type="radio"][aria-label="Selector"]') + ?.checked + ).toBe(true) + + const variableOption = container?.querySelector( + '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('[aria-label="Copy value"]') if (!copyButton) throw new Error('Copy action did not render') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.tsx index 1515a1a1282..33ac8424bd4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-header/field-header.tsx @@ -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 @@ -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) => { if (event.relatedTarget instanceof HTMLElement && event.relatedTarget.closest('button')) return wandAction?.onSearchBlur() @@ -172,27 +163,11 @@ export function SubBlockFieldHeader({ ) : null} {canonicalAction ? ( - - - - - {canonicalTooltip} - + ) : null} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index cdda2da961d..141235e438c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index e43fb61aa7b..78e8c7df25c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -6,17 +6,16 @@ import { Code, type ComboboxOption, calculateGutterWidth, - cn, Expandable, ExpandableContent, getCodeEditorProps, handleKeyboardActivation, highlight, + IconSwitch, 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, @@ -84,6 +83,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 */ @@ -158,42 +162,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 ( - - - - - -

{label}

-
-
+ + setFileFieldModes((prev) => ({ ...prev, [field.id]: nextMode })) + } + disabled={isReadOnly} + showTooltips + aria-label='File input mode' + className='-my-1' + /> ) } @@ -723,7 +709,7 @@ export function FieldFormat({ {showValue && (
{isFileFieldType(field.type) ? ( -
+
{renderFieldLabel('Value')} {renderFileModeToggle(field)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx index 5be9308c629..4cc4ad8cf6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx @@ -6,12 +6,13 @@ import { CollapsibleCard, type ComboboxOption, cn, + IconSwitch, Label, - Tooltip, } from '@sim/emcn' -import { ArrowLeftRight, Plus, Trash } from '@sim/emcn/icons' +import { List, Plus, Trash } from '@sim/emcn/icons' import { generateId } from '@sim/utils/id' import { useParams } from 'next/navigation' +import { VariableIcon } from '@/components/icons' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { ReferenceTextarea, @@ -64,6 +65,11 @@ const BOOLEAN_OPTIONS: ComboboxOption[] = [ { label: 'false', value: 'false' }, ] +const BOOLEAN_MODE_OPTIONS = [ + { value: 'selector', label: 'Selector', icon: List }, + { value: 'manual', label: 'Variable', icon: VariableIcon }, +] as const + /** * Values representable by the boolean selector; anything else (e.g. a block * reference) requires the manual input. @@ -459,40 +465,23 @@ export function VariablesInput({
-
+
{assignment.type === 'boolean' && ( - - - - - -

{isManualBoolean ? 'Switch to selector' : 'Switch to manual value'}

-
-
+ + setManualBooleanModes((prev) => ({ + ...prev, + [assignment.id]: mode === 'manual', + })) + } + disabled={isReadOnly} + showTooltips + aria-label='Boolean input mode' + className='-my-1' + /> )}
{assignment.type === 'boolean' && !isManualBoolean ? ( diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx index 61e239d3fc5..af27d3795a1 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx @@ -387,6 +387,8 @@ export type ChipModalDropdownOption = ChipDropdownOption interface ChipModalFieldBaseProps { /** Field title rendered above the control. Replaces the legacy `label` slot. */ title: React.ReactNode + /** Trailing title-row control, rendered outside the label so it stays independently interactive. */ + titleAdornment?: React.ReactNode /** * Renders a `*` marker after the title and sets `aria-required` on the * underlying control. @@ -622,24 +624,34 @@ function ChipModalField(props: ChipModalFieldProps) { const submitRef = React.useContext(ChipModalSubmitContext) const errorId = `${id}-error` const hintId = `${id}-hint` - const { title, required, error, hint, flush = false, className } = props + const { title, titleAdornment, required, error, hint, flush = false, className } = props const associatesLabel = props.type === 'input' || props.type === 'email' || props.type === 'textarea' || props.type === 'copy' || props.type === 'emails' + const label = ( + + ) return (
- + {titleAdornment ? ( +
+ {label} + {titleAdornment} +
+ ) : ( + label + )} {renderChipModalControl(props, id, errorId, hintId, submitRef)} {error && props.type !== 'emails' ? (
+ {options.map((option) => { + const Icon = option.icon + const selected = option.value === value + const optionId = `${groupName}-${option.value}` + const input = ( + onValueChange(option.value)} + disabled={disabled} + aria-label={option.label} + className='peer m-0 size-[16px] cursor-pointer appearance-none rounded-[calc(theme(borderRadius.lg)-2px-var(--border-width,1px))] bg-transparent transition-colors checked:bg-[var(--surface-active)] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[var(--text-icon)] disabled:cursor-not-allowed' + /> + ) + + return ( + + ) + })} +
+ ) +} diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index 240dc803be5..c914ace6ad9 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -128,6 +128,7 @@ export { } from './dropdown-menu/dropdown-menu' export { Expandable, ExpandableContent } from './expandable/expandable' export { DashedDividerLine, FieldDivider } from './field-divider/field-divider' +export { IconSwitch, type IconSwitchOption, type IconSwitchProps } from './icon-switch/icon-switch' export { Info } from './info/info' export { InfoCard,