Skip to content

Commit 63216f9

Browse files
committed
fix(connectors): keep resolved option labels and survive a half-failed key lookup
Addresses three review findings. A key lookup failed entirely when either status leg errored, discarding a match the other leg had found. Only a total failure is fatal now. Resolved option labels are remembered for the lifetime of the field. Both lookups key on values that change — the search box clears on select and close, and a multi-select field resolves no id at all — so the label for a just-picked option vanished a debounce later and the trigger fell back to a raw id. This covers the multi-select case, which is what the Confluence space field uses. A failed exact-value lookup now reads differently from a failed list load, rather than being reported as "still loading" or "none found".
1 parent e0d2a87 commit 63216f9

2 files changed

Lines changed: 57 additions & 30 deletions

File tree

apps/sim/app/api/tools/confluence/selector-spaces/route.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,14 +167,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
167167
new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT), status: 'archived' })
168168
),
169169
])
170-
if (!current.ok) return current.response
171-
if (!archived.ok) return archived.response
170+
// Only a total failure is fatal: one leg erroring must not discard a match
171+
// the other leg found.
172+
if (!current.ok && !archived.ok) return current.response
172173

173174
// A single resolution, never a page in a drained stream, so no cursor.
174175
return NextResponse.json({
175176
spaces: [
176-
...toSpaces(current.data.results, 'current'),
177-
...toSpaces(archived.data.results, 'archived'),
177+
...(current.ok ? toSpaces(current.data.results, 'current') : []),
178+
...(archived.ok ? toSpaces(archived.data.results, 'archived') : []),
178179
],
179180
nextCursor: undefined,
180181
})

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx

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

3-
import { useMemo, useState } from 'react'
3+
import { useEffect, useMemo, useState } from 'react'
44
import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn'
55
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
66
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
@@ -10,7 +10,7 @@ import type {
1010
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
1111
import { getDependsOnFields } from '@/blocks/utils'
1212
import type { ConnectorConfigField } from '@/connectors/types'
13-
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
13+
import type { SelectorContext, SelectorKey, SelectorOption } from '@/hooks/selectors/types'
1414
import { useSelectorOptionDetail, useSelectorOptions } from '@/hooks/selectors/use-selector-query'
1515
import { useDebounce } from '@/hooks/use-debounce'
1616

@@ -77,54 +77,72 @@ export function ConnectorSelectorField({
7777
enabled: isEnabled,
7878
})
7979

80-
const emptyMessage = getEmptyMessage(field.title.toLowerCase(), {
81-
error,
82-
hasMore,
83-
isFetchingMore,
84-
truncated,
85-
})
86-
8780
/**
8881
* The option list fills by draining pages in the background and the combobox
8982
* filters it client-side, so an option is only findable once its page has
9083
* arrived. Resolving the typed value directly makes an exact id/key selectable
9184
* immediately, independent of drain progress. Debounced so typing does not
92-
* issue a request per keystroke; selectors without a `fetchById` simply
93-
* resolve nothing.
85+
* issue a request per keystroke; selectors without a `fetchById` resolve nothing.
9486
*/
9587
const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS)
96-
const { data: searchedOption } = useSelectorOptionDetail(field.selectorKey, {
88+
const { data: searchedOption, error: searchError } = useSelectorOptionDetail(field.selectorKey, {
9789
context,
9890
detailId: isEnabled && debouncedSearch.length > 0 ? debouncedSearch : undefined,
9991
})
10092

10193
/**
102-
* Resolve the *selected* value too, not just the typed one. Selecting resets the
103-
* search box, which drops `searchedOption` a debounce later, and the drain may
104-
* not reach that option's page for tens of seconds — never, when truncated — so
105-
* the trigger would fall back to the placeholder for a value just picked.
106-
* Mirrors `SelectorCombobox` in the workflow editor. Multi-select is not covered:
107-
* resolving N ids needs N hooks, so a multi value beyond the drain still renders
108-
* as its raw id.
94+
* Resolve the *selected* value too, not just the typed one, so the trigger does
95+
* not fall back to a raw id for something just picked. Single-select only:
96+
* resolving N ids would need N hooks, so multi-select relies on the remembered
97+
* options below.
10998
*/
11099
const singleValue = Array.isArray(value) ? value[0] : value
111100
const { data: selectedOption } = useSelectorOptionDetail(field.selectorKey, {
112101
context,
113102
detailId: !isMulti && isEnabled && singleValue ? singleValue : undefined,
114103
})
115104

105+
const emptyMessage = getEmptyMessage(field.title.toLowerCase(), {
106+
error,
107+
lookupFailed: Boolean(searchError),
108+
hasMore,
109+
isFetchingMore,
110+
truncated,
111+
})
112+
113+
/**
114+
* Resolved options are remembered for the lifetime of the field. Both lookups are
115+
* keyed on values that change — the search box clears on select and close, and a
116+
* multi-select field resolves no id at all (that would need one hook per id) — so
117+
* reading them directly would drop a label moments after it appeared, leaving the
118+
* trigger showing a raw id for something the user just picked.
119+
*/
120+
const [resolvedOptions, setResolvedOptions] = useState<Record<string, string>>({})
121+
useEffect(() => {
122+
const found = [searchedOption, selectedOption].filter(Boolean) as SelectorOption[]
123+
if (found.length === 0) return
124+
setResolvedOptions((prev) => {
125+
let next = prev
126+
for (const option of found) {
127+
if (next[option.id] === option.label) continue
128+
if (next === prev) next = { ...prev }
129+
next[option.id] = option.label
130+
}
131+
return next
132+
})
133+
}, [searchedOption, selectedOption])
134+
116135
const comboboxOptions = useMemo<ComboboxOption[]>(() => {
117136
const base = options.map((opt) => ({ label: opt.label, value: opt.id }))
118137
const seen = new Set(base.map((opt) => opt.value))
119138
const extras: ComboboxOption[] = []
120-
for (const extra of [searchedOption, selectedOption]) {
121-
if (extra && !seen.has(extra.id)) {
122-
seen.add(extra.id)
123-
extras.push({ label: extra.label, value: extra.id })
124-
}
139+
for (const [id, label] of Object.entries(resolvedOptions)) {
140+
if (seen.has(id)) continue
141+
seen.add(id)
142+
extras.push({ label, value: id })
125143
}
126144
return extras.length > 0 ? [...extras, ...base] : base
127-
}, [options, searchedOption, selectedOption])
145+
}, [options, resolvedOptions])
128146

129147
if (isLoading && isEnabled) {
130148
return (
@@ -190,11 +208,19 @@ export function ConnectorSelectorField({
190208
*/
191209
function getEmptyMessage(
192210
noun: string,
193-
state: { error: Error | null; hasMore: boolean; isFetchingMore: boolean; truncated: boolean }
211+
state: {
212+
error: Error | null
213+
lookupFailed: boolean
214+
hasMore: boolean
215+
isFetchingMore: boolean
216+
truncated: boolean
217+
}
194218
): string {
195219
// `field.title` is singular on some connectors ("Base") and plural on others
196220
// ("Spaces"), so only the settled message puts the noun behind a quantifier.
197221
if (state.error) return 'No match — the list failed to load. Try reopening'
222+
// Distinct from the list failing: the list is fine, resolving the typed value is not.
223+
if (state.lookupFailed) return 'No match — could not check that exact value'
198224
if (state.hasMore || state.isFetchingMore) return 'No match yet — still loading…'
199225
if (state.truncated) return 'No match — too many to list. Try a more exact term'
200226
return `No ${noun} found`

0 commit comments

Comments
 (0)