Skip to content

Commit 0cc6ed6

Browse files
improvement(emcn): multi-select selectors + Wizard on ChipModal (#5614)
* feat(sub-block): support multi-select in channel/user selector fields * improvement(emcn): migrate Wizard primitive to ChipModal * fix(emcn): wizard height sizes whole dialog; restore dialog description
1 parent 1b82b97 commit 0cc6ed6

3 files changed

Lines changed: 128 additions & 51 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ interface SelectorComboboxProps {
2828
onOptionChange?: (value: string) => void
2929
allowSearch?: boolean
3030
missingOptionLabel?: string
31+
/** When true, store an array of ids and render removable chips (e.g. channel filter). */
32+
multiSelect?: boolean
3133
}
3234

3335
export function SelectorCombobox({
@@ -43,17 +45,24 @@ export function SelectorCombobox({
4345
onOptionChange,
4446
allowSearch = true,
4547
missingOptionLabel,
48+
multiSelect = false,
4649
}: SelectorComboboxProps) {
4750
const activeSearchTarget = useActiveSearchTarget()
48-
const [storeValueRaw, setStoreValue] = useSubBlockValue<string | null | undefined>(
51+
const [storeValueRaw, setStoreValue] = useSubBlockValue<string | string[] | null | undefined>(
4952
blockId,
5053
subBlock.id
5154
)
52-
const storeValue = storeValueRaw ?? undefined
53-
const previewedValue = previewValue ?? undefined
54-
const activeValue: string | undefined = isPreview ? previewedValue : storeValue
55+
const storeValue = typeof storeValueRaw === 'string' ? storeValueRaw : undefined
56+
const previewedValue = typeof previewValue === 'string' ? previewValue : undefined
57+
// Single-select active value; undefined in multi mode so detail/label hooks no-op.
58+
const activeValue: string | undefined = multiSelect
59+
? undefined
60+
: isPreview
61+
? previewedValue
62+
: storeValue
5563
const [searchTerm, setSearchTerm] = useState('')
5664
const [isEditing, setIsEditing] = useState(false)
65+
const [multiInput, setMultiInput] = useState('')
5766
const {
5867
data: options = [],
5968
isLoading,
@@ -127,6 +136,30 @@ export function SelectorCombobox({
127136
[setStoreValue, onOptionChange, readOnly, disabled]
128137
)
129138

139+
const selectedValues = useMemo<string[]>(() => {
140+
if (!multiSelect) return []
141+
const source = isPreview ? previewValue : storeValueRaw
142+
if (Array.isArray(source)) return source.map(String)
143+
if (typeof source === 'string' && source.length > 0) {
144+
return source
145+
.split(',')
146+
.map((v) => v.trim())
147+
.filter(Boolean)
148+
}
149+
return []
150+
}, [multiSelect, isPreview, previewValue, storeValueRaw])
151+
152+
const handleMultiChange = useCallback(
153+
(values: string[]) => {
154+
if (readOnly || disabled) return
155+
setStoreValue(values)
156+
// Reset the search box so the next channel is picked from the full list.
157+
setMultiInput('')
158+
setSearchTerm('')
159+
},
160+
[setStoreValue, readOnly, disabled]
161+
)
162+
130163
const showClearButton = Boolean(activeValue) && !disabled && !readOnly
131164
const displayValue = allowSearch ? inputValue : selectedLabel
132165
const workflowSearchHighlight = getWorkflowSearchLabelHighlight({
@@ -137,6 +170,54 @@ export function SelectorCombobox({
137170
label: displayValue,
138171
})
139172

173+
if (multiSelect) {
174+
return (
175+
<div className='w-full'>
176+
<EditableCombobox
177+
options={comboboxOptions}
178+
value={multiInput}
179+
multiSelect
180+
multiSelectValues={selectedValues}
181+
onMultiSelectChange={handleMultiChange}
182+
onChange={(newValue) => {
183+
setMultiInput(newValue)
184+
if (allowSearch) setSearchTerm(newValue)
185+
}}
186+
placeholder={placeholder || subBlock.placeholder || 'Select channels'}
187+
disabled={disabled || readOnly}
188+
editable={allowSearch}
189+
filterOptions={allowSearch}
190+
isLoading={isLoading}
191+
error={error instanceof Error ? error.message : null}
192+
/>
193+
{selectedValues.length > 0 && (
194+
<div className='mt-2 space-y-2'>
195+
{selectedValues.map((id) => (
196+
<div
197+
key={id}
198+
className='flex items-center justify-between gap-2 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] px-2.5 py-[5px]'
199+
>
200+
<span className='block min-w-0 flex-1 truncate text-[var(--text-tertiary)] text-sm'>
201+
{optionMap.get(id)?.label ?? id}
202+
</span>
203+
<Button
204+
type='button'
205+
variant='ghost'
206+
className='h-auto shrink-0 p-0'
207+
disabled={disabled || readOnly}
208+
aria-label={`Remove ${optionMap.get(id)?.label ?? id}`}
209+
onClick={() => handleMultiChange(selectedValues.filter((v) => v !== id))}
210+
>
211+
<X className='size-[14px] text-[var(--text-icon)]' />
212+
</Button>
213+
</div>
214+
))}
215+
</div>
216+
)}
217+
</div>
218+
)
219+
}
220+
140221
return (
141222
<div className='w-full'>
142223
<SubBlockInputController

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export function SelectorInput({
9696
previewValue={previewValue ?? null}
9797
placeholder={subBlock.placeholder || 'Select resource'}
9898
allowSearch={allowSearch}
99+
multiSelect={subBlock.multiSelect}
99100
onOptionChange={(value) => {
100101
if (!isPreview) {
101102
collaborativeSetSubblockValue(blockId, subBlock.id, value)

packages/emcn/src/components/wizard/wizard.tsx

Lines changed: 42 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
'use client'
22

33
import * as React from 'react'
4-
import { Button } from '../button/button'
4+
import { cn } from '../../lib/cn'
55
import {
6-
Modal,
7-
ModalBody,
8-
ModalContent,
9-
ModalDescription,
10-
ModalFooter,
11-
ModalHeader,
12-
type ModalSize,
13-
} from '../modal/modal'
6+
ChipModal,
7+
ChipModalBody,
8+
ChipModalFooter,
9+
ChipModalHeader,
10+
} from '../chip-modal/chip-modal'
11+
import { ModalDescription, type ModalSize } from '../modal/modal'
1412

1513
/**
1614
* A multi-step modal wizard primitive.
1715
*
1816
* @remarks
19-
* Wraps the emcn Modal with a shared Back / Next / Done footer and declarative
20-
* `Wizard.Step` children. Step state is controlled
17+
* Wraps the emcn ChipModal with a shared Back / Next / Done footer and
18+
* declarative `Wizard.Step` children. Step state is controlled
2119
* from the outside so the consumer can hydrate from persisted state, reset
2220
* on close, or jump around imperatively.
2321
*
@@ -160,42 +158,39 @@ const WizardRoot: React.FC<WizardProps> = ({
160158
if (total === 0) return null
161159

162160
return (
163-
<Modal open={open} onOpenChange={onOpenChange}>
164-
<ModalContent size={size} className={height}>
165-
<ModalHeader>
166-
{title ? (
167-
<span className='flex items-center gap-2'>
168-
{Icon ? <Icon className='size-[18px]' /> : null}
169-
{title}
170-
</span>
171-
) : (
172-
activeStep?.props.title
173-
)}
174-
</ModalHeader>
175-
176-
<ModalBody>
177-
<ModalDescription className='sr-only'>
178-
{description ?? 'Multi-step wizard'}
179-
</ModalDescription>
180-
{activeStep}
181-
</ModalBody>
182-
183-
<ModalFooter>
184-
<Button variant='default' onClick={handleBack} disabled={clamped === 0}>
185-
{backLabel}
186-
</Button>
187-
{isLast ? (
188-
<Button variant='primary' onClick={handleDone}>
189-
{doneLabel}
190-
</Button>
191-
) : (
192-
<Button variant='primary' onClick={handleNext} disabled={!canAdvance}>
193-
{nextLabel}
194-
</Button>
195-
)}
196-
</ModalFooter>
197-
</ModalContent>
198-
</Modal>
161+
<ChipModal
162+
open={open}
163+
onOpenChange={onOpenChange}
164+
size={size}
165+
srTitle={title ?? activeStep?.props.title ?? description ?? 'Multi-step wizard'}
166+
// A fixed `height` sizes the whole dialog (matching the legacy Modal). The
167+
// scoped `[&>div]` variants stretch ChipModal's inner content column so the
168+
// body fills the fixed height instead of leaving a gap; only applied when a
169+
// height is set, so other ChipModal consumers are untouched.
170+
className={height ? cn(height, '[&>div]:min-h-0 [&>div]:flex-1') : undefined}
171+
>
172+
<ChipModalHeader icon={title ? (Icon ?? null) : null} onClose={() => onOpenChange(false)}>
173+
{title ?? activeStep?.props.title}
174+
</ChipModalHeader>
175+
176+
<ChipModalBody>
177+
<ModalDescription className='sr-only'>
178+
{description ?? 'Multi-step wizard'}
179+
</ModalDescription>
180+
{activeStep}
181+
</ChipModalBody>
182+
183+
<ChipModalFooter
184+
onCancel={() => onOpenChange(false)}
185+
hideCancel
186+
primaryAdjacentAction={{ label: backLabel, onClick: handleBack, disabled: clamped === 0 }}
187+
primaryAction={
188+
isLast
189+
? { label: doneLabel, onClick: handleDone }
190+
: { label: nextLabel, onClick: handleNext, disabled: !canAdvance }
191+
}
192+
/>
193+
</ChipModal>
199194
)
200195
}
201196

0 commit comments

Comments
 (0)