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..9a8682ba7ed --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.test.tsx @@ -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 }) => ( + {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) + }) +}) 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 07ff925b5a1..8e629578aef 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 type { SelectorKey } from '@/lib/selectors/manifest' import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import type { @@ -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 @@ -68,50 +73,41 @@ export function ConnectorConfigFields({ * Cancelling the click's default action keeps label clicks * inert without affecting the buttons' own handlers. */ - 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 ? ( 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/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index 921e0c15285..1eee07d2002 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,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' 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 ab93a862a07..173f13bd845 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 @@ -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, @@ -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 */ @@ -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 ( - - - - - -

{label}

-
-
+ + setFileFieldModes((prev) => ({ ...prev, [field.id]: nextMode })) + } + disabled={isReadOnly} + showTooltips + aria-label='File input mode' + className='-my-1' + /> ) } 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 5301bf26a18..50ff8d0a41c 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,14 +6,15 @@ import { type ComboboxOption, cn, handleKeyboardActivation, + IconSwitch, Input, Label, Textarea, - 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 { checkTagTrigger, @@ -62,6 +63,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. @@ -480,38 +486,20 @@ 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/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 2fd31809eac..b0e22a32d98 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -1,17 +1,11 @@ import { type JSX, type MouseEvent, memo, useCallback, useMemo, useRef, useState } from 'react' import { Button, cn, Input, 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 { isEqual } from 'es-toolkit' import { useParams } from 'next/navigation' import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants' import { + CanonicalModeToggle, CheckboxList, Code, ComboBox, @@ -374,37 +368,11 @@ const renderLabel = ( )} {showCanonicalToggle && ( - - - - - -

- {canonicalToggle?.mode === 'advanced' - ? 'Switch to selector' - : 'Switch to manual ID'} -

-
-
+ )} diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx index aec4d78d119..bc2914506b4 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx @@ -492,6 +492,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. @@ -743,13 +745,23 @@ function ChipModalField(props: ChipModalFieldProps) { const id = React.useId() 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)} {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.sm)-1px-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 039102ac8c2..5455a469bf5 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -132,6 +132,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,