Skip to content

Commit 3d779e9

Browse files
committed
feat(connectors): resolve selector options by exact key without waiting on the drain
The connector selector fills by draining pages in the background and filters client-side, so an option is only findable once its page has arrived. On a large Confluence site that is ~38 seconds, during which searching for a real space returned nothing. Resolves the typed value and the selected value directly through the selector's `fetchById`, merging both into the option list, so an exact key is selectable immediately regardless of drain progress. `confluence.spaces.fetchById` now uses the documented v2 `keys` filter instead of scanning only the first page — which never resolved a space sorting beyond it. Adds `onSearchChange` to the shared Combobox so a consumer can observe the search box, held in a ref so its identity stays stable for the handlers that capture it without declaring it. Also fixes a pre-existing hazard in useSelectorOptionDetail: a caller-supplied `enabled` replaced the guard that checks a definition declares `fetchById`, so `queryFn`'s non-null assertion would throw for the ~12 selectors without one.
1 parent 2b313cb commit 3d779e9

6 files changed

Lines changed: 159 additions & 53 deletions

File tree

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

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4545
const parsed = await parseRequest(confluenceSpacesSelectorContract, request, {})
4646
if (!parsed.success) return parsed.response
4747

48-
const { credential, workflowId, domain, cursor } = parsed.data.body
48+
const { credential, workflowId, domain, cursor, spaceKey } = parsed.data.body
4949

