mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Claude Opus 4.6
parent
5b9f0d73c2
commit
d06aa1de7e
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
+216
-51
@@ -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)
|
||||
|
||||
+119
@@ -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'
|
||||
}
|
||||
-2
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -241,6 +241,7 @@ export const redditConnector: ConnectorConfig = {
|
||||
auth: {
|
||||
mode: 'oauth',
|
||||
provider: 'reddit',
|
||||
requiredScopes: ['read'],
|
||||
},
|
||||
|
||||
configFields: [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user