Skip to content

Commit 2b745ce

Browse files
committed
improvement(knowledge): show selector with saved option in connector edit modal
1 parent 5f56e46 commit 2b745ce

File tree

1 file changed

+212
-47
lines changed

1 file changed

+212
-47
lines changed

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx

Lines changed: 212 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use client'
22

3-
import { useMemo, useState } from 'react'
3+
import { useCallback, useMemo, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5-
import { ExternalLink, Loader2, RotateCcw } from 'lucide-react'
5+
import { ArrowLeftRight, ExternalLink, Loader2, RotateCcw } from 'lucide-react'
66
import {
77
Button,
88
ButtonGroup,
@@ -20,13 +20,16 @@ import {
2020
ModalTabsList,
2121
ModalTabsTrigger,
2222
Skeleton,
23+
Tooltip,
2324
} from '@/components/emcn'
2425
import { getSubscriptionAccessState } from '@/lib/billing/client'
26+
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field'
2527
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
2628
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
2729
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
30+
import { getDependsOnFields } from '@/blocks/utils'
2831
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
29-
import type { ConnectorConfig } from '@/connectors/types'
32+
import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types'
3033
import type { ConnectorData } from '@/hooks/queries/kb/connectors'
3134
import {
3235
useConnectorDocuments,
@@ -35,6 +38,7 @@ import {
3538
useUpdateConnector,
3639
} from '@/hooks/queries/kb/connectors'
3740
import { useSubscriptionData } from '@/hooks/queries/subscription'
41+
import type { SelectorKey } from '@/hooks/selectors/types'
3842

3943
const 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({
183299
interface 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

193315
function 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

Comments
 (0)