5050
if (!credential) {
5151
logger.error('Missing credential in request')
@@ -101,8 +101,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
101101
const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces`
102102
const { status, inner } = parseCursor(cursor)
103103

104-
const params = new URLSearchParams({ limit: String(PAGE_LIMIT), status })
105-
if (inner) params.set('cursor', inner)
104+
/**
105+
* Exact-key lookup: one request, no pagination. The dropdown drains pages in
106+
* the background and filters client-side, so a space only becomes findable
107+
* once its page has arrived — on a large site that is tens of seconds away.
108+
* Resolving a known key directly makes it available immediately. `status` is
109+
* left unset so the key matches whether the space is current or archived.
110+
*/
111+
const params = spaceKey
112+
? new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT) })
113+
: new URLSearchParams({ limit: String(PAGE_LIMIT), status })
114+
if (inner && !spaceKey) params.set('cursor', inner)
106115
const url = `${baseUrl}?${params.toString()}`
107116

108117
const response = await fetch(url, {
@@ -118,12 +127,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
118127
}
119128

120129
const data = await response.json()
121-
const spaces = (data.results || []).map((space: { id: string; name: string; key: string }) => ({
122-
id: space.id,
123-
name: space.name,
124-
key: space.key,
125-
status,
126-
}))
130+
const spaces = (data.results || []).map(
131+
(space: { id: string; name: string; key: string; status?: SpaceStatus }) => ({
132+
id: space.id,
133+
name: space.name,
134+
key: space.key,
135+
// An exact-key lookup is not scoped to one status, so trust the row's own.
136+
status: space.status ?? status,
137+
})
138+
)
127139

128140
let nextInner: string | undefined
129141
const nextLink = data._links?.next as string | undefined
@@ -135,8 +147,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
135147
}
136148
}
137149

150+
// An exact-key lookup is a single resolution, never a page in a drained stream.
138151
let nextCursor: string | undefined
139-
if (nextInner) {
152+
if (spaceKey) {
153+
nextCursor = undefined
154+
} else if (nextInner) {
140155
nextCursor = `${status}:${nextInner}`
141156
} else if (status === 'current') {
142157
nextCursor = 'archived:'

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

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

3-
import { useMemo } from 'react'
3+
import { useMemo, useState } from 'react'
44
import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn'
5+
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
56
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
67
import type {
78
ConfigFieldMap,
@@ -10,7 +11,8 @@ import type {
1011
import { getDependsOnFields } from '@/blocks/utils'
1112
import type { ConnectorConfigField } from '@/connectors/types'
1213
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
13-
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
14+
import { useSelectorOptionDetail, useSelectorOptions } from '@/hooks/selectors/use-selector-query'
15+
import { useDebounce } from '@/hooks/use-debounce'
1416

1517
interface ConnectorSelectorFieldProps {
1618
field: ConnectorConfigField & { selectorKey: SelectorKey }
@@ -34,6 +36,7 @@ export function ConnectorSelectorField({
3436
disabled,
3537
}: ConnectorSelectorFieldProps) {
3638
const isMulti = Boolean(field.multi)
39+
const [searchTerm, setSearchTerm] = useState('')
3740

3841
const context = useMemo<SelectorContext>(() => {
3942
const ctx: SelectorContext = {}
@@ -74,33 +77,54 @@ export function ConnectorSelectorField({
7477
enabled: isEnabled,
7578
})
7679

80+
const emptyMessage = getEmptyMessage(field.title.toLowerCase(), {
81+
error,
82+
hasMore,
83+
isFetchingMore,
84+
truncated,
85+
})
86+
7787
/**
78-
* Shown when the combobox has nothing to display. `isLoading` renders a spinner
79-
* instead until the first page lands, so by the time this is visible the list has
80-
* loaded at least once and is empty because the user's search matched none of the
81-
* options loaded *so far* — the wording is phrased for that case.
82-
*
83-
* Paginated selectors drain in the background and filter client-side, so an
84-
* option that has not drained yet is genuinely absent. A flat "none found" reads
85-
* as "it does not exist" and sends users off to enter the value by hand, which is
86-
* exactly what happened with a Confluence space on a site whose drain runs for
87-
* ~38s. Each branch instead explains why the list may still be incomplete.
88-
*
89-
* `error` is checked first: the drain halts on a failed page but leaves `hasMore`
90-
* set, so a failure would otherwise claim to be loading forever.
88+
* The option list fills by draining pages in the background and the combobox
89+
* filters it client-side, so an option is only findable once its page has
90+
* arrived. Resolving the typed value directly makes an exact id/key selectable
91+
* immediately, independent of drain progress. Debounced so typing does not
92+
* issue a request per keystroke; selectors without a `fetchById` simply
93+
* resolve nothing.
9194
*/
92-
const emptyMessage = useMemo(() => {
93-
const noun = field.title.toLowerCase()
94-
if (error) return `No match — could not load all ${noun}. Enter the value directly`
95-
if (hasMore || isFetchingMore) return `No match yet — still loading ${noun}…`
96-
if (truncated) return `No match in the ${noun} loaded — enter the value directly`
97-
return `No ${noun} found`
98-
}, [field.title, error, hasMore, isFetchingMore, truncated])
99-
100-
const comboboxOptions = useMemo<ComboboxOption[]>(
101-
() => options.map((opt) => ({ label: opt.label, value: opt.id })),
102-
[options]
103-
)
95+
const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS)
96+
const { data: searchedOption } = useSelectorOptionDetail(field.selectorKey, {
97+
context,
98+
detailId: isEnabled && debouncedSearch.length > 0 ? debouncedSearch : undefined,
99+
})
100+
101+
/**
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.
109+
*/
110+
const singleValue = Array.isArray(value) ? value[0] : value
111+
const { data: selectedOption } = useSelectorOptionDetail(field.selectorKey, {
112+
context,
113+
detailId: !isMulti && isEnabled && singleValue ? singleValue : undefined,
114+
})
115+
116+
const comboboxOptions = useMemo<ComboboxOption[]>(() => {
117+
const base = options.map((opt) => ({ label: opt.label, value: opt.id }))
118+
const seen = new Set(base.map((opt) => opt.value))
119+
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+
}
125+
}
126+
return extras.length > 0 ? [...extras, ...base] : base
127+
}, [options, searchedOption, selectedOption])
104128

105129
if (isLoading && isEnabled) {
106130
return (
@@ -120,6 +144,7 @@ export function ConnectorSelectorField({
120144
multiSelectValues={multiValues}
121145
onMultiSelectChange={(values) => onChange(values)}
122146
searchable
147+
onSearchChange={setSearchTerm}
123148
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
124149
placeholder={
125150
!credentialId
@@ -134,13 +159,13 @@ export function ConnectorSelectorField({
134159
)
135160
}
136161

137-
const singleValue = Array.isArray(value) ? value[0] : value
138162
return (
139163
<ChipCombobox
140164
options={comboboxOptions}
141165
value={singleValue || undefined}
142166
onChange={(next) => onChange(next)}
143167
searchable
168+
onSearchChange={setSearchTerm}
144169
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
145170
placeholder={
146171
!credentialId
@@ -155,6 +180,26 @@ export function ConnectorSelectorField({
155180
)
156181
}
157182

183+
/**
184+
* Only visible once the first page has landed (`isLoading` renders a spinner
185+
* before that), so "no match" here means no match among the options drained
186+
* *so far* — a flat "none found" would wrongly read as "does not exist".
187+
*
188+
* `error` is checked before `hasMore`: a failed page halts the drain but leaves
189+
* `hasMore` set, which would otherwise claim to be loading forever.
190+
*/
191+
function getEmptyMessage(
192+
noun: string,
193+
state: { error: Error | null; hasMore: boolean; isFetchingMore: boolean; truncated: boolean }
194+
): string {
195+
// `field.title` is singular on some connectors ("Base") and plural on others
196+
// ("Spaces"), so only the settled message puts the noun behind a quantifier.
197+
if (state.error) return 'No match — the list is incomplete. Enter the value directly'
198+
if (state.hasMore || state.isFetchingMore) return 'No match yet — still loading…'
199+
if (state.truncated) return 'No match in what loaded — enter the value directly'
200+
return `No ${noun} found`
201+
}
202+
158203
function resolveDepValue(
159204
depFieldId: string,
160205
configFields: ConnectorConfigField[],

apps/sim/hooks/selectors/providers/confluence/selectors.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ export const confluenceSelectors = {
5050
}
5151
},
5252
/**
53-
* Resolves a single space label. Hits only the first page — the dropdown's
54-
* `fetchPage` stream populates the options cache for spaces beyond page 1,
55-
* and `useSelectorOptionMap` merges them in. Walking all pages here would
56-
* double API load since the stream is already running in parallel.
53+
* Resolves a single space by key in one request via the server's exact-key
54+
* lookup. Previously this fetched the first page and scanned it, so a space
55+
* that sorts beyond page 1 never resolved — on a large site that is most of
56+
* them. Keyed resolution is independent of how far the page drain has run.
5757
*/
5858
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
5959
if (!detailId) return null
@@ -64,6 +64,7 @@ export const confluenceSelectors = {
6464
credential: credentialId,
6565
workflowId: context.workflowId,
6666
domain,
67+
spaceKey: detailId,
6768
},
6869
signal,
6970
})

apps/sim/hooks/selectors/use-selector-query.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ const EMPTY_PAGE: SelectorPage = { items: [], nextCursor: undefined }
5151
*/
5252
const MAX_AUTO_DRAIN_PAGES = 50
5353

54+
/** Fallback freshness for selectors that do not declare their own `staleTime`. */
55+
export const DEFAULT_SELECTOR_STALE_TIME = 30_000
56+
57+
/**
58+
* Single-option resolutions are keyed by an exact id, so they change far less
59+
* often than a list and can stay fresh longer.
60+
*/
61+
export const DEFAULT_SELECTOR_DETAIL_STALE_TIME = 300_000
62+
5463
export function useSelectorOptions(
5564
key: SelectorKey,
5665
args: SelectorHookArgs
@@ -69,7 +78,7 @@ export function useSelectorOptions(
6978
queryFn: ({ signal }) =>
7079
definition.fetchList?.({ ...queryArgs, signal }) ?? Promise.resolve([]),
7180
enabled: !supportsPagination && isEnabled,
72-
staleTime: definition.staleTime ?? 30_000,
81+
staleTime: definition.staleTime ?? DEFAULT_SELECTOR_STALE_TIME,
7382
})
7483

7584
const pagedQuery = useInfiniteQuery<SelectorPage>({
@@ -85,7 +94,7 @@ export function useSelectorOptions(
8594
getNextPageParam: (last) => last.nextCursor,
8695
initialPageParam: undefined as string | undefined,
8796
enabled: supportsPagination && isEnabled,
88-
staleTime: definition.staleTime ?? 30_000,
97+
staleTime: definition.staleTime ?? DEFAULT_SELECTOR_STALE_TIME,
8998
})
9099

91100
const { hasNextPage, isFetchingNextPage, fetchNextPage, isError } = pagedQuery
@@ -175,13 +184,18 @@ export function useSelectorOptionDetail(
175184
? definition.enabled(queryArgs)
176185
: true
177186
: false
178-
const enabled = args.enabled ?? baseEnabled
187+
/**
188+
* A caller's `enabled` narrows `baseEnabled` rather than replacing it: `queryFn`
189+
* asserts `fetchById` is defined, so dropping that guard would throw for every
190+
* selector that declares no `fetchById`.
191+
*/
192+
const enabled = (args.enabled ?? true) && baseEnabled
179193

180194
const query = useQuery<SelectorOption | null>({
181195
queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'],
182196
queryFn: ({ signal }) => definition.fetchById!({ ...queryArgs, signal }),
183197
enabled,
184-
staleTime: definition.staleTime ?? 300_000,
198+
staleTime: definition.staleTime ?? DEFAULT_SELECTOR_DETAIL_STALE_TIME,
185199
})
186200

187201
return query

apps/sim/lib/api/contracts/selectors/confluence.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,12 @@ const defineConfluenceGetContract = <TQuery extends z.ZodType>(path: string, que
366366

367367
export const confluenceSpacesSelectorBodySchema = credentialWorkflowDomainBodySchema.extend({
368368
cursor: optionalString,
369+
/**
370+
* Exact space key to resolve server-side, bypassing pagination. Confluence v2
371+
* `/spaces` supports a `keys` filter, so a known key resolves in one request
372+
* instead of depending on how far the background page drain has progressed.
373+
*/
374+
spaceKey: z.string().min(1).max(255).optional(),
369375
})
370376

371377
export const confluenceSpacesSelectorContract = definePostSelector(

0 commit comments

Comments
 (0)