Skip to content

Commit b6d08fb

Browse files
authored
fix(combobox): show selected values in multi-select trigger label (#4721)
* fix(combobox): show selected values in multi-select trigger label The collapsed trigger was reading only `selectedOption` (the single-value path) and falling back to the placeholder when nothing matched, so a multi-select dropdown with 1+ checked items still rendered "Select one or more channels" instead of the actual selections. Added `multiSelectLabel` derived from `multiSelectValues`: - 1 value → that label - 2 values → "A, B" - 3+ → "A, B +N" Trigger now prefers `multiSelectLabel` when present and falls back to the single-select label / placeholder otherwise. Muted-text color also flips off when multi has any selection. * chore(kb-connectors): strip redundant field-level descriptions Removed 41 inline `description:` lines from configFields across 16 connectors (Slack, MS Teams, GCal, Gmail, Notion, Linear-adjacent, Discord, Dropbox, Evernote, Fireflies, Google Sheets, Intercom, Obsidian, Outlook, Reddit, ServiceNow, WordPress, Zendesk). They mostly restated the field title (e.g. "Channels to sync messages from" under a "Channels" label) and cluttered the add/edit modal. Field titles + placeholders already communicate intent. Connector-level `description` (used in the connector picker grid) is unchanged. * test(leader-lock): use fake timers to deterministically test follower polling The "follower does a final read after timeout" test (and the "follower returns null after timeout" test) relied on real-clock `setTimeout` and `Date.now()` with very tight bounds (pollIntervalMs=5, maxWaitMs=9). Any CI scheduler jitter of >4ms would cause the second in-loop poll to be skipped, the polls counter to end at 2 instead of 3, and the assertion `expect(result).toBe('late-leader')` to fail. Switched both tests to `vi.useFakeTimers()` so the schedule is driven by mocked time advanced via `vi.advanceTimersByTimeAsync`. The intent is unchanged — verify that the in-loop deadline triggers exactly one post-deadline last-chance call to `onFollower` — but the assertions no longer depend on wall-clock timing. Verified across 5 sequential runs with zero flakes. * improvement(kb-connectors): restore field descriptions as info-icon tooltips Restores the 41 field-level `description` lines stripped in fc64421, but instead of rendering them as inline muted-text paragraphs they're shown via a small Info icon next to each field title. Hovering or focusing the icon reveals the description in the existing emcn Tooltip. Keeps the modal layout tight while preserving the per-field guidance. Used <button type="button"> as the tooltip trigger (Radix asChild) rather than <span tabIndex={0}> to satisfy a11y/noNoninteractiveTabindex.
1 parent 1631b36 commit b6d08fb

4 files changed

Lines changed: 109 additions & 44 deletions

File tree

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useMemo, useState } from 'react'
4-
import { ArrowLeft, ArrowLeftRight, Plus, Search } from 'lucide-react'
4+
import { ArrowLeft, ArrowLeftRight, Info, Plus, Search } from 'lucide-react'
55
import { useParams } from 'next/navigation'
66
import {
77
Button,
@@ -347,12 +347,28 @@ export function AddConnectorModal({
347347
return (
348348
<div key={field.id} className='flex flex-col gap-2'>
349349
<div className='flex items-center justify-between'>
350-
<Label>
351-
{field.title}
352-
{field.required && (
353-
<span className='ml-0.5 text-[var(--text-error)]'>*</span>
350+
<div className='flex items-center gap-1'>
351+
<Label>
352+
{field.title}
353+
{field.required && (
354+
<span className='ml-0.5 text-[var(--text-error)]'>*</span>
355+
)}
356+
</Label>
357+
{field.description && (
358+
<Tooltip.Root>
359+
<Tooltip.Trigger asChild>
360+
<button
361+
type='button'
362+
className='flex size-[14px] cursor-help items-center justify-center text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-secondary)]'
363+
aria-label={`About ${field.title}`}
364+
>
365+
<Info className='size-[12px]' />
366+
</button>
367+
</Tooltip.Trigger>
368+
<Tooltip.Content side='top'>{field.description}</Tooltip.Content>
369+
</Tooltip.Root>
354370
)}
355-
</Label>
371+
</div>
356372
{hasCanonicalPair && canonicalId && (
357373
<Tooltip.Root>
358374
<Tooltip.Trigger asChild>
@@ -372,9 +388,6 @@ export function AddConnectorModal({
372388
</Tooltip.Root>
373389
)}
374390
</div>
375-
{field.description && (
376-
<p className='text-[var(--text-muted)] text-xs'>{field.description}</p>
377-
)}
378391
{field.type === 'selector' && field.selectorKey ? (
379392
<ConnectorSelectorField
380393
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useMemo, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5-
import { ArrowLeftRight, ExternalLink, RotateCcw } from 'lucide-react'
5+
import { ArrowLeftRight, ExternalLink, Info, RotateCcw } from 'lucide-react'
66
import {
77
Button,
88
ButtonGroup,
@@ -385,10 +385,26 @@ function SettingsTab({
385385
return (
386386
<div key={field.id} className='flex flex-col gap-2'>
387387
<div className='flex items-center justify-between'>
388-
<Label>
389-
{field.title}
390-
{field.required && <span className='ml-0.5 text-[var(--text-error)]'>*</span>}
391-
</Label>
388+
<div className='flex items-center gap-1'>
389+
<Label>
390+
{field.title}
391+
{field.required && <span className='ml-0.5 text-[var(--text-error)]'>*</span>}
392+
</Label>
393+
{field.description && (
394+
<Tooltip.Root>
395+
<Tooltip.Trigger asChild>
396+
<button
397+
type='button'
398+
className='flex size-[14px] cursor-help items-center justify-center text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-secondary)]'
399+
aria-label={`About ${field.title}`}
400+
>
401+
<Info className='size-[12px]' />
402+
</button>
403+
</Tooltip.Trigger>
404+
<Tooltip.Content side='top'>{field.description}</Tooltip.Content>
405+
</Tooltip.Root>
406+
)}
407+
</div>
392408
{hasCanonicalPair && canonicalId && (
393409
<Tooltip.Root>
394410
<Tooltip.Trigger asChild>
@@ -406,9 +422,6 @@ function SettingsTab({
406422
</Tooltip.Root>
407423
)}
408424
</div>
409-
{field.description && (
410-
<p className='text-[var(--text-muted)] text-xs'>{field.description}</p>
411-
)}
412425
{field.type === 'selector' && field.selectorKey ? (
413426
<ConnectorSelectorField
414427
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}

apps/sim/components/emcn/components/combobox/combobox.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,22 @@ const Combobox = memo(
214214
[allOptions, effectiveSelectedValue]
215215
)
216216

217+
/**
218+
* Label rendered in the collapsed trigger for multi-select mode.
219+
* Shows the single label when one value is picked, comma-joined labels
220+
* for two, or "first, second +N" when more are selected. Falls back to
221+
* the raw value if an option for it hasn't loaded yet.
222+
*/
223+
const multiSelectLabel = useMemo(() => {
224+
if (!multiSelect || !multiSelectValues || multiSelectValues.length === 0) return null
225+
const labelFor = (v: string) => allOptions.find((opt) => opt.value === v)?.label ?? v
226+
if (multiSelectValues.length === 1) return labelFor(multiSelectValues[0])
227+
if (multiSelectValues.length === 2) {
228+
return `${labelFor(multiSelectValues[0])}, ${labelFor(multiSelectValues[1])}`
229+
}
230+
return `${labelFor(multiSelectValues[0])}, ${labelFor(multiSelectValues[1])} +${multiSelectValues.length - 2}`
231+
}, [multiSelect, multiSelectValues, allOptions])
232+
217233
/**
218234
* Filter options based on current value or search query
219235
*/
@@ -590,11 +606,11 @@ const Combobox = memo(
590606
<span
591607
className={cn(
592608
'flex-1 truncate',
593-
!selectedOption && 'text-[var(--text-muted)]',
609+
!selectedOption && !multiSelectLabel && 'text-[var(--text-muted)]',
594610
overlayContent && 'text-transparent'
595611
)}
596612
>
597-
{selectedOption ? selectedOption.label : placeholder}
613+
{multiSelectLabel ?? (selectedOption ? selectedOption.label : placeholder)}
598614
</span>
599615
<ChevronDown
600616
className={cn(

apps/sim/lib/concurrency/__tests__/leader-lock.test.ts

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -120,39 +120,62 @@ describe('withLeaderLock', () => {
120120
it('follower does a final read after timeout to catch a just-finished leader', async () => {
121121
redisConfigMockFns.mockAcquireLock.mockResolvedValueOnce(false)
122122

123-
// pollInterval=5, maxWait=9 → loop exits after 2 in-loop polls (T+5, T+10);
124-
// the third call (polls=3) is the post-deadline last-chance read.
125-
let polls = 0
126-
const onFollower = vi.fn(async () => {
127-
polls += 1
128-
if (polls <= 2) return null
129-
return 'late-leader'
130-
})
123+
/**
124+
* The intent: after the in-loop poll deadline is reached, the follower
125+
* does exactly one more (last-chance) `onFollower` call to catch a leader
126+
* that finished between the previous poll and the timeout. Using fake
127+
* timers makes the timing deterministic — pollInterval=10 and maxWait=15
128+
* cause two in-loop polls (T+10, T+20) and one last-chance read (T+20),
129+
* but the schedule is driven by mocked time, not the CI wall clock.
130+
*/
131+
vi.useFakeTimers()
132+
try {
133+
let polls = 0
134+
const onFollower = vi.fn(async () => {
135+
polls += 1
136+
if (polls <= 2) return null
137+
return 'late-leader'
138+
})
131139

132-
const result = await withLeaderLock<string>({
133-
key: 'k',
134-
pollIntervalMs: 5,
135-
maxWaitMs: 9,
136-
onLeader: async () => 'should-not-run',
137-
onFollower,
138-
})
140+
const promise = withLeaderLock<string>({
141+
key: 'k',
142+
pollIntervalMs: 10,
143+
maxWaitMs: 15,
144+
onLeader: async () => 'should-not-run',
145+
onFollower,
146+
})
147+
148+
await vi.advanceTimersByTimeAsync(30)
149+
const result = await promise
139150

140-
expect(result).toBe('late-leader')
141-
expect(onFollower).toHaveBeenCalledTimes(3)
151+
expect(result).toBe('late-leader')
152+
expect(onFollower).toHaveBeenCalledTimes(3)
153+
} finally {
154+
vi.useRealTimers()
155+
}
142156
})
143157

144158
it('follower returns null after timeout', async () => {
145159
redisConfigMockFns.mockAcquireLock.mockResolvedValueOnce(false)
146160

147-
const result = await withLeaderLock<string>({
148-
key: 'k',
149-
pollIntervalMs: 5,
150-
maxWaitMs: 20,
151-
onLeader: async () => 'should-not-run',
152-
onFollower: async () => null,
153-
})
161+
vi.useFakeTimers()
162+
try {
163+
const onFollower = vi.fn(async () => null)
164+
const promise = withLeaderLock<string>({
165+
key: 'k',
166+
pollIntervalMs: 10,
167+
maxWaitMs: 25,
168+
onLeader: async () => 'should-not-run',
169+
onFollower,
170+
})
171+
172+
await vi.advanceTimersByTimeAsync(50)
173+
const result = await promise
154174

155-
expect(result).toBeNull()
175+
expect(result).toBeNull()
176+
} finally {
177+
vi.useRealTimers()
178+
}
156179
})
157180

158181
it('only one of N concurrent callers acquires the lock', async () => {

0 commit comments

Comments
 (0)