11'use client'
22
3- import { useMemo , useState } from 'react'
3+ import { useCallback , useMemo , useState } from 'react'
44import { createLogger } from '@sim/logger'
5- import { ExternalLink , Loader2 , RotateCcw } from 'lucide-react'
5+ import { ArrowLeftRight , ExternalLink , Loader2 , RotateCcw } from 'lucide-react'
66import {
77 Button ,
88 ButtonGroup ,
@@ -20,13 +20,16 @@ import {
2020 ModalTabsList ,
2121 ModalTabsTrigger ,
2222 Skeleton ,
23+ Tooltip ,
2324} from '@/components/emcn'
2425import { getSubscriptionAccessState } from '@/lib/billing/client'
26+ import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field'
2527import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
2628import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
2729import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
30+ import { getDependsOnFields } from '@/blocks/utils'
2831import { CONNECTOR_REGISTRY } from '@/connectors/registry'
29- import type { ConnectorConfig } from '@/connectors/types'
32+ import type { ConnectorConfig , ConnectorConfigField } from '@/connectors/types'
3033import type { ConnectorData } from '@/hooks/queries/kb/connectors'
3134import {
3235 useConnectorDocuments ,
@@ -35,6 +38,7 @@ import {
3538 useUpdateConnector ,
3639} from '@/hooks/queries/kb/connectors'
3740import { useSubscriptionData } from '@/hooks/queries/subscription'
41+ import type { SelectorKey } from '@/hooks/selectors/types'
3842
3943const logger = createLogger ( 'EditConnectorModal' )
4044
@@ -56,34 +60,139 @@ export function EditConnectorModal({
5660} : EditConnectorModalProps ) {
5761 const connectorConfig = CONNECTOR_REGISTRY [ connector . connectorType ] ?? null
5862
59- const initialSourceConfig = useMemo ( ( ) => {
63+ const [ activeTab , setActiveTab ] = useState ( 'settings' )
64+ /**
65+ * Seeds from the stored canonical config. For canonical-pair fields (selector +
66+ * manual input), both field IDs get the same value so toggling preserves it.
67+ */
68+ const [ sourceConfig , setSourceConfig ] = useState < Record < string , string > > ( ( ) => {
6069 const config : Record < string , string > = { }
61- for ( const [ key , value ] of Object . entries ( connector . sourceConfig ) ) {
62- if ( ! INTERNAL_CONFIG_KEYS . has ( key ) ) {
63- config [ key ] = String ( value ?? '' )
70+ if ( ! connectorConfig ) {
71+ for ( const [ key , value ] of Object . entries ( connector . sourceConfig ) ) {
72+ if ( ! INTERNAL_CONFIG_KEYS . has ( key ) ) config [ key ] = String ( value ?? '' )
6473 }
74+ return config
75+ }
76+ for ( const field of connectorConfig . configFields ) {
77+ const canonicalId = field . canonicalParamId ?? field . id
78+ if ( INTERNAL_CONFIG_KEYS . has ( canonicalId ) ) continue
79+ const rawValue = connector . sourceConfig [ canonicalId ]
80+ if ( rawValue !== undefined ) config [ field . id ] = String ( rawValue ?? '' )
6581 }
6682 return config
67- } , [ connector . sourceConfig ] )
68-
69- const [ activeTab , setActiveTab ] = useState ( 'settings' )
70- const [ sourceConfig , setSourceConfig ] = useState < Record < string , string > > ( initialSourceConfig )
83+ } )
7184 const [ syncInterval , setSyncInterval ] = useState ( connector . syncIntervalMinutes )
7285 const [ error , setError ] = useState < string | null > ( null )
86+ const [ canonicalModes , setCanonicalModes ] = useState < Record < string , 'basic' | 'advanced' > > ( { } )
7387
7488 const { mutate : updateConnector , isPending : isSaving } = useUpdateConnector ( )
7589
7690 const { data : subscriptionResponse } = useSubscriptionData ( { enabled : isBillingEnabled } )
7791 const subscriptionAccess = getSubscriptionAccessState ( subscriptionResponse ?. data )
7892 const hasMaxAccess = ! isBillingEnabled || subscriptionAccess . hasUsableMaxAccess
7993
94+ const canonicalGroups = useMemo ( ( ) => {
95+ if ( ! connectorConfig ) return new Map < string , ConnectorConfigField [ ] > ( )
96+ const groups = new Map < string , ConnectorConfigField [ ] > ( )
97+ for ( const field of connectorConfig . configFields ) {
98+ if ( field . canonicalParamId ) {
99+ const existing = groups . get ( field . canonicalParamId )
100+ if ( existing ) existing . push ( field )
101+ else groups . set ( field . canonicalParamId , [ field ] )
102+ }
103+ }
104+ return groups
105+ } , [ connectorConfig ] )
106+
107+ const dependentFieldIds = useMemo ( ( ) => {
108+ if ( ! connectorConfig ) return new Map < string , string [ ] > ( )
109+ const map = new Map < string , string [ ] > ( )
110+ for ( const field of connectorConfig . configFields ) {
111+ const deps = getDependsOnFields ( field . dependsOn )
112+ for ( const dep of deps ) {
113+ const existing = map . get ( dep ) ?? [ ]
114+ existing . push ( field . id )
115+ map . set ( dep , existing )
116+ }
117+ }
118+ for ( const group of canonicalGroups . values ( ) ) {
119+ const allDependents = new Set < string > ( )
120+ for ( const field of group ) {
121+ for ( const dep of map . get ( field . id ) ?? [ ] ) {
122+ allDependents . add ( dep )
123+ const depField = connectorConfig . configFields . find ( ( f ) => f . id === dep )
124+ if ( depField ?. canonicalParamId ) {
125+ for ( const sibling of canonicalGroups . get ( depField . canonicalParamId ) ?? [ ] ) {
126+ allDependents . add ( sibling . id )
127+ }
128+ }
129+ }
130+ }
131+ if ( allDependents . size > 0 ) {
132+ for ( const field of group ) map . set ( field . id , [ ...allDependents ] )
133+ }
134+ }
135+ return map
136+ } , [ connectorConfig , canonicalGroups ] )
137+
138+ const isFieldVisible = ( field : ConnectorConfigField ) : boolean => {
139+ if ( ! field . canonicalParamId || ! field . mode ) return true
140+ const activeMode = canonicalModes [ field . canonicalParamId ] ?? 'basic'
141+ return field . mode === activeMode
142+ }
143+
144+ const handleFieldChange = ( fieldId : string , value : string ) => {
145+ setSourceConfig ( ( prev ) => {
146+ const next = { ...prev , [ fieldId ] : value }
147+ const toClear = dependentFieldIds . get ( fieldId )
148+ if ( toClear ) {
149+ for ( const depId of toClear ) next [ depId ] = ''
150+ }
151+ return next
152+ } )
153+ }
154+
155+ const toggleCanonicalMode = ( canonicalId : string ) => {
156+ setCanonicalModes ( ( prev ) => ( {
157+ ...prev ,
158+ [ canonicalId ] : prev [ canonicalId ] === 'advanced' ? 'basic' : 'advanced' ,
159+ } ) )
160+ }
161+
162+ /**
163+ * Collapse the canonical-pair state back to a flat map keyed by canonical IDs
164+ * (matching what's stored in `connector.sourceConfig`).
165+ */
166+ const resolveSourceConfig = useCallback ( ( ) : Record < string , string > => {
167+ const resolved : Record < string , string > = { }
168+ const processedCanonicals = new Set < string > ( )
169+ if ( ! connectorConfig ) return resolved
170+
171+ for ( const field of connectorConfig . configFields ) {
172+ if ( field . canonicalParamId ) {
173+ if ( processedCanonicals . has ( field . canonicalParamId ) ) continue
174+ processedCanonicals . add ( field . canonicalParamId )
175+ const group = canonicalGroups . get ( field . canonicalParamId )
176+ if ( ! group ) continue
177+ const activeMode = canonicalModes [ field . canonicalParamId ] ?? 'basic'
178+ const activeField = group . find ( ( f ) => f . mode === activeMode ) ?? group [ 0 ]
179+ const value = sourceConfig [ activeField . id ] ?? ''
180+ resolved [ field . canonicalParamId ] = value
181+ } else {
182+ resolved [ field . id ] = sourceConfig [ field . id ] ?? ''
183+ }
184+ }
185+ return resolved
186+ } , [ connectorConfig , canonicalGroups , canonicalModes , sourceConfig ] )
187+
80188 const hasChanges = useMemo ( ( ) => {
81189 if ( syncInterval !== connector . syncIntervalMinutes ) return true
82- for ( const [ key , value ] of Object . entries ( sourceConfig ) ) {
190+ const resolved = resolveSourceConfig ( )
191+ for ( const [ key , value ] of Object . entries ( resolved ) ) {
83192 if ( String ( connector . sourceConfig [ key ] ?? '' ) !== value ) return true
84193 }
85194 return false
86- } , [ sourceConfig , syncInterval , connector . syncIntervalMinutes , connector . sourceConfig ] )
195+ } , [ resolveSourceConfig , syncInterval , connector . syncIntervalMinutes , connector . sourceConfig ] )
87196
88197 const handleSave = ( ) => {
89198 setError ( null )
@@ -94,11 +203,12 @@ export function EditConnectorModal({
94203 updates . syncIntervalMinutes = syncInterval
95204 }
96205
97- const configChanged = Object . entries ( sourceConfig ) . some (
206+ const resolved = resolveSourceConfig ( )
207+ const configChanged = Object . entries ( resolved ) . some (
98208 ( [ key , value ] ) => String ( connector . sourceConfig [ key ] ?? '' ) !== value
99209 )
100210 if ( configChanged ) {
101- updates . sourceConfig = { ...connector . sourceConfig , ...sourceConfig }
211+ updates . sourceConfig = { ...connector . sourceConfig , ...resolved }
102212 }
103213
104214 if ( Object . keys ( updates ) . length === 0 ) {
@@ -144,10 +254,16 @@ export function EditConnectorModal({
144254 < SettingsTab
145255 connectorConfig = { connectorConfig }
146256 sourceConfig = { sourceConfig }
147- setSourceConfig = { setSourceConfig }
257+ credentialId = { connector . credentialId }
258+ canonicalGroups = { canonicalGroups }
259+ canonicalModes = { canonicalModes }
260+ onToggleCanonicalMode = { toggleCanonicalMode }
261+ onFieldChange = { handleFieldChange }
262+ isFieldVisible = { isFieldVisible }
148263 syncInterval = { syncInterval }
149264 setSyncInterval = { setSyncInterval }
150265 hasMaxAccess = { hasMaxAccess }
266+ isSaving = { isSaving }
151267 error = { error }
152268 />
153269 </ ModalTabsContent >
@@ -183,53 +299,102 @@ export function EditConnectorModal({
183299interface SettingsTabProps {
184300 connectorConfig : ConnectorConfig | null
185301 sourceConfig : Record < string , string >
186- setSourceConfig : React . Dispatch < React . SetStateAction < Record < string , string > > >
302+ credentialId : string | null
303+ canonicalGroups : Map < string , ConnectorConfigField [ ] >
304+ canonicalModes : Record < string , 'basic' | 'advanced' >
305+ onToggleCanonicalMode : ( canonicalId : string ) => void
306+ onFieldChange : ( fieldId : string , value : string ) => void
307+ isFieldVisible : ( field : ConnectorConfigField ) => boolean
187308 syncInterval : number
188309 setSyncInterval : ( v : number ) => void
189310 hasMaxAccess : boolean
311+ isSaving : boolean
190312 error : string | null
191313}
192314
193315function SettingsTab ( {
194316 connectorConfig,
195317 sourceConfig,
196- setSourceConfig,
318+ credentialId,
319+ canonicalGroups,
320+ canonicalModes,
321+ onToggleCanonicalMode,
322+ onFieldChange,
323+ isFieldVisible,
197324 syncInterval,
198325 setSyncInterval,
199326 hasMaxAccess,
327+ isSaving,
200328 error,
201329} : SettingsTabProps ) {
202330 return (
203331 < div className = 'flex flex-col gap-3' >
204- { connectorConfig ?. configFields . map ( ( field ) => (
205- < div key = { field . id } className = 'flex flex-col gap-2' >
206- < Label >
207- { field . title }
208- { field . required && < span className = 'ml-0.5 text-[var(--text-error)]' > *</ span > }
209- </ Label >
210- { field . description && (
211- < p className = 'text-[var(--text-muted)] text-xs' > { field . description } </ p >
212- ) }
213- { field . type === 'dropdown' && field . options ? (
214- < Combobox
215- size = 'sm'
216- options = { field . options . map ( ( opt ) => ( {
217- label : opt . label ,
218- value : opt . id ,
219- } ) ) }
220- value = { sourceConfig [ field . id ] || undefined }
221- onChange = { ( value ) => setSourceConfig ( ( prev ) => ( { ...prev , [ field . id ] : value } ) ) }
222- placeholder = { field . placeholder || `Select ${ field . title . toLowerCase ( ) } ` }
223- />
224- ) : (
225- < Input
226- value = { sourceConfig [ field . id ] || '' }
227- onChange = { ( e ) => setSourceConfig ( ( prev ) => ( { ...prev , [ field . id ] : e . target . value } ) ) }
228- placeholder = { field . placeholder }
229- />
230- ) }
231- </ div >
232- ) ) }
332+ { connectorConfig ?. configFields . map ( ( field ) => {
333+ if ( ! isFieldVisible ( field ) ) return null
334+
335+ const canonicalId = field . canonicalParamId
336+ const hasCanonicalPair =
337+ canonicalId && ( canonicalGroups . get ( canonicalId ) ?. length ?? 0 ) === 2
338+
339+ return (
340+ < div key = { field . id } className = 'flex flex-col gap-2' >
341+ < div className = 'flex items-center justify-between' >
342+ < Label >
343+ { field . title }
344+ { field . required && < span className = 'ml-0.5 text-[var(--text-error)]' > *</ span > }
345+ </ Label >
346+ { hasCanonicalPair && canonicalId && (
347+ < Tooltip . Root >
348+ < Tooltip . Trigger asChild >
349+ < button
350+ type = 'button'
351+ className = 'flex h-[18px] w-[18px] items-center justify-center rounded-[3px] text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-secondary)]'
352+ onClick = { ( ) => onToggleCanonicalMode ( canonicalId ) }
353+ >
354+ < ArrowLeftRight className = 'h-[12px] w-[12px]' />
355+ </ button >
356+ </ Tooltip . Trigger >
357+ < Tooltip . Content side = 'top' >
358+ { field . mode === 'basic' ? 'Switch to manual input' : 'Switch to selector' }
359+ </ Tooltip . Content >
360+ </ Tooltip . Root >
361+ ) }
362+ </ div >
363+ { field . description && (
364+ < p className = 'text-[var(--text-muted)] text-xs' > { field . description } </ p >
365+ ) }
366+ { field . type === 'selector' && field . selectorKey ? (
367+ < ConnectorSelectorField
368+ field = { field as ConnectorConfigField & { selectorKey : SelectorKey } }
369+ value = { sourceConfig [ field . id ] || '' }
370+ onChange = { ( value ) => onFieldChange ( field . id , value ) }
371+ credentialId = { credentialId }
372+ sourceConfig = { sourceConfig }
373+ configFields = { connectorConfig . configFields }
374+ canonicalModes = { canonicalModes }
375+ disabled = { isSaving }
376+ />
377+ ) : field . type === 'dropdown' && field . options ? (
378+ < Combobox
379+ size = 'sm'
380+ options = { field . options . map ( ( opt ) => ( {
381+ label : opt . label ,
382+ value : opt . id ,
383+ } ) ) }
384+ value = { sourceConfig [ field . id ] || undefined }
385+ onChange = { ( value ) => onFieldChange ( field . id , value ) }
386+ placeholder = { field . placeholder || `Select ${ field . title . toLowerCase ( ) } ` }
387+ />
388+ ) : (
389+ < Input
390+ value = { sourceConfig [ field . id ] || '' }
391+ onChange = { ( e ) => onFieldChange ( field . id , e . target . value ) }
392+ placeholder = { field . placeholder }
393+ />
394+ ) }
395+ </ div >
396+ )
397+ } ) }
233398
234399 < div className = 'flex flex-col gap-2' >
235400 < Label > Sync Frequency</ Label >
0 commit comments