From 6d9ffc721b40e8a030c57ecb7e2e67d0fc11dd13 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Apr 2026 16:21:20 -0700 Subject: [PATCH 1/5] improvement(knowledge): show selector with saved option in connector edit modal --- .../edit-connector-modal.tsx | 259 ++++++++++++++---- 1 file changed, 212 insertions(+), 47 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index e285f2fa94d..a1e886c2111 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -1,8 +1,8 @@ 'use client' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import { ExternalLink, Loader2, RotateCcw } from 'lucide-react' +import { ArrowLeftRight, ExternalLink, Loader2, RotateCcw } from 'lucide-react' import { Button, ButtonGroup, @@ -20,13 +20,16 @@ import { ModalTabsList, ModalTabsTrigger, Skeleton, + Tooltip, } from '@/components/emcn' import { getSubscriptionAccessState } from '@/lib/billing/client' +import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' +import { getDependsOnFields } from '@/blocks/utils' import { CONNECTOR_REGISTRY } from '@/connectors/registry' -import type { ConnectorConfig } from '@/connectors/types' +import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types' import type { ConnectorData } from '@/hooks/queries/kb/connectors' import { useConnectorDocuments, @@ -35,6 +38,7 @@ import { useUpdateConnector, } from '@/hooks/queries/kb/connectors' import { useSubscriptionData } from '@/hooks/queries/subscription' +import type { SelectorKey } from '@/hooks/selectors/types' const logger = createLogger('EditConnectorModal') @@ -56,20 +60,30 @@ export function EditConnectorModal({ }: EditConnectorModalProps) { const connectorConfig = CONNECTOR_REGISTRY[connector.connectorType] ?? null - const initialSourceConfig = useMemo(() => { + const [activeTab, setActiveTab] = useState('settings') + /** + * Seeds from the stored canonical config. For canonical-pair fields (selector + + * manual input), both field IDs get the same value so toggling preserves it. + */ + const [sourceConfig, setSourceConfig] = useState>(() => { const config: Record = {} - for (const [key, value] of Object.entries(connector.sourceConfig)) { - if (!INTERNAL_CONFIG_KEYS.has(key)) { - config[key] = String(value ?? '') + if (!connectorConfig) { + for (const [key, value] of Object.entries(connector.sourceConfig)) { + if (!INTERNAL_CONFIG_KEYS.has(key)) config[key] = String(value ?? '') } + return config + } + for (const field of connectorConfig.configFields) { + const canonicalId = field.canonicalParamId ?? field.id + if (INTERNAL_CONFIG_KEYS.has(canonicalId)) continue + const rawValue = connector.sourceConfig[canonicalId] + if (rawValue !== undefined) config[field.id] = String(rawValue ?? '') } return config - }, [connector.sourceConfig]) - - const [activeTab, setActiveTab] = useState('settings') - const [sourceConfig, setSourceConfig] = useState>(initialSourceConfig) + }) const [syncInterval, setSyncInterval] = useState(connector.syncIntervalMinutes) const [error, setError] = useState(null) + const [canonicalModes, setCanonicalModes] = useState>({}) const { mutate: updateConnector, isPending: isSaving } = useUpdateConnector() @@ -77,13 +91,108 @@ export function EditConnectorModal({ const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data) const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess + const canonicalGroups = useMemo(() => { + if (!connectorConfig) return new Map() + const groups = new Map() + for (const field of connectorConfig.configFields) { + if (field.canonicalParamId) { + const existing = groups.get(field.canonicalParamId) + if (existing) existing.push(field) + else groups.set(field.canonicalParamId, [field]) + } + } + return groups + }, [connectorConfig]) + + const dependentFieldIds = useMemo(() => { + if (!connectorConfig) return new Map() + const map = new Map() + for (const field of connectorConfig.configFields) { + const deps = getDependsOnFields(field.dependsOn) + for (const dep of deps) { + const existing = map.get(dep) ?? [] + existing.push(field.id) + map.set(dep, existing) + } + } + for (const group of canonicalGroups.values()) { + const allDependents = new Set() + for (const field of group) { + for (const dep of map.get(field.id) ?? []) { + allDependents.add(dep) + const depField = connectorConfig.configFields.find((f) => f.id === dep) + if (depField?.canonicalParamId) { + for (const sibling of canonicalGroups.get(depField.canonicalParamId) ?? []) { + allDependents.add(sibling.id) + } + } + } + } + if (allDependents.size > 0) { + for (const field of group) map.set(field.id, [...allDependents]) + } + } + return map + }, [connectorConfig, canonicalGroups]) + + const isFieldVisible = (field: ConnectorConfigField): boolean => { + if (!field.canonicalParamId || !field.mode) return true + const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic' + return field.mode === activeMode + } + + const handleFieldChange = (fieldId: string, value: string) => { + setSourceConfig((prev) => { + const next = { ...prev, [fieldId]: value } + const toClear = dependentFieldIds.get(fieldId) + if (toClear) { + for (const depId of toClear) next[depId] = '' + } + return next + }) + } + + const toggleCanonicalMode = (canonicalId: string) => { + setCanonicalModes((prev) => ({ + ...prev, + [canonicalId]: prev[canonicalId] === 'advanced' ? 'basic' : 'advanced', + })) + } + + /** + * Collapse the canonical-pair state back to a flat map keyed by canonical IDs + * (matching what's stored in `connector.sourceConfig`). + */ + const resolveSourceConfig = useCallback((): Record => { + const resolved: Record = {} + const processedCanonicals = new Set() + if (!connectorConfig) return resolved + + for (const field of connectorConfig.configFields) { + if (field.canonicalParamId) { + if (processedCanonicals.has(field.canonicalParamId)) continue + processedCanonicals.add(field.canonicalParamId) + const group = canonicalGroups.get(field.canonicalParamId) + if (!group) continue + const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic' + const activeField = group.find((f) => f.mode === activeMode) ?? group[0] + const value = sourceConfig[activeField.id] ?? '' + resolved[field.canonicalParamId] = value + } else { + resolved[field.id] = sourceConfig[field.id] ?? '' + } + } + return resolved + }, [connectorConfig, canonicalGroups, canonicalModes, sourceConfig]) + const hasChanges = useMemo(() => { if (syncInterval !== connector.syncIntervalMinutes) return true - for (const [key, value] of Object.entries(sourceConfig)) { + const resolved = resolveSourceConfig() + for (const [key, value] of Object.entries(resolved)) { if (String(connector.sourceConfig[key] ?? '') !== value) return true } return false - }, [sourceConfig, syncInterval, connector.syncIntervalMinutes, connector.sourceConfig]) + }, [resolveSourceConfig, syncInterval, connector.syncIntervalMinutes, connector.sourceConfig]) const handleSave = () => { setError(null) @@ -94,11 +203,12 @@ export function EditConnectorModal({ updates.syncIntervalMinutes = syncInterval } - const configChanged = Object.entries(sourceConfig).some( + const resolved = resolveSourceConfig() + const configChanged = Object.entries(resolved).some( ([key, value]) => String(connector.sourceConfig[key] ?? '') !== value ) if (configChanged) { - updates.sourceConfig = { ...connector.sourceConfig, ...sourceConfig } + updates.sourceConfig = { ...connector.sourceConfig, ...resolved } } if (Object.keys(updates).length === 0) { @@ -144,10 +254,16 @@ export function EditConnectorModal({ @@ -183,53 +299,102 @@ export function EditConnectorModal({ interface SettingsTabProps { connectorConfig: ConnectorConfig | null sourceConfig: Record - setSourceConfig: React.Dispatch>> + credentialId: string | null + canonicalGroups: Map + canonicalModes: Record + onToggleCanonicalMode: (canonicalId: string) => void + onFieldChange: (fieldId: string, value: string) => void + isFieldVisible: (field: ConnectorConfigField) => boolean syncInterval: number setSyncInterval: (v: number) => void hasMaxAccess: boolean + isSaving: boolean error: string | null } function SettingsTab({ connectorConfig, sourceConfig, - setSourceConfig, + credentialId, + canonicalGroups, + canonicalModes, + onToggleCanonicalMode, + onFieldChange, + isFieldVisible, syncInterval, setSyncInterval, hasMaxAccess, + isSaving, error, }: SettingsTabProps) { return (
- {connectorConfig?.configFields.map((field) => ( -
- - {field.description && ( -

{field.description}

- )} - {field.type === 'dropdown' && field.options ? ( - ({ - label: opt.label, - value: opt.id, - }))} - value={sourceConfig[field.id] || undefined} - onChange={(value) => setSourceConfig((prev) => ({ ...prev, [field.id]: value }))} - placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`} - /> - ) : ( - setSourceConfig((prev) => ({ ...prev, [field.id]: e.target.value }))} - placeholder={field.placeholder} - /> - )} -
- ))} + {connectorConfig?.configFields.map((field) => { + if (!isFieldVisible(field)) return null + + const canonicalId = field.canonicalParamId + const hasCanonicalPair = + canonicalId && (canonicalGroups.get(canonicalId)?.length ?? 0) === 2 + + return ( +
+
+ + {hasCanonicalPair && canonicalId && ( + + + + + + {field.mode === 'basic' ? 'Switch to manual input' : 'Switch to selector'} + + + )} +
+ {field.description && ( +

{field.description}

+ )} + {field.type === 'selector' && field.selectorKey ? ( + onFieldChange(field.id, value)} + credentialId={credentialId} + sourceConfig={sourceConfig} + configFields={connectorConfig.configFields} + canonicalModes={canonicalModes} + disabled={isSaving} + /> + ) : field.type === 'dropdown' && field.options ? ( + ({ + label: opt.label, + value: opt.id, + }))} + value={sourceConfig[field.id] || undefined} + onChange={(value) => onFieldChange(field.id, value)} + placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`} + /> + ) : ( + onFieldChange(field.id, e.target.value)} + placeholder={field.placeholder} + /> + )} +
+ ) + })}
From 958d106af7c67d416d42c589455ef6167e5cccd0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Apr 2026 16:52:28 -0700 Subject: [PATCH 2/5] fix(kb-connectors): clear canonical siblings when non-canonical dep changes; share selector field --- .../add-connector-modal.tsx | 19 +++++++++++++------ .../connector-selector-field.tsx | 0 .../connector-selector-field/index.ts | 1 + .../edit-connector-modal.tsx | 19 +++++++++++++------ 4 files changed, 27 insertions(+), 12 deletions(-) rename apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/{add-connector-modal/components => connector-selector-field}/connector-selector-field.tsx (100%) create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 84a2497836a..b40bae76714 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -23,7 +23,7 @@ import { getSubscriptionAccessState } from '@/lib/billing/client' import { consumeOAuthReturnContext } from '@/lib/credentials/client-state' import { getProviderIdFromServiceId, type OAuthProvider } from '@/lib/oauth' import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal' -import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field' +import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' @@ -118,12 +118,17 @@ export function AddConnectorModal({ const dependentFieldIds = useMemo(() => { if (!connectorConfig) return new Map() - const map = new Map() + const map = new Map>() for (const field of connectorConfig.configFields) { const deps = getDependsOnFields(field.dependsOn) for (const dep of deps) { - const existing = map.get(dep) ?? [] - existing.push(field.id) + const existing = map.get(dep) ?? new Set() + existing.add(field.id) + if (field.canonicalParamId) { + for (const sibling of canonicalGroups.get(field.canonicalParamId) ?? []) { + existing.add(sibling.id) + } + } map.set(dep, existing) } } @@ -142,11 +147,13 @@ export function AddConnectorModal({ } if (allDependents.size > 0) { for (const field of group) { - map.set(field.id, [...allDependents]) + map.set(field.id, new Set(allDependents)) } } } - return map + const result = new Map() + for (const [key, value] of map) result.set(key, [...value]) + return result }, [connectorConfig, canonicalGroups]) const handleSelectType = (type: string) => { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/index.ts new file mode 100644 index 00000000000..58b644797a5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/index.ts @@ -0,0 +1 @@ +export { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index a1e886c2111..87f02eb4c19 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -23,7 +23,7 @@ import { Tooltip, } from '@/components/emcn' import { getSubscriptionAccessState } from '@/lib/billing/client' -import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field' +import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' @@ -106,12 +106,17 @@ export function EditConnectorModal({ const dependentFieldIds = useMemo(() => { if (!connectorConfig) return new Map() - const map = new Map() + const map = new Map>() for (const field of connectorConfig.configFields) { const deps = getDependsOnFields(field.dependsOn) for (const dep of deps) { - const existing = map.get(dep) ?? [] - existing.push(field.id) + const existing = map.get(dep) ?? new Set() + existing.add(field.id) + if (field.canonicalParamId) { + for (const sibling of canonicalGroups.get(field.canonicalParamId) ?? []) { + existing.add(sibling.id) + } + } map.set(dep, existing) } } @@ -129,10 +134,12 @@ export function EditConnectorModal({ } } if (allDependents.size > 0) { - for (const field of group) map.set(field.id, [...allDependents]) + for (const field of group) map.set(field.id, new Set(allDependents)) } } - return map + const result = new Map() + for (const [key, value] of map) result.set(key, [...value]) + return result }, [connectorConfig, canonicalGroups]) const isFieldVisible = (field: ConnectorConfigField): boolean => { From 13faa16211d6f9a02fed352e56bea368b908e5ce Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Apr 2026 20:01:37 -0700 Subject: [PATCH 3/5] refactor(kb-connectors): extract canonical-field logic into useConnectorConfigFields hook --- .../add-connector-modal.tsx | 133 ++------------- .../edit-connector-modal.tsx | 127 +++------------ .../[id]/hooks/use-connector-config-fields.ts | 153 ++++++++++++++++++ 3 files changed, 188 insertions(+), 225 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index b40bae76714..38815185831 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -26,8 +26,8 @@ import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' +import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' -import { getDependsOnFields } from '@/blocks/utils' import { CONNECTOR_REGISTRY } from '@/connectors/registry' import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types' import { useCreateConnector } from '@/hooks/queries/kb/connectors' @@ -57,13 +57,11 @@ export function AddConnectorModal({ }: AddConnectorModalProps) { const [step, setStep] = useState(() => (initialConnectorType ? 'configure' : 'select-type')) const [selectedType, setSelectedType] = useState(initialConnectorType ?? null) - const [sourceConfig, setSourceConfig] = useState>({}) const [syncInterval, setSyncInterval] = useState(1440) const [selectedCredentialId, setSelectedCredentialId] = useState(null) const [disabledTagIds, setDisabledTagIds] = useState>(() => new Set()) const [error, setError] = useState(null) const [showOAuthModal, setShowOAuthModal] = useState(false) - const [canonicalModes, setCanonicalModes] = useState>({}) const [apiKeyValue, setApiKeyValue] = useState('') const [apiKeyFocused, setApiKeyFocused] = useState(false) @@ -100,61 +98,17 @@ export function AddConnectorModal({ const effectiveCredentialId = selectedCredentialId ?? (credentials.length === 1 ? credentials[0].id : null) - const canonicalGroups = useMemo(() => { - if (!connectorConfig) return new Map() - const groups = new Map() - for (const field of connectorConfig.configFields) { - if (field.canonicalParamId) { - const existing = groups.get(field.canonicalParamId) - if (existing) { - existing.push(field) - } else { - groups.set(field.canonicalParamId, [field]) - } - } - } - return groups - }, [connectorConfig]) - - const dependentFieldIds = useMemo(() => { - if (!connectorConfig) return new Map() - const map = new Map>() - for (const field of connectorConfig.configFields) { - const deps = getDependsOnFields(field.dependsOn) - for (const dep of deps) { - const existing = map.get(dep) ?? new Set() - existing.add(field.id) - if (field.canonicalParamId) { - for (const sibling of canonicalGroups.get(field.canonicalParamId) ?? []) { - existing.add(sibling.id) - } - } - map.set(dep, existing) - } - } - for (const group of canonicalGroups.values()) { - const allDependents = new Set() - for (const field of group) { - for (const dep of map.get(field.id) ?? []) { - allDependents.add(dep) - const depField = connectorConfig.configFields.find((f) => f.id === dep) - if (depField?.canonicalParamId) { - for (const sibling of canonicalGroups.get(depField.canonicalParamId) ?? []) { - allDependents.add(sibling.id) - } - } - } - } - if (allDependents.size > 0) { - for (const field of group) { - map.set(field.id, new Set(allDependents)) - } - } - } - const result = new Map() - for (const [key, value] of map) result.set(key, [...value]) - return result - }, [connectorConfig, canonicalGroups]) + const { + sourceConfig, + setSourceConfig, + canonicalModes, + setCanonicalModes, + canonicalGroups, + isFieldVisible, + handleFieldChange, + toggleCanonicalMode, + resolveSourceConfig, + } = useConnectorConfigFields({ connectorConfig }) const handleSelectType = (type: string) => { setSelectedType(type) @@ -170,64 +124,6 @@ export function AddConnectorModal({ onConnectorTypeChange?.(type) } - const handleFieldChange = useCallback( - (fieldId: string, value: string) => { - setSourceConfig((prev) => { - const next = { ...prev, [fieldId]: value } - const toClear = dependentFieldIds.get(fieldId) - if (toClear) { - for (const depId of toClear) { - next[depId] = '' - } - } - return next - }) - }, - [dependentFieldIds] - ) - - const toggleCanonicalMode = useCallback((canonicalId: string) => { - setCanonicalModes((prev) => ({ - ...prev, - [canonicalId]: prev[canonicalId] === 'advanced' ? 'basic' : 'advanced', - })) - }, []) - - const isFieldVisible = useCallback( - (field: ConnectorConfigField): boolean => { - if (!field.canonicalParamId || !field.mode) return true - const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic' - return field.mode === activeMode - }, - [canonicalModes] - ) - - const resolveSourceConfig = useCallback((): Record => { - const resolved: Record = {} - const processedCanonicals = new Set() - - if (!connectorConfig) return resolved - - for (const field of connectorConfig.configFields) { - if (field.canonicalParamId) { - if (processedCanonicals.has(field.canonicalParamId)) continue - processedCanonicals.add(field.canonicalParamId) - - const group = canonicalGroups.get(field.canonicalParamId) - if (!group) continue - - const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic' - const activeField = group.find((f) => f.mode === activeMode) ?? group[0] - const value = sourceConfig[activeField.id] - if (value) resolved[field.canonicalParamId] = value - } else { - if (sourceConfig[field.id]) resolved[field.id] = sourceConfig[field.id] - } - } - - return resolved - }, [connectorConfig, canonicalGroups, canonicalModes, sourceConfig]) - const canSubmit = useMemo(() => { if (!connectorConfig) return false if (isApiKeyMode) { @@ -256,7 +152,10 @@ export function AddConnectorModal({ setError(null) - const resolvedConfig = resolveSourceConfig() + const resolvedConfig: Record = {} + for (const [key, value] of Object.entries(resolveSourceConfig())) { + if (value) resolvedConfig[key] = value + } const finalSourceConfig = disabledTagIds.size > 0 ? { ...resolvedConfig, disabledTagIds: Array.from(disabledTagIds) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 87f02eb4c19..162b91555ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { ArrowLeftRight, ExternalLink, Loader2, RotateCcw } from 'lucide-react' import { @@ -26,8 +26,8 @@ import { getSubscriptionAccessState } from '@/lib/billing/client' import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' +import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' -import { getDependsOnFields } from '@/blocks/utils' import { CONNECTOR_REGISTRY } from '@/connectors/registry' import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types' import type { ConnectorData } from '@/hooks/queries/kb/connectors' @@ -61,11 +61,14 @@ export function EditConnectorModal({ const connectorConfig = CONNECTOR_REGISTRY[connector.connectorType] ?? null const [activeTab, setActiveTab] = useState('settings') + const [syncInterval, setSyncInterval] = useState(connector.syncIntervalMinutes) + const [error, setError] = useState(null) + /** * Seeds from the stored canonical config. For canonical-pair fields (selector + * manual input), both field IDs get the same value so toggling preserves it. */ - const [sourceConfig, setSourceConfig] = useState>(() => { + const initialSourceConfig = useMemo(() => { const config: Record = {} if (!connectorConfig) { for (const [key, value] of Object.entries(connector.sourceConfig)) { @@ -80,10 +83,19 @@ export function EditConnectorModal({ if (rawValue !== undefined) config[field.id] = String(rawValue ?? '') } return config - }) - const [syncInterval, setSyncInterval] = useState(connector.syncIntervalMinutes) - const [error, setError] = useState(null) - const [canonicalModes, setCanonicalModes] = useState>({}) + // Seed once on mount; editing state is owned by the hook afterward + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const { + sourceConfig, + canonicalModes, + canonicalGroups, + isFieldVisible, + handleFieldChange, + toggleCanonicalMode, + resolveSourceConfig, + } = useConnectorConfigFields({ connectorConfig, initialSourceConfig }) const { mutate: updateConnector, isPending: isSaving } = useUpdateConnector() @@ -91,107 +103,6 @@ export function EditConnectorModal({ const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data) const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess - const canonicalGroups = useMemo(() => { - if (!connectorConfig) return new Map() - const groups = new Map() - for (const field of connectorConfig.configFields) { - if (field.canonicalParamId) { - const existing = groups.get(field.canonicalParamId) - if (existing) existing.push(field) - else groups.set(field.canonicalParamId, [field]) - } - } - return groups - }, [connectorConfig]) - - const dependentFieldIds = useMemo(() => { - if (!connectorConfig) return new Map() - const map = new Map>() - for (const field of connectorConfig.configFields) { - const deps = getDependsOnFields(field.dependsOn) - for (const dep of deps) { - const existing = map.get(dep) ?? new Set() - existing.add(field.id) - if (field.canonicalParamId) { - for (const sibling of canonicalGroups.get(field.canonicalParamId) ?? []) { - existing.add(sibling.id) - } - } - map.set(dep, existing) - } - } - for (const group of canonicalGroups.values()) { - const allDependents = new Set() - for (const field of group) { - for (const dep of map.get(field.id) ?? []) { - allDependents.add(dep) - const depField = connectorConfig.configFields.find((f) => f.id === dep) - if (depField?.canonicalParamId) { - for (const sibling of canonicalGroups.get(depField.canonicalParamId) ?? []) { - allDependents.add(sibling.id) - } - } - } - } - if (allDependents.size > 0) { - for (const field of group) map.set(field.id, new Set(allDependents)) - } - } - const result = new Map() - for (const [key, value] of map) result.set(key, [...value]) - return result - }, [connectorConfig, canonicalGroups]) - - const isFieldVisible = (field: ConnectorConfigField): boolean => { - if (!field.canonicalParamId || !field.mode) return true - const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic' - return field.mode === activeMode - } - - const handleFieldChange = (fieldId: string, value: string) => { - setSourceConfig((prev) => { - const next = { ...prev, [fieldId]: value } - const toClear = dependentFieldIds.get(fieldId) - if (toClear) { - for (const depId of toClear) next[depId] = '' - } - return next - }) - } - - const toggleCanonicalMode = (canonicalId: string) => { - setCanonicalModes((prev) => ({ - ...prev, - [canonicalId]: prev[canonicalId] === 'advanced' ? 'basic' : 'advanced', - })) - } - - /** - * Collapse the canonical-pair state back to a flat map keyed by canonical IDs - * (matching what's stored in `connector.sourceConfig`). - */ - const resolveSourceConfig = useCallback((): Record => { - const resolved: Record = {} - const processedCanonicals = new Set() - if (!connectorConfig) return resolved - - for (const field of connectorConfig.configFields) { - if (field.canonicalParamId) { - if (processedCanonicals.has(field.canonicalParamId)) continue - processedCanonicals.add(field.canonicalParamId) - const group = canonicalGroups.get(field.canonicalParamId) - if (!group) continue - const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic' - const activeField = group.find((f) => f.mode === activeMode) ?? group[0] - const value = sourceConfig[activeField.id] ?? '' - resolved[field.canonicalParamId] = value - } else { - resolved[field.id] = sourceConfig[field.id] ?? '' - } - } - return resolved - }, [connectorConfig, canonicalGroups, canonicalModes, sourceConfig]) - const hasChanges = useMemo(() => { if (syncInterval !== connector.syncIntervalMinutes) return true const resolved = resolveSourceConfig() diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts new file mode 100644 index 00000000000..54ff7c16906 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts @@ -0,0 +1,153 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { getDependsOnFields } from '@/blocks/utils' +import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types' + +export interface UseConnectorConfigFieldsOptions { + connectorConfig: ConnectorConfig | null + initialSourceConfig?: Record +} + +export interface UseConnectorConfigFieldsResult { + sourceConfig: Record + setSourceConfig: React.Dispatch>> + canonicalModes: Record + setCanonicalModes: React.Dispatch>> + canonicalGroups: Map + isFieldVisible: (field: ConnectorConfigField) => boolean + handleFieldChange: (fieldId: string, value: string) => void + toggleCanonicalMode: (canonicalId: string) => void + resolveSourceConfig: () => Record +} + +/** + * Shared state and helpers for connector configuration fields that support + * canonical pairs (selector + manual input sharing a `canonicalParamId`). + * + * - Tracks current field values and active mode (basic/advanced) per canonical group. + * - Computes the dependency graph including canonical-sibling expansion so that + * changing a dependency clears both siblings of any dependent canonical pair. + * - Returns `resolveSourceConfig` which collapses the per-field map back to a + * canonical-keyed object ready to submit. + */ +export function useConnectorConfigFields({ + connectorConfig, + initialSourceConfig, +}: UseConnectorConfigFieldsOptions): UseConnectorConfigFieldsResult { + const [sourceConfig, setSourceConfig] = useState>( + () => initialSourceConfig ?? {} + ) + const [canonicalModes, setCanonicalModes] = useState>({}) + + const canonicalGroups = useMemo(() => { + const groups = new Map() + if (!connectorConfig) return groups + for (const field of connectorConfig.configFields) { + if (!field.canonicalParamId) continue + const existing = groups.get(field.canonicalParamId) + if (existing) existing.push(field) + else groups.set(field.canonicalParamId, [field]) + } + return groups + }, [connectorConfig]) + + const dependentFieldIds = useMemo(() => { + const result = new Map() + if (!connectorConfig) return result + + const map = new Map>() + for (const field of connectorConfig.configFields) { + const deps = getDependsOnFields(field.dependsOn) + for (const dep of deps) { + const existing = map.get(dep) ?? new Set() + existing.add(field.id) + if (field.canonicalParamId) { + for (const sibling of canonicalGroups.get(field.canonicalParamId) ?? []) { + existing.add(sibling.id) + } + } + map.set(dep, existing) + } + } + for (const group of canonicalGroups.values()) { + const allDependents = new Set() + for (const field of group) { + for (const dep of map.get(field.id) ?? []) { + allDependents.add(dep) + const depField = connectorConfig.configFields.find((f) => f.id === dep) + if (depField?.canonicalParamId) { + for (const sibling of canonicalGroups.get(depField.canonicalParamId) ?? []) { + allDependents.add(sibling.id) + } + } + } + } + if (allDependents.size > 0) { + for (const field of group) map.set(field.id, new Set(allDependents)) + } + } + for (const [key, value] of map) result.set(key, [...value]) + return result + }, [connectorConfig, canonicalGroups]) + + const isFieldVisible = useCallback( + (field: ConnectorConfigField): boolean => { + if (!field.canonicalParamId || !field.mode) return true + const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic' + return field.mode === activeMode + }, + [canonicalModes] + ) + + const handleFieldChange = (fieldId: string, value: string) => { + setSourceConfig((prev) => { + const next = { ...prev, [fieldId]: value } + const toClear = dependentFieldIds.get(fieldId) + if (toClear) { + for (const depId of toClear) next[depId] = '' + } + return next + }) + } + + const toggleCanonicalMode = (canonicalId: string) => { + setCanonicalModes((prev) => ({ + ...prev, + [canonicalId]: prev[canonicalId] === 'advanced' ? 'basic' : 'advanced', + })) + } + + const resolveSourceConfig = useCallback((): Record => { + const resolved: Record = {} + const processed = new Set() + if (!connectorConfig) return resolved + + for (const field of connectorConfig.configFields) { + if (field.canonicalParamId) { + if (processed.has(field.canonicalParamId)) continue + processed.add(field.canonicalParamId) + const group = canonicalGroups.get(field.canonicalParamId) + if (!group) continue + const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic' + const activeField = group.find((f) => f.mode === activeMode) ?? group[0] + resolved[field.canonicalParamId] = sourceConfig[activeField.id] ?? '' + } else { + resolved[field.id] = sourceConfig[field.id] ?? '' + } + } + return resolved + }, [connectorConfig, canonicalGroups, canonicalModes, sourceConfig]) + + return { + sourceConfig, + setSourceConfig, + canonicalModes, + setCanonicalModes, + canonicalGroups, + isFieldVisible, + handleFieldChange, + toggleCanonicalMode, + resolveSourceConfig, + } +} From e6f17ca2a8adcbad83107a56805b840aa663506a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Apr 2026 20:24:15 -0700 Subject: [PATCH 4/5] fix(kb-connectors): only merge changed fields into sourceConfig on edit save Avoids writing spurious empty-string keys for untouched optional fields when another field triggers a save. --- .../edit-connector-modal/edit-connector-modal.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 162b91555ae..984a2ec5b06 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -122,11 +122,12 @@ export function EditConnectorModal({ } const resolved = resolveSourceConfig() - const configChanged = Object.entries(resolved).some( - ([key, value]) => String(connector.sourceConfig[key] ?? '') !== value - ) - if (configChanged) { - updates.sourceConfig = { ...connector.sourceConfig, ...resolved } + const changedEntries: Record = {} + for (const [key, value] of Object.entries(resolved)) { + if (String(connector.sourceConfig[key] ?? '') !== value) changedEntries[key] = value + } + if (Object.keys(changedEntries).length > 0) { + updates.sourceConfig = { ...connector.sourceConfig, ...changedEntries } } if (Object.keys(updates).length === 0) { From 5505e1c58e20f0d1c0cbc5cec59ead14a6aaa6a6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Apr 2026 20:33:46 -0700 Subject: [PATCH 5/5] refactor(kb-connectors): tighten state primitives in modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - edit modal: replace useMemo([]) + eslint-disable with useState lazy initializer for initialSourceConfig — same mount-once semantics without the escape hatch. - add modal: drop useCallback on handleConnectNewAccount (no observer saw the reference) and inline the one call site. --- .../add-connector-modal/add-connector-modal.tsx | 10 ++-------- .../edit-connector-modal/edit-connector-modal.tsx | 7 +++---- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 38815185831..fc6f2b0eaeb 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { ArrowLeft, ArrowLeftRight, Loader2, Plus, Search } from 'lucide-react' import { useParams } from 'next/navigation' import { @@ -180,10 +180,6 @@ export function AddConnectorModal({ ) } - const handleConnectNewAccount = useCallback(() => { - setShowOAuthModal(true) - }, []) - const filteredEntries = useMemo(() => { const term = searchTerm.toLowerCase().trim() if (!term) return CONNECTOR_ENTRIES @@ -291,9 +287,7 @@ export function AddConnectorModal({ : `Connect ${connectorConfig.name} account`, value: '__connect_new__', icon: Plus, - onSelect: () => { - void handleConnectNewAccount() - }, + onSelect: () => setShowOAuthModal(true), }, ]} value={effectiveCredentialId ?? undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 984a2ec5b06..097ccfedcc7 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -67,8 +67,9 @@ export function EditConnectorModal({ /** * Seeds from the stored canonical config. For canonical-pair fields (selector + * manual input), both field IDs get the same value so toggling preserves it. + * Captured once on mount; editing state is owned by the hook afterward. */ - const initialSourceConfig = useMemo(() => { + const [initialSourceConfig] = useState>(() => { const config: Record = {} if (!connectorConfig) { for (const [key, value] of Object.entries(connector.sourceConfig)) { @@ -83,9 +84,7 @@ export function EditConnectorModal({ if (rawValue !== undefined) config[field.id] = String(rawValue ?? '') } return config - // Seed once on mount; editing state is owned by the hook afterward - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }) const { sourceConfig,