11'use client'
22
3- import { useEffect , useMemo , useState } from 'react'
3+ import { useMemo , useState } from 'react'
44import { ChipCombobox , type ComboboxOption , Loader } from '@sim/emcn'
55import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
66import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
@@ -10,8 +10,13 @@ import type {
1010} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
1111import { getDependsOnFields } from '@/blocks/utils'
1212import type { ConnectorConfigField } from '@/connectors/types'
13- import type { SelectorContext , SelectorKey , SelectorOption } from '@/hooks/selectors/types'
14- import { useSelectorOptionDetail , useSelectorOptions } from '@/hooks/selectors/use-selector-query'
13+ import { getSelectorDefinition } from '@/hooks/selectors/registry'
14+ import type { SelectorContext , SelectorKey } from '@/hooks/selectors/types'
15+ import {
16+ useSelectorOptionDetail ,
17+ useSelectorOptionDetails ,
18+ useSelectorOptions ,
19+ } from '@/hooks/selectors/use-selector-query'
1520import { useDebounce } from '@/hooks/use-debounce'
1621
1722interface ConnectorSelectorFieldProps {
@@ -78,83 +83,55 @@ export function ConnectorSelectorField({
7883 } )
7984
8085 /**
81- * The option list fills by draining pages in the background and the combobox
82- * filters it client-side, so an option is only findable once its page has
83- * arrived. Resolving the typed value directly makes an exact id/key selectable
84- * immediately, independent of drain progress. Debounced so typing does not
85- * issue a request per keystroke; selectors without a `fetchById` resolve nothing.
86+ * Label every selected value, including values restored from saved config that no
87+ * in-session search would have resolved. Queries are keyed on `context`, so a label
88+ * can never outlive the context that produced it, and they share keys with the
89+ * speculative lookup below so an already-resolved id costs no extra request.
8690 */
87- const debouncedSearch = useDebounce ( searchTerm . trim ( ) , SEARCH_DEBOUNCE_MS )
88- const { data : searchedOption , error : searchError } = useSelectorOptionDetail ( field . selectorKey , {
91+ const singleValue = Array . isArray ( value ) ? value [ 0 ] : value
92+ const selectedIds = useMemo (
93+ ( ) => ( Array . isArray ( value ) ? value : value ? [ value ] : [ ] ) . filter ( Boolean ) ,
94+ [ value ]
95+ )
96+ const selectedOptions = useSelectorOptionDetails ( field . selectorKey , {
8997 context,
90- detailId : isEnabled && debouncedSearch . length > 0 ? debouncedSearch : undefined ,
98+ detailIds : isEnabled ? selectedIds : [ ] ,
9199 } )
92100
93101 /**
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.
102+ * The option list fills by draining pages in the background and the combobox filters
103+ * it client-side, so an option is only findable once its page has arrived. Where the
104+ * selector's `fetchById` tolerates an unknown id, whatever the user typed is resolved
105+ * directly so an exact key is selectable immediately. Gated on that flag because most
106+ * implementations resolve a record by id, where a partial keystroke is a guaranteed
107+ * failed upstream request rather than an empty result.
98108 */
99- const singleValue = Array . isArray ( value ) ? value [ 0 ] : value
100- const { data : selectedOption } = useSelectorOptionDetail ( field . selectorKey , {
109+ const resolvesUnknownIds = Boolean ( getSelectorDefinition ( field . selectorKey ) . resolvesUnknownIds )
110+ const debouncedSearch = useDebounce ( searchTerm . trim ( ) , SEARCH_DEBOUNCE_MS )
111+ const { data : searchedOption } = useSelectorOptionDetail ( field . selectorKey , {
101112 context,
102- detailId : ! isMulti && isEnabled && singleValue ? singleValue : undefined ,
113+ detailId :
114+ resolvesUnknownIds && isEnabled && debouncedSearch . length > 0 ? debouncedSearch : undefined ,
103115 } )
104116
105117 const emptyMessage = getEmptyMessage ( field . title . toLowerCase ( ) , {
106118 error,
107- lookupFailed : Boolean ( searchError ) ,
108119 hasMore,
109120 isFetchingMore,
110121 truncated,
111122 } )
112123
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- /**
122- * Ids are only meaningful within one selector context, so switching credential,
123- * domain, or a dependency must drop what was resolved under the old one — the
124- * queries re-key, but remembered labels would otherwise linger and mislabel.
125- * Keyed on the serialized context rather than its identity: the memo also depends
126- * on `sourceConfig`, so its identity changes on unrelated field edits.
127- */
128- const contextKey = useMemo ( ( ) => JSON . stringify ( context ) , [ context ] )
129- useEffect ( ( ) => {
130- setResolvedOptions ( ( prev ) => ( Object . keys ( prev ) . length > 0 ? { } : prev ) )
131- } , [ contextKey ] )
132-
133- useEffect ( ( ) => {
134- const found = [ searchedOption , selectedOption ] . filter ( Boolean ) as SelectorOption [ ]
135- if ( found . length === 0 ) return
136- setResolvedOptions ( ( prev ) => {
137- let next = prev
138- for ( const option of found ) {
139- if ( next [ option . id ] === option . label ) continue
140- if ( next === prev ) next = { ...prev }
141- next [ option . id ] = option . label
142- }
143- return next
144- } )
145- } , [ searchedOption , selectedOption ] )
146-
147124 const comboboxOptions = useMemo < ComboboxOption [ ] > ( ( ) => {
148125 const base = options . map ( ( opt ) => ( { label : opt . label , value : opt . id } ) )
149126 const seen = new Set ( base . map ( ( opt ) => opt . value ) )
150127 const extras : ComboboxOption [ ] = [ ]
151- for ( const [ id , label ] of Object . entries ( resolvedOptions ) ) {
152- if ( seen . has ( id ) ) continue
153- seen . add ( id )
154- extras . push ( { label, value : id } )
128+ for ( const option of searchedOption ? [ ... selectedOptions , searchedOption ] : selectedOptions ) {
129+ if ( seen . has ( option . id ) ) continue
130+ seen . add ( option . id )
131+ extras . push ( { label : option . label , value : option . id } )
155132 }
156133 return extras . length > 0 ? [ ...extras , ...base ] : base
157- } , [ options , resolvedOptions ] )
134+ } , [ options , selectedOptions , searchedOption ] )
158135
159136 if ( isLoading && isEnabled ) {
160137 return (
@@ -172,7 +149,7 @@ export function ConnectorSelectorField({
172149 multiSelect
173150 options = { comboboxOptions }
174151 multiSelectValues = { multiValues }
175- onMultiSelectChange = { ( values ) => onChange ( values ) }
152+ onMultiSelectChange = { onChange }
176153 searchable
177154 onSearchChange = { setSearchTerm }
178155 searchPlaceholder = { `Search ${ field . title . toLowerCase ( ) } ...` }
@@ -193,7 +170,7 @@ export function ConnectorSelectorField({
193170 < ChipCombobox
194171 options = { comboboxOptions }
195172 value = { singleValue || undefined }
196- onChange = { ( next ) => onChange ( next ) }
173+ onChange = { onChange }
197174 searchable
198175 onSearchChange = { setSearchTerm }
199176 searchPlaceholder = { `Search ${ field . title . toLowerCase ( ) } ...` }
@@ -222,19 +199,16 @@ function getEmptyMessage(
222199 noun : string ,
223200 state : {
224201 error : Error | null
225- lookupFailed : boolean
226202 hasMore : boolean
227203 isFetchingMore : boolean
228204 truncated : boolean
229205 }
230206) : string {
231- // `field.title` is singular on some connectors ("Base") and plural on others
232- // ("Spaces"), so only the settled message puts the noun behind a quantifier.
233207 if ( state . error ) return 'No match — the list failed to load. Try reopening'
234- // Distinct from the list failing: the list is fine, resolving the typed value is not.
235- if ( state . lookupFailed ) return 'No match — could not check that exact value'
236208 if ( state . hasMore || state . isFetchingMore ) return 'No match yet — still loading…'
237209 if ( state . truncated ) return 'No match — too many to list. Try a more exact term'
210+ // `noun` is singular on some connectors ("Base") and plural on others ("Spaces"),
211+ // so only this settled message puts it behind a quantifier.
238212 return `No ${ noun } found`
239213}
240214
0 commit comments