11'use client'
22
3- import { useMemo } from 'react'
3+ import { useMemo , useState } from 'react'
44import { ChipCombobox , type ComboboxOption , Loader } from '@sim/emcn'
5+ import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
56import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
67import type {
78 ConfigFieldMap ,
@@ -10,7 +11,8 @@ import type {
1011import { getDependsOnFields } from '@/blocks/utils'
1112import type { ConnectorConfigField } from '@/connectors/types'
1213import 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
1517interface 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+
158203function resolveDepValue (
159204 depFieldId : string ,
160205 configFields : ConnectorConfigField [ ] ,
0 commit comments