Skip to content
Merged
106 changes: 84 additions & 22 deletions apps/sim/app/api/tools/confluence/selector-spaces/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@ const PAGE_LIMIT = 250

type SpaceStatus = 'current' | 'archived'

/** A row as Confluence returns it. `status` is not marked required in the v2 schema. */
interface SpaceRow {
id: string
name: string
key: string
status?: SpaceStatus
}

interface SpacesResponse {
results?: SpaceRow[]
_links?: { next?: string }
}

/** A row as this selector emits it, with `status` always resolved. */
interface SelectorSpace {
id: string
name: string
key: string
status: SpaceStatus
}

/**
* Cursor format: `<status>:<innerCursor>`. Empty inner cursor means "first page
* of that status". When current is exhausted we hand back `archived:` so the
Expand All @@ -45,7 +66,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(confluenceSpacesSelectorContract, request, {})
if (!parsed.success) return parsed.response

const { credential, workflowId, domain, cursor } = parsed.data.body
const { credential, workflowId, domain, cursor, spaceKey } = parsed.data.body

if (!credential) {
logger.error('Missing credential in request')
Expand Down Expand Up @@ -101,32 +122,73 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces`
const { status, inner } = parseCursor(cursor)

const params = new URLSearchParams({ limit: String(PAGE_LIMIT), status })
if (inner) params.set('cursor', inner)
const url = `${baseUrl}?${params.toString()}`
const requestSpaces = async (
search: URLSearchParams
): Promise<{ ok: true; data: SpacesResponse } | { ok: false; response: NextResponse }> => {
const response = await fetch(`${baseUrl}?${search.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
})

if (!response.ok) {
const errorText = await response.text()
const message = parseAtlassianErrorMessage(response.status, response.statusText, errorText)
logger.error('Confluence API error response', { error: message, status: response.status })
return { ok: false, response: NextResponse.json({ error: message }, { status: 502 }) }
}

const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
})
return { ok: true, data: await response.json() }
}

if (!response.ok) {
const errorText = await response.text()
const message = parseAtlassianErrorMessage(response.status, response.statusText, errorText)
logger.error('Confluence API error response', { error: message, status: response.status })
return NextResponse.json({ error: message }, { status: 502 })
const toSpaces = (rows: SpaceRow[] | undefined, queried: SpaceStatus): SelectorSpace[] =>
(rows ?? []).map((space) => ({
id: space.id,
name: space.name,
key: space.key,
// Trust the row's own status; fall back to the status this call asked for.
status: space.status ?? queried,
}))

if (spaceKey) {
/**
* Exact-key lookup, bypassing the paged drain the dropdown otherwise depends
* on. Both statuses are queried explicitly rather than omitting `status`: it
* takes a single value on this endpoint (unlike `/pages`, where it is an array
* with a documented `current,archived` default) and `/spaces` documents no
* default, while archived spaces are reachable in the paged path and sync works
* against them. Concurrent because the key is user-typed text, so a miss — which
* dominates while typing — would otherwise pay two round-trips.
*/
const [current, archived] = await Promise.all([
requestSpaces(
new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT), status: 'current' })
),
requestSpaces(
new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT), status: 'archived' })
),
])
// Only a total failure is fatal: one leg erroring must not discard a match
// the other leg found.
if (!current.ok && !archived.ok) return current.response

// A single resolution, never a page in a drained stream, so no cursor.
return NextResponse.json({
spaces: [
...(current.ok ? toSpaces(current.data.results, 'current') : []),
...(archived.ok ? toSpaces(archived.data.results, 'archived') : []),
],
nextCursor: undefined,
})
}

const data = await response.json()
const spaces = (data.results || []).map((space: { id: string; name: string; key: string }) => ({
id: space.id,
name: space.name,
key: space.key,
status,
}))
const params = new URLSearchParams({ limit: String(PAGE_LIMIT), status })
if (inner) params.set('cursor', inner)
const result = await requestSpaces(params)
if (!result.ok) return result.response
const data = result.data

