diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx index e52684555d9..6768c2e0e87 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx @@ -28,6 +28,8 @@ interface SelectorComboboxProps { onOptionChange?: (value: string) => void allowSearch?: boolean missingOptionLabel?: string + /** When true, store an array of ids and render removable chips (e.g. channel filter). */ + multiSelect?: boolean } export function SelectorCombobox({ @@ -43,17 +45,24 @@ export function SelectorCombobox({ onOptionChange, allowSearch = true, missingOptionLabel, + multiSelect = false, }: SelectorComboboxProps) { const activeSearchTarget = useActiveSearchTarget() - const [storeValueRaw, setStoreValue] = useSubBlockValue( + const [storeValueRaw, setStoreValue] = useSubBlockValue( blockId, subBlock.id ) - const storeValue = storeValueRaw ?? undefined - const previewedValue = previewValue ?? undefined - const activeValue: string | undefined = isPreview ? previewedValue : storeValue + const storeValue = typeof storeValueRaw === 'string' ? storeValueRaw : undefined + const previewedValue = typeof previewValue === 'string' ? previewValue : undefined + // Single-select active value; undefined in multi mode so detail/label hooks no-op. + const activeValue: string | undefined = multiSelect + ? undefined + : isPreview + ? previewedValue + : storeValue const [searchTerm, setSearchTerm] = useState('') const [isEditing, setIsEditing] = useState(false) + const [multiInput, setMultiInput] = useState('') const { data: options = [], isLoading, @@ -127,6 +136,30 @@ export function SelectorCombobox({ [setStoreValue, onOptionChange, readOnly, disabled] ) + const selectedValues = useMemo(() => { + if (!multiSelect) return [] + const source = isPreview ? previewValue : storeValueRaw + if (Array.isArray(source)) return source.map(String) + if (typeof source === 'string' && source.length > 0) { + return source + .split(',') + .map((v) => v.trim()) + .filter(Boolean) + } + return [] + }, [multiSelect, isPreview, previewValue, storeValueRaw]) + + const handleMultiChange = useCallback( + (values: string[]) => { + if (readOnly || disabled) return + setStoreValue(values) + // Reset the search box so the next channel is picked from the full list. + setMultiInput('') + setSearchTerm('') + }, + [setStoreValue, readOnly, disabled] + ) + const showClearButton = Boolean(activeValue) && !disabled && !readOnly const displayValue = allowSearch ? inputValue : selectedLabel const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ @@ -137,6 +170,54 @@ export function SelectorCombobox({ label: displayValue, }) + if (multiSelect) { + return ( +
+ { + setMultiInput(newValue) + if (allowSearch) setSearchTerm(newValue) + }} + placeholder={placeholder || subBlock.placeholder || 'Select channels'} + disabled={disabled || readOnly} + editable={allowSearch} + filterOptions={allowSearch} + isLoading={isLoading} + error={error instanceof Error ? error.message : null} + /> + {selectedValues.length > 0 && ( +
+ {selectedValues.map((id) => ( +
+ + {optionMap.get(id)?.label ?? id} + + +
+ ))} +
+ )} +
+ ) + } + return (
{ if (!isPreview) { collaborativeSetSubblockValue(blockId, subBlock.id, value) diff --git a/packages/emcn/src/components/wizard/wizard.tsx b/packages/emcn/src/components/wizard/wizard.tsx index ff533c64e3b..88417d6b7cb 100644 --- a/packages/emcn/src/components/wizard/wizard.tsx +++ b/packages/emcn/src/components/wizard/wizard.tsx @@ -1,23 +1,21 @@ 'use client' import * as React from 'react' -import { Button } from '../button/button' +import { cn } from '../../lib/cn' import { - Modal, - ModalBody, - ModalContent, - ModalDescription, - ModalFooter, - ModalHeader, - type ModalSize, -} from '../modal/modal' + ChipModal, + ChipModalBody, + ChipModalFooter, + ChipModalHeader, +} from '../chip-modal/chip-modal' +import { ModalDescription, type ModalSize } from '../modal/modal' /** * A multi-step modal wizard primitive. * * @remarks - * Wraps the emcn Modal with a shared Back / Next / Done footer and declarative - * `Wizard.Step` children. Step state is controlled + * Wraps the emcn ChipModal with a shared Back / Next / Done footer and + * declarative `Wizard.Step` children. Step state is controlled * from the outside so the consumer can hydrate from persisted state, reset * on close, or jump around imperatively. * @@ -160,42 +158,39 @@ const WizardRoot: React.FC = ({ if (total === 0) return null return ( - - - - {title ? ( - - {Icon ? : null} - {title} - - ) : ( - activeStep?.props.title - )} - - - - - {description ?? 'Multi-step wizard'} - - {activeStep} - - - - - {isLast ? ( - - ) : ( - - )} - - - + div]` variants stretch ChipModal's inner content column so the + // body fills the fixed height instead of leaving a gap; only applied when a + // height is set, so other ChipModal consumers are untouched. + className={height ? cn(height, '[&>div]:min-h-0 [&>div]:flex-1') : undefined} + > + onOpenChange(false)}> + {title ?? activeStep?.props.title} + + + + + {description ?? 'Multi-step wizard'} + + {activeStep} + + + onOpenChange(false)} + hideCancel + primaryAdjacentAction={{ label: backLabel, onClick: handleBack, disabled: clamped === 0 }} + primaryAction={ + isLast + ? { label: doneLabel, onClick: handleDone } + : { label: nextLabel, onClick: handleNext, disabled: !canAdvance } + } + /> + ) }