mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
fix(connectors): make selector dropdowns resolve options the drain has not reached (#6027)
* fix(connectors): stop the connector selector implying an option does not exist The connector selector field ignored the drain state the shared selector hook already exposes. Paginated selectors fill in the background and the combobox filters client-side, so a not-yet-drained option is genuinely absent from the list — and the dropdown said "No spaces found", which reads as "this space does not exist" and sends users off to enter the value by hand. That is what happened with a Confluence space on a site with thousands of personal spaces. It now reports that the list is still filling, surfaces a failed drain instead of claiming to load forever, and is honest when the drain stops at its page cap. * 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. * fix(connectors): paginate the Confluence pages selector instead of returning one page `/api/tools/confluence/pages` issued a single request and discarded the `_links.next` cursor Confluence returns, so the picker showed only the first `limit` pages of however many exist — a search for a real page found nothing, with no error and no truncation signal. Threads the documented opaque cursor and moves the selector to `fetchPage`, so the option list drains like `confluence.spaces` and reports real `hasMore` / `truncated` state. The `title` server-side filter is unchanged and now paginates too. `limit` is deliberately left at its existing default: the endpoint's maximum is not confirmable from Atlassian's published reference, and raising it is not needed for correctness. Also guards `data.results`, which threw when the response omitted it. * fix(connectors): query both space statuses explicitly on exact-key lookup The exact-key lookup omitted `status`, assuming that matched a space whether it was current or archived. Atlassian documents a `current,archived` default for `/pages` but documents no default for `/spaces`, where `status` takes a single value rather than an array — so the assumption was unverified, and resolving only current spaces would silently miss archived ones. Archived spaces are reachable through the paged path and sync works against them. Queries `current` first and falls back to `archived` only when it finds nothing, so the common case stays one request and the behaviour no longer depends on an undocumented default. * fix(connectors): revert pages drain, restore CloudWatch labels, tighten key lookup Six adversarial verification passes against the provider spec and the surrounding plumbing found three defects in the prior commits. Reverts the Confluence pages selector to a single request. Draining it was not strictly better: with no search term `title` is unset, so opening the dropdown walked the entire site — up to 50 sequential requests and 2,500 options where there had been one request and 50 — and the route forwards no abort signal upstream, so superseded drains still bill the tenant's rate limit. The same fan-out reached `loadAllSelectorOptions`, and the justification rested on `title` semantics Atlassian does not publish. The list cap is a real gap, but it needs confirmed server-side search, not brute force. Keeps the `results` guard. Restores selector display names for CloudWatch blocks. Narrowing a caller's `enabled` against `definition.enabled` disabled a detail query those selectors satisfy without AWS context, so collapsed blocks rendered "-" instead of the log group name. A caller that opts in is now narrowed only by the hard precondition that `fetchById` exists, which is what `queryFn` actually asserts. Queries both space statuses concurrently rather than sequentially: the key is user-typed text, so a miss dominates while typing and paid two round-trips. Each row now falls back to the status its own call requested. Also drops the "enter the value directly" empty states — the combobox is not editable, so there is no such affordance — and dedupes `onSearchChange`, which several reset paths fire redundantly. * 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". * fix(connectors): drop remembered option labels when the selector context changes Resolved ids are only meaningful within one selector context, so switching credential, domain, or a dependency has to discard what was remembered under the old one — the queries re-key, but the remembered labels would linger and mislabel until the field remounted. Keyed on the serialized context rather than its identity: the context memo also depends on `sourceConfig`, so its identity changes on unrelated field edits and would clear the cache far more often than intended. * refactor(connectors): resolve selected option labels with useQueries Replaces the remembered-label map with per-id queries over the selected values, following the pattern the knowledge-base selector already uses. The map only ever held ids searched for in the current session, so a multi-select field restored from saved config still rendered raw ids — the case that actually matters. It also needed two effects and a serialized-context key to avoid leaking labels across a context change, all of which disappear: queries key on the context, so a label cannot outlive it. Gates the speculative lookup of typed text on a new `resolvesUnknownIds` flag, set only where `fetchById` returns null for an id that does not exist. Most implementations resolve a record by id, so every partial keystroke was a failed upstream request, retried once, and its error made "could not check that exact value" the normal empty state on those selectors. Also: pass handlers straight to the combobox rather than through identity wrappers that defeated its memo, move the latest-callback ref write into an effect, rename the raw search setter so the deduping write path cannot be bypassed, and correct the prop and staleTime docs.
This commit is contained in:
@@ -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
|
||||
@@ -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')
|
||||
@@ -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}` },
|
||||
})
|
||||
|
||||
const response = await fetch(url, {
|
||||
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 }) }
|
||||
}
|
||||
|
||||
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 })
|
||||
return { ok: true, data: await response.json() }
|
||||
}
|
||||
|
||||
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 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 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
|
||||
@@ -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(
|
||||
|
||||
+98
-11
@@ -1,7 +1,8 @@
|
||||
'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,
|
||||
@@ -9,8 +10,14 @@ import type {
|
||||
} 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 }
|
||||
@@ -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 = {}
|
||||
@@ -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 (
|
||||
@@ -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
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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[],
|
||||
|
||||
@@ -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
|
||||
@@ -64,6 +65,7 @@ export const confluenceSelectors = {
|
||||
credential: credentialId,
|
||||
workflowId: context.workflowId,
|
||||
domain,
|
||||
spaceKey: detailId,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
|
||||
import { usePersonalEnvironment } from '@/hooks/queries/environment'
|
||||
import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry'
|
||||
@@ -51,6 +51,15 @@ const EMPTY_PAGE: SelectorPage = { items: [], nextCursor: undefined }
|
||||
*/
|
||||
const MAX_AUTO_DRAIN_PAGES = 50
|
||||
|
||||
/** Fallback freshness for selectors that do not declare their own `staleTime`. */
|
||||
export const DEFAULT_SELECTOR_STALE_TIME = 30_000
|
||||
|
||||
/**
|
||||
* Fallback for a single-option resolution when the definition declares no
|
||||
* `staleTime`: keyed by an exact id, so it changes far less often than a list.
|
||||
*/
|
||||
export const DEFAULT_SELECTOR_DETAIL_STALE_TIME = 300_000
|
||||
|
||||
export function useSelectorOptions(
|
||||
key: SelectorKey,
|
||||
args: SelectorHookArgs
|
||||
@@ -69,7 +78,7 @@ export function useSelectorOptions(
|
||||
queryFn: ({ signal }) =>
|
||||
definition.fetchList?.({ ...queryArgs, signal }) ?? Promise.resolve([]),
|
||||
enabled: !supportsPagination && isEnabled,
|
||||
staleTime: definition.staleTime ?? 30_000,
|
||||
staleTime: definition.staleTime ?? DEFAULT_SELECTOR_STALE_TIME,
|
||||
})
|
||||
|
||||
const pagedQuery = useInfiniteQuery<SelectorPage>({
|
||||
@@ -85,7 +94,7 @@ export function useSelectorOptions(
|
||||
getNextPageParam: (last) => last.nextCursor,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
enabled: supportsPagination && isEnabled,
|
||||
staleTime: definition.staleTime ?? 30_000,
|
||||
staleTime: definition.staleTime ?? DEFAULT_SELECTOR_STALE_TIME,
|
||||
})
|
||||
|
||||
const { hasNextPage, isFetchingNextPage, fetchNextPage, isError } = pagedQuery
|
||||
@@ -169,24 +178,80 @@ export function useSelectorOptionDetail(
|
||||
detailId: resolvedDetailId,
|
||||
}
|
||||
const hasRealDetailId = Boolean(resolvedDetailId)
|
||||
const baseEnabled =
|
||||
hasRealDetailId && definition.fetchById !== undefined
|
||||
? definition.enabled
|
||||
? definition.enabled(queryArgs)
|
||||
: true
|
||||
: false
|
||||
const enabled = args.enabled ?? baseEnabled
|
||||
/**
|
||||
* Hard precondition: `queryFn` asserts `fetchById` is defined, so this must hold
|
||||
* however the caller configures the query — otherwise the assertion throws for the
|
||||
* many selectors that declare no `fetchById`.
|
||||
*/
|
||||
const canResolveDetail = hasRealDetailId && definition.fetchById !== undefined
|
||||
/**
|
||||
* `definition.enabled` describes when the *list* can be fetched, so it gates on
|
||||
* context a list needs (credential, domain, region). Resolving one already-known id
|
||||
* can need far less — `cloudwatch.*` echoes the id back without calling AWS at all —
|
||||
* so a caller that opts in explicitly is only narrowed by the hard precondition.
|
||||
* Callers that pass nothing keep the list predicate as their default.
|
||||
*/
|
||||
const enabled =
|
||||
(args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true)) &&
|
||||
canResolveDetail
|
||||
|
||||
const query = useQuery<SelectorOption | null>({
|
||||
queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'],
|
||||
queryFn: ({ signal }) => definition.fetchById!({ ...queryArgs, signal }),
|
||||
enabled,
|
||||
staleTime: definition.staleTime ?? 300_000,
|
||||
staleTime: definition.staleTime ?? DEFAULT_SELECTOR_DETAIL_STALE_TIME,
|
||||
})
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves several ids at once, so a multi-select field can label every selected
|
||||
* value — including values restored from saved config, which no in-session search
|
||||
* would have resolved. Query keys match {@link useSelectorOptionDetail} exactly, so
|
||||
* the two share a cache and an id already resolved by search costs no extra request.
|
||||
*/
|
||||
export function useSelectorOptionDetails(
|
||||
key: SelectorKey,
|
||||
args: Omit<SelectorHookArgs, 'detailId'> & { detailIds: string[] }
|
||||
): SelectorOption[] {
|
||||
const { data: envVariables = {} } = usePersonalEnvironment()
|
||||
const definition = getSelectorDefinition(key)
|
||||
|
||||
const resolvedIds = useMemo(() => {
|
||||
const out: string[] = []
|
||||
for (const id of args.detailIds) {
|
||||
if (!id || isReference(id)) continue
|
||||
if (isEnvVarReference(id)) {
|
||||
const value = envVariables[extractEnvVarName(id)]?.value
|
||||
if (value) out.push(value)
|
||||
continue
|
||||
}
|
||||
out.push(id)
|
||||
}
|
||||
return Array.from(new Set(out))
|
||||
}, [args.detailIds, envVariables])
|
||||
|
||||
const results = useQueries({
|
||||
queries: resolvedIds.map((detailId) => {
|
||||
const queryArgs: SelectorQueryArgs = { key, context: args.context, detailId }
|
||||
const canResolveDetail = definition.fetchById !== undefined
|
||||
return {
|
||||
queryKey: [...definition.getQueryKey(queryArgs), 'detail', detailId],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
definition.fetchById!({ ...queryArgs, signal }),
|
||||
enabled:
|
||||
args.enabled !== undefined
|
||||
? args.enabled && canResolveDetail
|
||||
: canResolveDetail && (definition.enabled ? definition.enabled(queryArgs) : true),
|
||||
staleTime: definition.staleTime ?? DEFAULT_SELECTOR_DETAIL_STALE_TIME,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
return useMemo(() => results.flatMap((result) => (result.data ? [result.data] : [])), [results])
|
||||
}
|
||||
|
||||
export function useSelectorOptionMap(options: SelectorOption[], extra?: SelectorOption | null) {
|
||||
return useMemo(() => {
|
||||
const merged = mergeOption(options, extra)
|
||||
|
||||
@@ -366,6 +366,16 @@ const defineConfluenceGetContract = <TQuery extends z.ZodType>(path: string, que
|
||||
|
||||
export const confluenceSpacesSelectorBodySchema = credentialWorkflowDomainBodySchema.extend({
|
||||
cursor: optionalString,
|
||||
/**
|
||||
* Exact space key to resolve server-side, bypassing pagination. Confluence v2
|
||||
* `/spaces` supports a `keys` filter, so a known key resolves in one request
|
||||
* instead of depending on how far the background page drain has progressed.
|
||||
*/
|
||||
spaceKey: z
|
||||
.string()
|
||||
.min(1, 'spaceKey cannot be empty')
|
||||
.max(255, 'spaceKey must be 255 characters or fewer')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const confluenceSpacesSelectorContract = definePostSelector(
|
||||
|
||||
@@ -118,6 +118,19 @@ export interface ComboboxProps
|
||||
onArrowLeft?: () => void
|
||||
/** Enable search input in dropdown (useful for multiselect) */
|
||||
searchable?: boolean
|
||||
/**
|
||||
* Notified when the dropdown's search box changes value, including the `''` a
|
||||
* select, close, Escape, or ArrowLeft resets it to. Deduped, so an already-empty
|
||||
* query resetting again is silent and a consumer sees nothing while `searchable`
|
||||
* is false.
|
||||
*
|
||||
* This is the `searchable` search box only. In `editable` mode the typed text
|
||||
* arrives via `onChange`, not here.
|
||||
*
|
||||
* Client-side filtering of `options` is unaffected — this is an additional
|
||||
* signal for consumers that also resolve matches server-side.
|
||||
*/
|
||||
onSearchChange?: (query: string) => void
|
||||
/** Placeholder for search input */
|
||||
searchPlaceholder?: string
|
||||
/** Size variant */
|
||||
@@ -169,6 +182,7 @@ const Combobox = memo(
|
||||
onOpenChange,
|
||||
onArrowLeft,
|
||||
searchable = false,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Search...',
|
||||
align = 'start',
|
||||
dropdownWidth = 'trigger',
|
||||
@@ -184,7 +198,29 @@ const Combobox = memo(
|
||||
const listboxId = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchQuery, setSearchQueryState] = useState('')
|
||||
/**
|
||||
* Read through a ref so `updateSearchQuery` keeps a stable identity —
|
||||
* `handleSelect`, `handleBlur`, and `handleKeyDown` all capture it without
|
||||
* listing it as a dependency.
|
||||
*/
|
||||
const onSearchChangeRef = useRef(onSearchChange)
|
||||
useEffect(() => {
|
||||
onSearchChangeRef.current = onSearchChange
|
||||
}, [onSearchChange])
|
||||
/**
|
||||
* Single write path for the search box so `onSearchChange` cannot be missed on a
|
||||
* reset. Deduped because several paths reset redundantly — Escape both handles the
|
||||
* key and lets the popover dismiss, and an editable select blurs after selecting —
|
||||
* which the raw setState absorbed silently but a consumer callback would not.
|
||||
*/
|
||||
const searchQueryRef = useRef('')
|
||||
const updateSearchQuery = useCallback((next: string) => {
|
||||
if (searchQueryRef.current === next) return
|
||||
searchQueryRef.current = next
|
||||
setSearchQueryState(next)
|
||||
onSearchChangeRef.current?.(next)
|
||||
}, [])
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
@@ -299,7 +335,7 @@ const Combobox = memo(
|
||||
if (customOnSelect) {
|
||||
customOnSelect()
|
||||
// Always reset search/highlight so stale queries don't filter new options
|
||||
setSearchQuery('')
|
||||
updateSearchQuery('')
|
||||
setHighlightedIndex(-1)
|
||||
if (!keepOpen) {
|
||||
setOpen(false)
|
||||
@@ -318,7 +354,7 @@ const Combobox = memo(
|
||||
if (!keepOpen) {
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
setSearchQuery('')
|
||||
updateSearchQuery('')
|
||||
if (editable && inputRef.current) {
|
||||
inputRef.current.blur()
|
||||
}
|
||||
@@ -365,7 +401,7 @@ const Combobox = memo(
|
||||
if (!activeElement || (!isInContainer && !isInDropdown && !isSearchInput)) {
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
setSearchQuery('')
|
||||
updateSearchQuery('')
|
||||
}
|
||||
}, 150)
|
||||
}, [])
|
||||
@@ -380,7 +416,7 @@ const Combobox = memo(
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
setSearchQuery('')
|
||||
updateSearchQuery('')
|
||||
if (editable && inputRef.current) {
|
||||
inputRef.current.blur()
|
||||
}
|
||||
@@ -442,7 +478,7 @@ const Combobox = memo(
|
||||
if (open && onArrowLeft) {
|
||||
e.preventDefault()
|
||||
onArrowLeft()
|
||||
setSearchQuery('')
|
||||
updateSearchQuery('')
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
}
|
||||
@@ -525,7 +561,7 @@ const Combobox = memo(
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
if (!next) setSearchQuery('')
|
||||
if (!next) updateSearchQuery('')
|
||||
onOpenChange?.(next)
|
||||
}}
|
||||
>
|
||||
@@ -664,7 +700,7 @@ const Combobox = memo(
|
||||
className='w-full bg-transparent text-[var(--text-primary)] text-small placeholder:text-[var(--text-muted)] focus:outline-none'
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={(e) => updateSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Forward navigation keys to main handler
|
||||
// Only forward ArrowLeft/ArrowRight when cursor is at the boundary
|
||||
|
||||
Reference in New Issue
Block a user