let nextInner: string | undefined
const nextLink = data._links?.next as string | undefined
const nextLink = data._links?.next
if (nextLink) {
try {
nextInner = new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined
Expand All @@ -142,7 +204,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
nextCursor = 'archived:'
}

return NextResponse.json({ spaces, nextCursor })
return NextResponse.json({ spaces: toSpaces(data.results, status), nextCursor })
} catch (error) {
logger.error('Error listing Confluence spaces:', error)
return NextResponse.json(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
'use client'

import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
import type {
ConfigFieldMap,
ConfigFieldValue,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { getDependsOnFields } from '@/blocks/utils'
import type { ConnectorConfigField } from '@/connectors/types'
import { getSelectorDefinition } from '@/hooks/selectors/registry'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
import {
useSelectorOptionDetail,
useSelectorOptionDetails,
useSelectorOptions,
} from '@/hooks/selectors/use-selector-query'
import { useDebounce } from '@/hooks/use-debounce'

interface ConnectorSelectorFieldProps {
field: ConnectorConfigField & { selectorKey: SelectorKey }
Expand All @@ -34,6 +41,7 @@ export function ConnectorSelectorField({
disabled,
}: ConnectorSelectorFieldProps) {
const isMulti = Boolean(field.multi)
const [searchTerm, setSearchTerm] = useState('')

const context = useMemo<SelectorContext>(() => {
const ctx: SelectorContext = {}
Expand Down Expand Up @@ -62,15 +70,68 @@ export function ConnectorSelectorField({
}, [field.dependsOn, sourceConfig, configFields, canonicalModes])

const isEnabled = !disabled && !!credentialId && depsResolved
const { data: options = [], isLoading } = useSelectorOptions(field.selectorKey, {
const {
data: options = [],
isLoading,
hasMore,
isFetchingMore,
truncated,
error,
} = useSelectorOptions(field.selectorKey, {
context,
enabled: isEnabled,
})

const comboboxOptions = useMemo<ComboboxOption[]>(
() => options.map((opt) => ({ label: opt.label, value: opt.id })),
[options]
/**
* Label every selected value, including values restored from saved config that no
* in-session search would have resolved. Queries are keyed on `context`, so a label
* can never outlive the context that produced it, and they share keys with the
* speculative lookup below so an already-resolved id costs no extra request.
*/
const singleValue = Array.isArray(value) ? value[0] : value
const selectedIds = useMemo(
() => (Array.isArray(value) ? value : value ? [value] : []).filter(Boolean),
[value]
)
const selectedOptions = useSelectorOptionDetails(field.selectorKey, {
context,
detailIds: isEnabled ? selectedIds : [],
})

/**
* The option list fills by draining pages in the background and the combobox filters
* it client-side, so an option is only findable once its page has arrived. Where the
* selector's `fetchById` tolerates an unknown id, whatever the user typed is resolved
* directly so an exact key is selectable immediately. Gated on that flag because most
* implementations resolve a record by id, where a partial keystroke is a guaranteed
* failed upstream request rather than an empty result.
*/
const resolvesUnknownIds = Boolean(getSelectorDefinition(field.selectorKey).resolvesUnknownIds)
const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS)
const { data: searchedOption } = useSelectorOptionDetail(field.selectorKey, {
context,
detailId:
resolvesUnknownIds && isEnabled && debouncedSearch.length > 0 ? debouncedSearch : undefined,
})

const emptyMessage = getEmptyMessage(field.title.toLowerCase(), {
error,
hasMore,
isFetchingMore,
truncated,
})

const comboboxOptions = useMemo<ComboboxOption[]>(() => {
const base = options.map((opt) => ({ label: opt.label, value: opt.id }))
const seen = new Set(base.map((opt) => opt.value))
const extras: ComboboxOption[] = []
for (const option of searchedOption ? [...selectedOptions, searchedOption] : selectedOptions) {
if (seen.has(option.id)) continue
seen.add(option.id)
extras.push({ label: option.label, value: option.id })
}
return extras.length > 0 ? [...extras, ...base] : base
}, [options, selectedOptions, searchedOption])

if (isLoading && isEnabled) {
return (
Expand All @@ -88,8 +149,9 @@ export function ConnectorSelectorField({
multiSelect
options={comboboxOptions}
multiSelectValues={multiValues}
onMultiSelectChange={(values) => onChange(values)}
onMultiSelectChange={onChange}
searchable
onSearchChange={setSearchTerm}
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
placeholder={
!credentialId
Expand All @@ -99,18 +161,18 @@ export function ConnectorSelectorField({
: field.placeholder || `Select ${field.title.toLowerCase()}`
}
disabled={disabled || !credentialId || !depsResolved}
emptyMessage={`No ${field.title.toLowerCase()} found`}
emptyMessage={emptyMessage}
Comment thread
waleedlatif1 marked this conversation as resolved.
/>
)
}

const singleValue = Array.isArray(value) ? value[0] : value
return (
<ChipCombobox
options={comboboxOptions}
value={singleValue || undefined}
onChange={(next) => onChange(next)}
onChange={onChange}
searchable
onSearchChange={setSearchTerm}
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
placeholder={
!credentialId
Expand All @@ -120,11 +182,36 @@ export function ConnectorSelectorField({
: field.placeholder || `Select ${field.title.toLowerCase()}`
}
disabled={disabled || !credentialId || !depsResolved}
emptyMessage={`No ${field.title.toLowerCase()} found`}
emptyMessage={emptyMessage}
/>
)
}

/**
* Only visible once the first page has landed (`isLoading` renders a spinner
* before that), so "no match" here means no match among the options drained
* *so far* — a flat "none found" would wrongly read as "does not exist".
*
* `error` is checked before `hasMore`: a failed page halts the drain but leaves
* `hasMore` set, which would otherwise claim to be loading forever.
*/
function getEmptyMessage(
noun: string,
state: {
error: Error | null
hasMore: boolean
isFetchingMore: boolean
truncated: boolean
}
): string {
if (state.error) return 'No match — the list failed to load. Try reopening'
if (state.hasMore || state.isFetchingMore) return 'No match yet — still loading…'
if (state.truncated) return 'No match — too many to list. Try a more exact term'
// `noun` is singular on some connectors ("Base") and plural on others ("Spaces"),
// so only this settled message puts it behind a quantifier.
return `No ${noun} found`
}

function resolveDepValue(
depFieldId: string,
configFields: ConnectorConfigField[],
Expand Down
20 changes: 16 additions & 4 deletions apps/sim/hooks/selectors/providers/confluence/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,12 @@ export const confluenceSelectors = {
nextCursor: data.nextCursor,
}
},
/** The server filters by key and returns nothing for a key that does not exist. */
resolvesUnknownIds: true,
/**
* Resolves a single space label. Hits only the first page — the dropdown's
* `fetchPage` stream populates the options cache for spaces beyond page 1,
* and `useSelectorOptionMap` merges them in. Walking all pages here would
* double API load since the stream is already running in parallel.
* Resolves a single space by key via the server's exact-key lookup, independent
* of how far the page drain has run — a space sorting beyond page 1 would
* otherwise never resolve, which on a large site is most of them.
*/
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
if (!detailId) return null
Expand All @@ -64,6 +65,7 @@ export const confluenceSelectors = {
credential: credentialId,
workflowId: context.workflowId,
domain,
spaceKey: detailId,
},
signal,
})
Expand All @@ -87,6 +89,16 @@ export const confluenceSelectors = {
search ?? '',
],
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
/**
* Deliberately a single request, not a drain. `/pages` is cursor-paginated and
* this list is therefore capped at `limit`, which is a real gap — but draining it
* is worse: with no search term `title` is unset, so the drain would walk the
* entire site (up to `MAX_AUTO_DRAIN_PAGES` requests) every time the dropdown
* opens, and the route does not forward an abort signal upstream, so superseded
* drains still bill the tenant's rate limit. Fixing this properly needs
* server-side search whose `title` semantics have been confirmed against a live
* instance, not brute-force loading.
*/
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/hooks/selectors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ export interface SelectorDefinition {
*/
fetchPage?: (args: SelectorPageArgs) => Promise<SelectorPage>
fetchById?: (args: SelectorQueryArgs) => Promise<SelectorOption | null>
/**
* Set when `fetchById` tolerates an id that may not exist, returning `null` rather
* than erroring. Only then is it safe to speculatively resolve whatever a user has
* typed — most implementations resolve a record by id and would turn every partial
* keystroke into a failed upstream request.
*/
resolvesUnknownIds?: boolean
enabled?: (args: SelectorQueryArgs) => boolean
staleTime?: number
}
Loading
Loading