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 3e088bbdd0f..3ad9ec9c8c4 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 { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors' import type { ResourceScope } from '@/lib/core/resource-scope' import type { Credential } from '@/lib/oauth/types' @@ -15,6 +15,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 { scope?: ResourceScope accessMode?: ConnectorAccessMode @@ -90,54 +95,43 @@ export function ConnectorConfigFields({ * Cancelling the click's default action keeps label clicks * inert without affecting the buttons' own handlers. */ - event.preventDefault()} - > - - - {title} - {isConnectorFieldRequired(field, connectorConfig, accessMode) && ( - * - )} - - {description && ( - - - - - {description} - + event.preventDefault()}> + + {title} + {isConnectorFieldRequired(field, connectorConfig, accessMode) && ( + * )} - {hasCanonicalPair && canonicalId && ( + {description && ( - - {field.mode === 'basic' ? 'Switch to manual input' : 'Switch to selector'} - + {description} )} } + titleActions={ + hasCanonicalPair && canonicalId ? ( + onToggleCanonicalMode(canonicalId)} + disabled={disabled} + showTooltips + aria-label={`${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/icon-switch/icon-switch.test.tsx b/packages/emcn/src/components/icon-switch/icon-switch.test.tsx new file mode 100644 index 00000000000..ad46477e766 --- /dev/null +++ b/packages/emcn/src/components/icon-switch/icon-switch.test.tsx @@ -0,0 +1,138 @@ +/** @vitest-environment jsdom */ +import { act, useState } from 'react' +import { ChipModalField, IconSwitch } from '@sim/emcn' +import { Code, List } from '@sim/emcn/icons' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const OPTIONS = [ + { value: 'selector', label: 'Selector', icon: List }, + { value: 'variable', label: 'Variable', icon: Code }, +] as const + +interface HarnessProps { + disabled?: boolean + showTooltips?: boolean + inModalField?: boolean + onValueChange: (value: string) => void +} + +function Harness({ disabled, showTooltips, inModalField, onValueChange }: HarnessProps) { + const [value, setValue] = useState('selector') + const toggle = ( + { + setValue(nextValue) + onValueChange(nextValue) + }} + disabled={disabled} + showTooltips={showTooltips} + aria-label='Input mode' + /> + ) + return inModalField ? ( + + + + ) : ( + toggle + ) +} + +let root: Root | null = null +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() + root = null + vi.useRealTimers() +}) + +function mount(props: HarnessProps) { + act(() => root?.render()) + return { + inputs: [...container.querySelectorAll('input[type="radio"]')], + labels: [...container.querySelectorAll('[role="radiogroup"] label')], + } +} + +describe('IconSwitch', () => { + it('changes mode beside a modal field title without making title clicks change mode', () => { + const onValueChange = vi.fn() + const { inputs, labels } = mount({ inModalField: true, onValueChange }) + const title = [...container.querySelectorAll('label')].find( + (label) => label.textContent === 'Folder' + ) + expect(title).toBeDefined() + expect(container.querySelector('[role="radiogroup"]')?.closest('label')).toBeNull() + + act(() => labels[1].click()) + expect(inputs.map((input) => input.checked)).toEqual([false, true]) + expect(onValueChange).toHaveBeenLastCalledWith('variable') + + act(() => title?.click()) + expect(onValueChange).toHaveBeenCalledTimes(1) + expect(inputs.map((input) => input.checked)).toEqual([false, true]) + }) + + it('selects either mode without toggling an already selected choice', () => { + const onValueChange = vi.fn() + const { inputs, labels } = mount({ onValueChange }) + + act(() => labels[1].click()) + expect(inputs.map((input) => input.checked)).toEqual([false, true]) + expect(onValueChange).toHaveBeenLastCalledWith('variable') + + act(() => labels[1].click()) + expect(onValueChange).toHaveBeenCalledTimes(1) + + act(() => labels[0].click()) + expect(inputs.map((input) => input.checked)).toEqual([true, false]) + expect(onValueChange).toHaveBeenLastCalledWith('selector') + }) + + it('prevents selection changes while disabled', () => { + const onValueChange = vi.fn() + const { inputs, labels } = mount({ disabled: true, onValueChange }) + + act(() => labels[1].click()) + expect(onValueChange).not.toHaveBeenCalled() + expect(inputs.every((input) => input.disabled)).toBe(true) + expect(inputs.map((input) => input.checked)).toEqual([true, false]) + }) + + it('shows each option label in its hover tooltip', () => { + const { inputs } = mount({ showTooltips: true, onValueChange: vi.fn() }) + + for (const [index, option] of OPTIONS.entries()) { + act(() => { + inputs[index].dispatchEvent( + new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 }) + ) + }) + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(option.label) + act(() => inputs[index].dispatchEvent(new MouseEvent('pointerout', { bubbles: true }))) + } + }) + + it('shows a tooltip on keyboard focus without changing selection', () => { + const onValueChange = vi.fn() + const { inputs } = mount({ showTooltips: true, onValueChange }) + + act(() => inputs[1].focus()) + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('Variable') + expect(onValueChange).not.toHaveBeenCalled() + expect(inputs.map((input) => input.checked)).toEqual([true, false]) + }) +}) diff --git a/packages/emcn/src/components/icon-switch/icon-switch.tsx b/packages/emcn/src/components/icon-switch/icon-switch.tsx new file mode 100644 index 00000000000..8b16fc4068f --- /dev/null +++ b/packages/emcn/src/components/icon-switch/icon-switch.tsx @@ -0,0 +1,97 @@ +'use client' + +import { type ComponentType, useId } from 'react' +import { cn } from '../../lib/cn' +import { Tooltip } from '../tooltip/tooltip' + +export interface IconSwitchOption { + value: T + label: string + icon: ComponentType<{ className?: string }> +} + +export interface IconSwitchProps { + options: readonly [IconSwitchOption, IconSwitchOption] + value: T + onValueChange: (value: T) => void + disabled?: boolean + showTooltips?: boolean + 'aria-label': string + className?: string +} + +/** + * Two square icon choices inside a compact frame. Native radios provide mutually + * exclusive selection and keyboard navigation; optional tooltips use each label. + * + * @example + * + */ +export function IconSwitch({ + options, + value, + onValueChange, + disabled = false, + showTooltips = false, + 'aria-label': ariaLabel, + className, +}: IconSwitchProps) { + const groupName = useId() + + return ( +
+ {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 20e11b11f4c..974a49b847b 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -140,6 +140,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,