fix(connectors): align connector scopes with oauth config and fix kb modal UX (#3573)

* fix(connectors): align connector scopes with oauth config and fix kb modal UX

* fix(connectors): restore onCheckedChange for keyboard accessibility

* feat(connectors): add dynamic selectors to knowledge base connector config

Replace manual ID text inputs with dynamic selector dropdowns that fetch
options from the existing selector registry. Users can toggle between
selector and manual input via canonical pairs (basic/advanced mode).

Adds selector support to 12 connectors: Airtable (cascading base→table),
Slack, Gmail, Google Calendar, Linear (cascading team→project), Jira,
Confluence, MS Teams (cascading team→channel), Notion, Asana, Webflow,
and Outlook. Dependency clearing propagates across canonical siblings to
prevent stale cross-mode data on submit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* updated animated blocks UI

* fix(connectors): clear canonical siblings of dependents and resolve active mode values

Fixes three issues from PR review:
- Dependency clearing now includes canonical siblings of dependent fields
  (e.g., changing base clears both tableSelector AND tableIdOrName)
- Selector context and depsResolved now resolve dependency values through
  the active canonical mode, not just the raw depFieldId
- Tooltip text changed from "Switch to manual ID" to "Switch to manual input"
  to correctly describe dropdown fallbacks (e.g., Outlook folder)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: linter class ordering fixes and docs link update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(connectors): reset apiKeyFocused on connector re-selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-03-14 03:31:07 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 5b9f0d73c2
commit d06aa1de7e
22 changed files with 717 additions and 269 deletions
+138
View File
@@ -117,6 +117,8 @@ export const {service}Connector: ConnectorConfig = {
The add-connector modal renders these automatically — no custom UI needed.
Three field types are supported: `short-input`, `dropdown`, and `selector`.
```typescript
// Text input
{
@@ -141,6 +143,136 @@ The add-connector modal renders these automatically — no custom UI needed.
}
```
## Dynamic Selectors (Canonical Pairs)
Use `type: 'selector'` to fetch options dynamically from the existing selector registry (`hooks/selectors/registry.ts`). Selectors are always paired with a manual fallback input using the **canonical pair** pattern — a `selector` field (basic mode) and a `short-input` field (advanced mode) linked by `canonicalParamId`.
The user sees a toggle button (ArrowLeftRight) to switch between the selector dropdown and manual text input. On submit, the modal resolves each canonical pair to the active mode's value, keyed by `canonicalParamId`.
### Rules
1. **Every selector field MUST have a canonical pair** — a corresponding `short-input` (or `dropdown`) field with the same `canonicalParamId` and `mode: 'advanced'`.
2. **`required` must be set identically on both fields** in a pair. If the selector is required, the manual input must also be required.
3. **`canonicalParamId` must match the key the connector expects in `sourceConfig`** (e.g. `baseId`, `channel`, `teamId`). The advanced field's `id` should typically match `canonicalParamId`.
4. **`dependsOn` references the selector field's `id`**, not the `canonicalParamId`. The modal propagates dependency clearing across canonical siblings automatically — changing either field in a parent pair clears dependent children.
### Selector canonical pair example (Airtable base → table cascade)
```typescript
configFields: [
// Base: selector (basic) + manual (advanced)
{
id: 'baseSelector',
title: 'Base',
type: 'selector',
selectorKey: 'airtable.bases', // Must exist in hooks/selectors/registry.ts
canonicalParamId: 'baseId',
mode: 'basic',
placeholder: 'Select a base',
required: true,
},
{
id: 'baseId',
title: 'Base ID',
type: 'short-input',
canonicalParamId: 'baseId',
mode: 'advanced',
placeholder: 'e.g. appXXXXXXXXXXXXXX',
required: true,
},
// Table: selector depends on base (basic) + manual (advanced)
{
id: 'tableSelector',
title: 'Table',
type: 'selector',
selectorKey: 'airtable.tables',
canonicalParamId: 'tableIdOrName',
mode: 'basic',
dependsOn: ['baseSelector'], // References the selector field ID
placeholder: 'Select a table',
required: true,
},
{
id: 'tableIdOrName',
title: 'Table Name or ID',
type: 'short-input',
canonicalParamId: 'tableIdOrName',
mode: 'advanced',
placeholder: 'e.g. Tasks',
required: true,
},
// Non-selector fields stay as-is
{ id: 'maxRecords', title: 'Max Records', type: 'short-input', ... },
]
```
### Selector with domain dependency (Jira/Confluence pattern)
When a selector depends on a plain `short-input` field (no canonical pair), `dependsOn` references that field's `id` directly. The `domain` field's value maps to `SelectorContext.domain` automatically via `SELECTOR_CONTEXT_FIELDS`.
```typescript
configFields: [
{
id: 'domain',
title: 'Jira Domain',
type: 'short-input',
placeholder: 'yoursite.atlassian.net',
required: true,
},
{
id: 'projectSelector',
title: 'Project',
type: 'selector',
selectorKey: 'jira.projects',
canonicalParamId: 'projectKey',
mode: 'basic',
dependsOn: ['domain'],
placeholder: 'Select a project',
required: true,
},
{
id: 'projectKey',
title: 'Project Key',
type: 'short-input',
canonicalParamId: 'projectKey',
mode: 'advanced',
placeholder: 'e.g. ENG, PROJ',
required: true,
},
]
```
### How `dependsOn` maps to `SelectorContext`
The connector selector field builds a `SelectorContext` from dependency values. For the mapping to work, each dependency's `canonicalParamId` (or field `id` for non-canonical fields) must exist in `SELECTOR_CONTEXT_FIELDS` (`lib/workflows/subblocks/context.ts`):
```
oauthCredential, domain, teamId, projectId, knowledgeBaseId, planId,
siteId, collectionId, spreadsheetId, fileId, baseId, datasetId, serviceDeskId
```
### Available selector keys
Check `hooks/selectors/types.ts` for the full `SelectorKey` union. Common ones for connectors:
| SelectorKey | Context Deps | Returns |
|-------------|-------------|---------|
| `airtable.bases` | credential | Base ID + name |
| `airtable.tables` | credential, `baseId` | Table ID + name |
| `slack.channels` | credential | Channel ID + name |
| `gmail.labels` | credential | Label ID + name |
| `google.calendar` | credential | Calendar ID + name |
| `linear.teams` | credential | Team ID + name |
| `linear.projects` | credential, `teamId` | Project ID + name |
| `jira.projects` | credential, `domain` | Project key + name |
| `confluence.spaces` | credential, `domain` | Space key + name |
| `notion.databases` | credential | Database ID + name |
| `asana.workspaces` | credential | Workspace GID + name |
| `microsoft.teams` | credential | Team ID + name |
| `microsoft.channels` | credential, `teamId` | Channel ID + name |
| `webflow.sites` | credential | Site ID + name |
| `outlook.folders` | credential | Folder ID + name |
## ExternalDocument Shape
Every document returned from `listDocuments`/`getDocument` must include:
@@ -287,6 +419,12 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = {
- [ ] **Auth configured correctly:**
- OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts`
- API key: `auth.label` and `auth.placeholder` set appropriately
- [ ] **Selector fields configured correctly (if applicable):**
- Every `type: 'selector'` field has a canonical pair (`short-input` or `dropdown` with same `canonicalParamId` and `mode: 'advanced'`)
- `required` is identical on both fields in each canonical pair
- `selectorKey` exists in `hooks/selectors/registry.ts`
- `dependsOn` references selector field IDs (not `canonicalParamId`)
- Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS`
- [ ] `listDocuments` handles pagination and computes content hashes
- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative)
- [ ] `metadata` includes source-specific data for tag mapping
+34 -210
View File
@@ -1,18 +1,7 @@
'use client'
import { memo } from 'react'
import { memo, useEffect, useState } from 'react'
/** Shared corner radius from Figma export for all decorative rects. */
const RX = '2.59574'
const ENTER_STAGGER = 0.06
const ENTER_DURATION = 0.3
const EXIT_STAGGER = 0.12
const EXIT_DURATION = 0.5
const INITIAL_HOLD = 3000
const HOLD_BETWEEN = 3000
const TRANSITION_PAUSE = 400
interface BlockRect {
opacity: number
width: string
@@ -23,8 +12,6 @@ interface BlockRect {
transform?: string
}
type AnimState = 'visible' | 'exiting' | 'hidden'
const RECTS = {
topRight: [
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
@@ -67,76 +54,33 @@ const RECTS = {
fill: '#FA4EDF',
},
],
left: [
{
opacity: 0.6,
width: '34.240',
height: '33.725',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 0)',
},
{
opacity: 0.6,
width: '16.8626',
height: '68.480',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.727 0)',
},
bottomLeft: [
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{
opacity: 1,
x: '51.6188',
y: '16.8626',
width: '16.8626',
height: '16.8626',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.727 17.378)',
},
{
opacity: 0.6,
width: '16.8626',
height: '33.986',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 51.616)',
},
{
opacity: 0.6,
width: '16.8626',
height: '140.507',
fill: '#00F701',
transform: 'matrix(-1 0 0 1 33.986 85.335)',
},
{
opacity: 0.4,
x: '17.119',
y: '136.962',
width: '34.240',
height: '16.8626',
fill: '#FFCC02',
transform: 'rotate(-90 17.119 136.962)',
},
{
opacity: 1,
x: '17.119',
y: '136.962',
width: '16.8626',
height: '16.8626',
fill: '#FFCC02',
transform: 'rotate(-90 17.119 136.962)',
},
{
opacity: 0.5,
width: '34.240',
height: '33.725',
fill: '#00F701',
transform: 'matrix(0 1 1 0 0.257 153.825)',
fill: '#2ABBF8',
},
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
{
opacity: 1,
x: '123.6484',
y: '16.8626',
width: '16.8626',
height: '16.8626',
fill: '#00F701',
transform: 'matrix(0 1 1 0 0.257 153.825)',
},
],
right: [
bottomRight: [
{
opacity: 0.6,
width: '16.8626',
@@ -175,68 +119,33 @@ const RECTS = {
{
opacity: 0.6,
width: '16.8626',
height: '33.726',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0.012 68.510)',
},
{
opacity: 0.6,
width: '16.8626',
height: '102.384',
height: '34.24',
fill: '#2ABBF8',
transform: 'matrix(-1 0 0 1 33.787 102.384)',
transform: 'matrix(-1 0 0 1 33.787 68)',
},
{
opacity: 0.4,
x: '17.131',
y: '153.859',
width: '34.241',
height: '16.8626',
fill: '#00F701',
transform: 'rotate(-90 17.131 153.859)',
},
{
opacity: 1,
x: '17.131',
y: '153.859',
width: '16.8626',
height: '16.8626',
fill: '#00F701',
transform: 'rotate(-90 17.131 153.859)',
fill: '#1A8FCC',
transform: 'matrix(-1 0 0 1 33.787 85)',
},
],
} as const satisfies Record<string, readonly BlockRect[]>
type Position = keyof typeof RECTS
function enterTime(pos: Position): number {
return (RECTS[pos].length - 1) * ENTER_STAGGER + ENTER_DURATION
}
function exitTime(pos: Position): number {
return (RECTS[pos].length - 1) * EXIT_STAGGER + EXIT_DURATION
}
interface BlockGroupProps {
width: number
height: number
viewBox: string
rects: readonly BlockRect[]
animState: AnimState
globalOpacity: number
}
const GLOBAL_OPACITY = 0.55
const BlockGroup = memo(function BlockGroup({
width,
height,
viewBox,
rects,
animState,
globalOpacity,
}: BlockGroupProps) {
const isVisible = animState === 'visible'
const isExiting = animState === 'exiting'
}: {
width: number
height: number
viewBox: string
rects: readonly BlockRect[]
}) {
return (
<svg
width={width}
@@ -245,7 +154,7 @@ const BlockGroup = memo(function BlockGroup({
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='h-auto w-full'
style={{ opacity: globalOpacity }}
style={{ opacity: GLOBAL_OPACITY }}
>
{rects.map((r, i) => (
<rect
@@ -257,114 +166,29 @@ const BlockGroup = memo(function BlockGroup({
rx={RX}
fill={r.fill}
transform={r.transform}
style={{
opacity: isVisible ? r.opacity : 0,
transition: `opacity ${isExiting ? EXIT_DURATION : ENTER_DURATION}s ease ${
isVisible ? i * ENTER_STAGGER : isExiting ? i * EXIT_STAGGER : 0
}s`,
}}
opacity={r.opacity}
/>
))}
</svg>
)
})
function useGroupState(): [AnimState, (s: AnimState) => void] {
return useState<AnimState>('visible')
}
function useBlockCycle() {
const [topRight, setTopRight] = useGroupState()
const [left, setLeft] = useGroupState()
const [right, setRight] = useGroupState()
useEffect(() => {
if (typeof window !== 'undefined' && !window.matchMedia('(min-width: 1024px)').matches) return
const cancelled = { current: false }
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
async function exit(setter: (s: AnimState) => void, pos: Position, pauseAfter: number) {
if (cancelled.current) return
setter('exiting')
await wait(exitTime(pos) * 1000)
if (cancelled.current) return
setter('hidden')
await wait(pauseAfter)
}
async function enter(setter: (s: AnimState) => void, pos: Position, pauseAfter: number) {
if (cancelled.current) return
setter('visible')
await wait(enterTime(pos) * 1000 + pauseAfter)
}
const run = async () => {
await wait(INITIAL_HOLD)
while (!cancelled.current) {
await exit(setTopRight, 'topRight', TRANSITION_PAUSE)
await exit(setLeft, 'left', HOLD_BETWEEN)
await enter(setLeft, 'left', TRANSITION_PAUSE)
await enter(setTopRight, 'topRight', TRANSITION_PAUSE)
await exit(setRight, 'right', HOLD_BETWEEN)
await enter(setRight, 'right', HOLD_BETWEEN)
}
}
run()
return () => {
cancelled.current = true
}
}, [])
return { topRight, left, right } as const
}
/**
* Ambient animated block decorations for the docs layout.
* Adapts the landing page's colorful block patterns with slightly reduced
* opacity and the same staggered enter/exit animation cycle.
*/
export function AnimatedBlocks() {
const states = useBlockCycle()
return (
<div
className='pointer-events-none fixed inset-0 z-0 hidden overflow-hidden lg:block'
aria-hidden='true'
>
<div className='absolute top-[93px] right-0 w-[calc(140px+10.76vw)] max-w-[295px]'>
<BlockGroup
width={295}
height={34}
viewBox='0 0 295 34'
rects={RECTS.topRight}
animState={states.topRight}
globalOpacity={0.75}
/>
<BlockGroup width={295} height={34} viewBox='0 0 295 34' rects={RECTS.topRight} />
</div>
<div className='-translate-y-1/2 absolute top-[50%] left-0 w-[calc(16px+1.25vw)] max-w-[34px] scale-x-[-1]'>
<BlockGroup
width={34}
height={226}
viewBox='0 0 34 226.021'
rects={RECTS.left}
animState={states.left}
globalOpacity={0.75}
/>
<div className='-left-24 absolute bottom-0 w-[calc(140px+10.76vw)] max-w-[295px] rotate-180'>
<BlockGroup width={295} height={34} viewBox='0 0 295 34' rects={RECTS.bottomLeft} />
</div>
<div className='-translate-y-1/2 absolute top-[50%] right-0 w-[calc(16px+1.25vw)] max-w-[34px]'>
<BlockGroup
width={34}
height={205}
viewBox='0 0 34 204.769'
rects={RECTS.right}
animState={states.right}
globalOpacity={0.75}
/>
<div className='-bottom-2 absolute right-0 w-[calc(16px+1.25vw)] max-w-[34px]'>
<BlockGroup width={34} height={102} viewBox='0 0 34 102' rects={RECTS.bottomRight} />
</div>
</div>
)
@@ -11,7 +11,7 @@ interface NavLink {
}
const NAV_LINKS: NavLink[] = [
{ label: 'Docs', href: '/docs', icon: 'chevron' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Careers', href: '/careers' },
{ label: 'Enterprise', href: '/enterprise' },
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { ArrowLeft, Loader2, Plus, Search } from 'lucide-react'
import { ArrowLeft, ArrowLeftRight, Loader2, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -17,6 +17,7 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import {
@@ -24,11 +25,14 @@ import {
getProviderIdFromServiceId,
type OAuthProvider,
} from '@/lib/oauth'
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
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 { useCreateConnector } from '@/hooks/queries/kb/connectors'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import type { SelectorKey } from '@/hooks/selectors/types'
const SYNC_INTERVALS = [
{ label: 'Every hour', value: 60 },
@@ -38,6 +42,8 @@ const SYNC_INTERVALS = [
{ label: 'Manual only', value: 0 },
] as const
const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
interface AddConnectorModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -55,8 +61,10 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
const [disabledTagIds, setDisabledTagIds] = useState<Set<string>>(() => new Set())
const [error, setError] = useState<string | null>(null)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [canonicalModes, setCanonicalModes] = useState<Record<string, 'basic' | 'advanced'>>({})
const [apiKeyValue, setApiKeyValue] = useState('')
const [apiKeyFocused, setApiKeyFocused] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const { workspaceId } = useParams<{ workspaceId: string }>()
@@ -81,17 +89,126 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
const effectiveCredentialId =
selectedCredentialId ?? (credentials.length === 1 ? credentials[0].id : null)
const canonicalGroups = useMemo(() => {
if (!connectorConfig) return new Map<string, ConnectorConfigField[]>()
const groups = new Map<string, ConnectorConfigField[]>()
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<string, string[]>()
const map = new Map<string, string[]>()
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<string>()
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 handleSelectType = (type: string) => {
setSelectedType(type)
setSourceConfig({})
setSelectedCredentialId(null)
setApiKeyValue('')
setApiKeyFocused(false)
setDisabledTagIds(new Set())
setCanonicalModes({})
setError(null)
setSearchTerm('')
setStep('configure')
}
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<string, string> => {
const resolved: Record<string, string> = {}
const processedCanonicals = new Set<string>()
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) {
@@ -99,20 +216,32 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
} else {
if (!effectiveCredentialId) return false
}
return connectorConfig.configFields
.filter((f) => f.required)
.every((f) => sourceConfig[f.id]?.trim())
}, [connectorConfig, isApiKeyMode, apiKeyValue, effectiveCredentialId, sourceConfig])
for (const field of connectorConfig.configFields) {
if (!field.required) continue
if (!isFieldVisible(field)) continue
if (!sourceConfig[field.id]?.trim()) return false
}
return true
}, [
connectorConfig,
isApiKeyMode,
apiKeyValue,
effectiveCredentialId,
sourceConfig,
isFieldVisible,
])
const handleSubmit = () => {
if (!selectedType || !canSubmit) return
setError(null)
const resolvedConfig = resolveSourceConfig()
const finalSourceConfig =
disabledTagIds.size > 0
? { ...sourceConfig, disabledTagIds: Array.from(disabledTagIds) }
: sourceConfig
? { ...resolvedConfig, disabledTagIds: Array.from(disabledTagIds) }
: resolvedConfig
createConnector(
{
@@ -162,21 +291,19 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
setShowOAuthModal(true)
}, [connectorConfig, connectorProviderId, workspaceId, session?.user?.name])
const connectorEntries = Object.entries(CONNECTOR_REGISTRY)
const filteredEntries = useMemo(() => {
const term = searchTerm.toLowerCase().trim()
if (!term) return connectorEntries
return connectorEntries.filter(
if (!term) return CONNECTOR_ENTRIES
return CONNECTOR_ENTRIES.filter(
([, config]) =>
config.name.toLowerCase().includes(term) || config.description.toLowerCase().includes(term)
)
}, [connectorEntries, searchTerm])
}, [searchTerm])
return (
<>
<Modal open={open} onOpenChange={(val) => !isCreating && onOpenChange(val)}>
<ModalContent size='md'>
<ModalContent size='md' className='h-[80vh] max-h-[560px]'>
<ModalHeader>
{step === 'configure' && (
<Button
@@ -205,7 +332,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='max-h-[400px] min-h-0 overflow-y-auto'>
<div className='min-h-[400px] overflow-y-auto'>
<div className='flex flex-col gap-[2px]'>
{filteredEntries.map(([type, config]) => (
<ConnectorTypeCard
@@ -216,7 +343,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
))}
{filteredEntries.length === 0 && (
<div className='py-[16px] text-center text-[14px] text-[var(--text-muted)]'>
{connectorEntries.length === 0
{CONNECTOR_ENTRIES.length === 0
? 'No connectors available.'
: `No sources found matching "${searchTerm}"`}
</div>
@@ -235,10 +362,12 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
: 'API Key'}
</Label>
<Input
type='password'
type={apiKeyFocused ? 'text' : 'password'}
autoComplete='new-password'
value={apiKeyValue}
onChange={(e) => setApiKeyValue(e.target.value)}
onFocus={() => setApiKeyFocused(true)}
onBlur={() => setApiKeyFocused(false)}
placeholder={
connectorConfig.auth.mode === 'apiKey' && connectorConfig.auth.placeholder
? connectorConfig.auth.placeholder
@@ -287,41 +416,76 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
)}
{/* Config fields */}
{connectorConfig.configFields.map((field) => (
<div key={field.id} className='flex flex-col gap-[4px]'>
<Label>
{field.title}
{field.required && (
<span className='ml-[2px] text-[var(--text-error)]'>*</span>
{connectorConfig.configFields.map((field) => {
if (!isFieldVisible(field)) return null
const canonicalId = field.canonicalParamId
const hasCanonicalPair =
canonicalId && (canonicalGroups.get(canonicalId)?.length ?? 0) === 2
return (
<div key={field.id} className='flex flex-col gap-[4px]'>
<div className='flex items-center justify-between'>
<Label>
{field.title}
{field.required && (
<span className='ml-[2px] text-[var(--text-error)]'>*</span>
)}
</Label>
{hasCanonicalPair && canonicalId && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='flex h-[18px] w-[18px] items-center justify-center rounded-[3px] text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-secondary)]'
onClick={() => toggleCanonicalMode(canonicalId)}
>
<ArrowLeftRight className='h-[12px] w-[12px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{field.mode === 'basic'
? 'Switch to manual input'
: 'Switch to selector'}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
{field.description && (
<p className='text-[11px] text-[var(--text-muted)]'>{field.description}</p>
)}
</Label>
{field.description && (
<p className='text-[11px] text-[var(--text-muted)]'>{field.description}</p>
)}
{field.type === 'dropdown' && field.options ? (
<Combobox
size='sm'
options={field.options.map((opt) => ({
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()}`}
/>
) : (
<Input
value={sourceConfig[field.id] || ''}
onChange={(e) =>
setSourceConfig((prev) => ({ ...prev, [field.id]: e.target.value }))
}
placeholder={field.placeholder}
/>
)}
</div>
))}
{field.type === 'selector' && field.selectorKey ? (
<ConnectorSelectorField
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}
value={sourceConfig[field.id] || ''}
onChange={(value) => handleFieldChange(field.id, value)}
credentialId={effectiveCredentialId}
sourceConfig={sourceConfig}
configFields={connectorConfig.configFields}
canonicalModes={canonicalModes}
disabled={isCreating}
/>
) : field.type === 'dropdown' && field.options ? (
<Combobox
size='sm'
options={field.options.map((opt) => ({
label: opt.label,
value: opt.id,
}))}
value={sourceConfig[field.id] || undefined}
onChange={(value) => handleFieldChange(field.id, value)}
placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
/>
) : (
<Input
value={sourceConfig[field.id] || ''}
onChange={(e) => handleFieldChange(field.id, e.target.value)}
placeholder={field.placeholder}
/>
)}
</div>
)
})}
{/* Tag definitions (opt-out) */}
{connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
@@ -345,6 +509,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
>
<Checkbox
checked={!disabledTagIds.has(tagDef.id)}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => {
setDisabledTagIds((prev) => {
const next = new Set(prev)
@@ -0,0 +1,119 @@
'use client'
import { useMemo } from 'react'
import { Loader2 } from 'lucide-react'
import { Combobox, type ComboboxOption } from '@/components/emcn'
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
import { getDependsOnFields } from '@/blocks/utils'
import type { ConnectorConfigField } from '@/connectors/types'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
interface ConnectorSelectorFieldProps {
field: ConnectorConfigField & { selectorKey: SelectorKey }
value: string
onChange: (value: string) => void
credentialId: string | null
sourceConfig: Record<string, string>
configFields: ConnectorConfigField[]
canonicalModes: Record<string, 'basic' | 'advanced'>
disabled?: boolean
}
export function ConnectorSelectorField({
field,
value,
onChange,
credentialId,
sourceConfig,
configFields,
canonicalModes,
disabled,
}: ConnectorSelectorFieldProps) {
const context = useMemo<SelectorContext>(() => {
const ctx: SelectorContext = {}
if (credentialId) ctx.oauthCredential = credentialId
for (const depFieldId of getDependsOnFields(field.dependsOn)) {
const depField = configFields.find((f) => f.id === depFieldId)
const canonicalId = depField?.canonicalParamId ?? depFieldId
const depValue = resolveDepValue(depFieldId, configFields, canonicalModes, sourceConfig)
if (depValue && SELECTOR_CONTEXT_FIELDS.has(canonicalId as keyof SelectorContext)) {
ctx[canonicalId as keyof SelectorContext] = depValue
}
}
return ctx
}, [credentialId, field.dependsOn, sourceConfig, configFields, canonicalModes])
const depsResolved = useMemo(() => {
if (!field.dependsOn) return true
const deps = Array.isArray(field.dependsOn) ? field.dependsOn : (field.dependsOn.all ?? [])
return deps.every((depId) =>
Boolean(resolveDepValue(depId, configFields, canonicalModes, sourceConfig)?.trim())
)
}, [field.dependsOn, sourceConfig, configFields, canonicalModes])
const isEnabled = !disabled && !!credentialId && depsResolved
const { data: options = [], isLoading } = useSelectorOptions(field.selectorKey, {
context,
enabled: isEnabled,
})
const comboboxOptions = useMemo<ComboboxOption[]>(
() => options.map((opt) => ({ label: opt.label, value: opt.id })),
[options]
)
if (isLoading && isEnabled) {
return (
<div className='flex items-center gap-2 rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] text-[var(--text-muted)] text-sm'>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
Loading...
</div>
)
}
return (
<Combobox
options={comboboxOptions}
value={value || undefined}
onChange={onChange}
placeholder={
!credentialId
? 'Connect an account first'
: !depsResolved
? `Select ${getDependencyLabel(field, configFields)} first`
: field.placeholder || `Select ${field.title.toLowerCase()}`
}
disabled={disabled || !credentialId || !depsResolved}
/>
)
}
function resolveDepValue(
depFieldId: string,
configFields: ConnectorConfigField[],
canonicalModes: Record<string, 'basic' | 'advanced'>,
sourceConfig: Record<string, string>
): string {
const depField = configFields.find((f) => f.id === depFieldId)
if (!depField?.canonicalParamId) return sourceConfig[depFieldId] ?? ''
const activeMode = canonicalModes[depField.canonicalParamId] ?? 'basic'
if (depField.mode === activeMode) return sourceConfig[depFieldId] ?? ''
const activeField = configFields.find(
(f) => f.canonicalParamId === depField.canonicalParamId && f.mode === activeMode
)
return activeField ? (sourceConfig[activeField.id] ?? '') : (sourceConfig[depFieldId] ?? '')
}
function getDependencyLabel(
field: ConnectorConfigField,
configFields: ConnectorConfigField[]
): string {
const deps = getDependsOnFields(field.dependsOn)
const depField = deps.length > 0 ? configFields.find((f) => f.id === deps[0]) : undefined
return depField?.title?.toLowerCase() ?? 'dependency'
}
@@ -130,8 +130,6 @@ export function ConnectorsSection({
return (
<div className='mt-[16px]'>
<h2 className='font-medium text-[14px] text-[var(--text-secondary)]'>Connected Sources</h2>
{error && (
<p className='mt-[8px] text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
)}
+25
View File
@@ -85,17 +85,42 @@ export const airtableConnector: ConnectorConfig = {
},
configFields: [
{
id: 'baseSelector',
title: 'Base',
type: 'selector',
selectorKey: 'airtable.bases',
canonicalParamId: 'baseId',
mode: 'basic',
placeholder: 'Select a base',
required: true,
},
{
id: 'baseId',
title: 'Base ID',
type: 'short-input',
canonicalParamId: 'baseId',
mode: 'advanced',
placeholder: 'e.g. appXXXXXXXXXXXXXX',
required: true,
},
{
id: 'tableSelector',
title: 'Table',
type: 'selector',
selectorKey: 'airtable.tables',
canonicalParamId: 'tableIdOrName',
mode: 'basic',
dependsOn: ['baseSelector'],
placeholder: 'Select a table',
required: true,
},
{
id: 'tableIdOrName',
title: 'Table Name or ID',
type: 'short-input',
canonicalParamId: 'tableIdOrName',
mode: 'advanced',
placeholder: 'e.g. Tasks or tblXXXXXXXXXXXXXX',
required: true,
},
+12
View File
@@ -139,10 +139,22 @@ export const asanaConnector: ConnectorConfig = {
auth: { mode: 'oauth', provider: 'asana', requiredScopes: ['default'] },
configFields: [
{
id: 'workspaceSelector',
title: 'Workspace',
type: 'selector',
selectorKey: 'asana.workspaces',
canonicalParamId: 'workspace',
mode: 'basic',
placeholder: 'Select a workspace',
required: true,
},
{
id: 'workspace',
title: 'Workspace GID',
type: 'short-input',
canonicalParamId: 'workspace',
mode: 'advanced',
placeholder: 'e.g. 1234567890',
required: true,
},
@@ -124,10 +124,23 @@ export const confluenceConnector: ConnectorConfig = {
placeholder: 'yoursite.atlassian.net',
required: true,
},
{
id: 'spaceSelector',
title: 'Space',
type: 'selector',
selectorKey: 'confluence.spaces',
canonicalParamId: 'spaceKey',
mode: 'basic',
dependsOn: ['domain'],
placeholder: 'Select a space',
required: true,
},
{
id: 'spaceKey',
title: 'Space Key',
type: 'short-input',
canonicalParamId: 'spaceKey',
mode: 'advanced',
placeholder: 'e.g. ENG, PRODUCT',
required: true,
},
+5 -1
View File
@@ -158,7 +158,11 @@ export const githubConnector: ConnectorConfig = {
version: '1.0.0',
icon: GithubIcon,
auth: { mode: 'oauth', provider: 'github', requiredScopes: ['repo'] },
auth: {
mode: 'apiKey',
label: 'Personal Access Token',
placeholder: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
},
configFields: [
{
+14 -1
View File
@@ -291,14 +291,27 @@ export const gmailConnector: ConnectorConfig = {
auth: {
mode: 'oauth',
provider: 'google-email',
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
requiredScopes: ['https://www.googleapis.com/auth/gmail.modify'],
},
configFields: [
{
id: 'labelSelector',
title: 'Label',
type: 'selector',
selectorKey: 'gmail.labels',
canonicalParamId: 'label',
mode: 'basic',
placeholder: 'Select a label',
required: false,
description: 'Only sync emails with this label. Leave empty for all mail.',
},
{
id: 'label',
title: 'Label',
type: 'short-input',
canonicalParamId: 'label',
mode: 'advanced',
placeholder: 'e.g. INBOX, IMPORTANT, or a custom label name',
required: false,
description: 'Only sync emails with this label. Leave empty for all mail.',
@@ -237,14 +237,27 @@ export const googleCalendarConnector: ConnectorConfig = {
auth: {
mode: 'oauth',
provider: 'google-calendar',
requiredScopes: ['https://www.googleapis.com/auth/calendar.readonly'],
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
},
configFields: [
{
id: 'calendarSelector',
title: 'Calendar',
type: 'selector',
selectorKey: 'google.calendar',
canonicalParamId: 'calendarId',
mode: 'basic',
placeholder: 'Select a calendar',
required: false,
description: 'The calendar to sync from. Defaults to your primary calendar.',
},
{
id: 'calendarId',
title: 'Calendar ID',
type: 'short-input',
canonicalParamId: 'calendarId',
mode: 'advanced',
placeholder: 'e.g. primary (default: primary)',
required: false,
description: 'The calendar to sync from. Use "primary" for your main calendar.',
@@ -171,7 +171,7 @@ export const googleSheetsConnector: ConnectorConfig = {
auth: {
mode: 'oauth',
provider: 'google-sheets',
requiredScopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
requiredScopes: ['https://www.googleapis.com/auth/drive'],
},
configFields: [
+13
View File
@@ -91,10 +91,23 @@ export const jiraConnector: ConnectorConfig = {
placeholder: 'yoursite.atlassian.net',
required: true,
},
{
id: 'projectSelector',
title: 'Project',
type: 'selector',
selectorKey: 'jira.projects',
canonicalParamId: 'projectKey',
mode: 'basic',
dependsOn: ['domain'],
placeholder: 'Select a project',
required: true,
},
{
id: 'projectKey',
title: 'Project Key',
type: 'short-input',
canonicalParamId: 'projectKey',
mode: 'advanced',
placeholder: 'e.g. ENG, PROJ',
required: true,
},
+25
View File
@@ -193,17 +193,42 @@ export const linearConnector: ConnectorConfig = {
auth: { mode: 'oauth', provider: 'linear', requiredScopes: ['read'] },
configFields: [
{
id: 'teamSelector',
title: 'Team',
type: 'selector',
selectorKey: 'linear.teams',
canonicalParamId: 'teamId',
mode: 'basic',
placeholder: 'Select a team (optional)',
required: false,
},
{
id: 'teamId',
title: 'Team ID',
type: 'short-input',
canonicalParamId: 'teamId',
mode: 'advanced',
placeholder: 'e.g. abc123 (leave empty for all teams)',
required: false,
},
{
id: 'projectSelector',
title: 'Project',
type: 'selector',
selectorKey: 'linear.projects',
canonicalParamId: 'projectId',
mode: 'basic',
dependsOn: ['teamSelector'],
placeholder: 'Select a project (optional)',
required: false,
},
{
id: 'projectId',
title: 'Project ID',
type: 'short-input',
canonicalParamId: 'projectId',
mode: 'advanced',
placeholder: 'e.g. def456 (leave empty for all projects)',
required: false,
},
@@ -195,18 +195,43 @@ export const microsoftTeamsConnector: ConnectorConfig = {
},
configFields: [
{
id: 'teamSelector',
title: 'Team',
type: 'selector',
selectorKey: 'microsoft.teams',
canonicalParamId: 'teamId',
mode: 'basic',
placeholder: 'Select a team',
required: true,
},
{
id: 'teamId',
title: 'Team ID',
type: 'short-input',
canonicalParamId: 'teamId',
mode: 'advanced',
placeholder: 'e.g. fbe2bf47-16c8-47cf-b4a5-4b9b187c508b',
required: true,
description: 'The ID of the Microsoft Teams team',
},
{
id: 'channelSelector',
title: 'Channel',
type: 'selector',
selectorKey: 'microsoft.channels',
canonicalParamId: 'channel',
mode: 'basic',
dependsOn: ['teamSelector'],
placeholder: 'Select a channel',
required: true,
},
{
id: 'channel',
title: 'Channel',
type: 'short-input',
canonicalParamId: 'channel',
mode: 'advanced',
placeholder: 'e.g. General or 19:abc123@thread.tacv2',
required: true,
description: 'Channel name or ID to sync messages from',
+12
View File
@@ -191,10 +191,22 @@ export const notionConnector: ConnectorConfig = {
{ label: 'Specific page (and children)', id: 'page' },
],
},
{
id: 'databaseSelector',
title: 'Database',
type: 'selector',
selectorKey: 'notion.databases',
canonicalParamId: 'databaseId',
mode: 'basic',
placeholder: 'Select a database',
required: false,
},
{
id: 'databaseId',
title: 'Database ID',
type: 'short-input',
canonicalParamId: 'databaseId',
mode: 'advanced',
required: false,
placeholder: 'e.g. 8a3b5f6e-1234-5678-abcd-ef0123456789',
},
+12
View File
@@ -256,10 +256,22 @@ export const outlookConnector: ConnectorConfig = {
},
configFields: [
{
id: 'folderSelector',
title: 'Folder',
type: 'selector',
selectorKey: 'outlook.folders',
canonicalParamId: 'folder',
mode: 'basic',
placeholder: 'Select a folder',
required: false,
},
{
id: 'folder',
title: 'Folder',
type: 'dropdown',
canonicalParamId: 'folder',
mode: 'advanced',
required: false,
options: [
{ label: 'Inbox', id: 'inbox' },
+1
View File
@@ -241,6 +241,7 @@ export const redditConnector: ConnectorConfig = {
auth: {
mode: 'oauth',
provider: 'reddit',
requiredScopes: ['read'],
},
configFields: [
+13
View File
@@ -252,10 +252,23 @@ export const slackConnector: ConnectorConfig = {
},
configFields: [
{
id: 'channelSelector',
title: 'Channel',
type: 'selector',
selectorKey: 'slack.channels',
canonicalParamId: 'channel',
mode: 'basic',
placeholder: 'Select a channel',
required: true,
description: 'Channel to sync messages from',
},
{
id: 'channel',
title: 'Channel',
type: 'short-input',
canonicalParamId: 'channel',
mode: 'advanced',
placeholder: 'e.g. general or C01ABC23DEF',
required: true,
description: 'Channel name or ID to sync messages from',
+12 -1
View File
@@ -1,4 +1,5 @@
import type { OAuthService } from '@/lib/oauth/types'
import type { SelectorKey } from '@/hooks/selectors/types'
/**
* Authentication configuration for a connector.
@@ -56,11 +57,21 @@ export interface SyncResult {
export interface ConnectorConfigField {
id: string
title: string
type: 'short-input' | 'dropdown'
type: 'short-input' | 'dropdown' | 'selector'
placeholder?: string
required?: boolean
description?: string
options?: { label: string; id: string }[]
/** Selector key from the selector registry (used when type is 'selector') */
selectorKey?: SelectorKey
/** Field IDs this field depends on — clears when deps change */
dependsOn?: string[] | { all?: string[]; any?: string[] }
/** Display mode for canonical pair fields ('basic' for selector, 'advanced' for manual input) */
mode?: 'basic' | 'advanced'
/** Links selector + manual input fields that resolve to the same config key */
canonicalParamId?: string
}
/**
+12
View File
@@ -87,10 +87,22 @@ export const webflowConnector: ConnectorConfig = {
auth: { mode: 'oauth', provider: 'webflow', requiredScopes: ['sites:read', 'cms:read'] },
configFields: [
{
id: 'siteSelector',
title: 'Site',
type: 'selector',
selectorKey: 'webflow.sites',
canonicalParamId: 'siteId',
mode: 'basic',
placeholder: 'Select a site',
required: true,
},
{
id: 'siteId',
title: 'Site ID',
type: 'short-input',
canonicalParamId: 'siteId',
mode: 'advanced',
placeholder: 'Your Webflow site ID',
required: true,
},