From f58356f74c2d85118626dedd44fae722b828a4cf Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 14 Mar 2026 01:58:33 -0700 Subject: [PATCH 1/7] fix(connectors): align connector scopes with oauth config and fix kb modal UX --- .../add-connector-modal/add-connector-modal.tsx | 17 +++++------------ .../connectors-section/connectors-section.tsx | 2 -- apps/sim/connectors/github/github.ts | 6 +++++- apps/sim/connectors/gmail/gmail.ts | 2 +- .../google-calendar/google-calendar.ts | 2 +- .../connectors/google-sheets/google-sheets.ts | 2 +- apps/sim/connectors/reddit/reddit.ts | 1 + 7 files changed, 14 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index a80d039822..f8f0689a30 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -57,6 +57,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo const [showOAuthModal, setShowOAuthModal] = useState(false) const [apiKeyValue, setApiKeyValue] = useState('') + const [apiKeyFocused, setApiKeyFocused] = useState(false) const [searchTerm, setSearchTerm] = useState('') const { workspaceId } = useParams<{ workspaceId: string }>() @@ -235,10 +236,12 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo : 'API Key'} setApiKeyValue(e.target.value)} + onFocus={() => setApiKeyFocused(true)} + onBlur={() => setApiKeyFocused(false)} placeholder={ connectorConfig.auth.mode === 'apiKey' && connectorConfig.auth.placeholder ? connectorConfig.auth.placeholder @@ -345,17 +348,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo > { - setDisabledTagIds((prev) => { - const next = new Set(prev) - if (checked) { - next.delete(tagDef.id) - } else { - next.add(tagDef.id) - } - return next - }) - }} + onCheckedChange={() => {}} /> {tagDef.displayName} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index d8049fc90f..01d2245851 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -130,8 +130,6 @@ export function ConnectorsSection({ return (
-

Connected Sources

- {error && (

{error}

)} diff --git a/apps/sim/connectors/github/github.ts b/apps/sim/connectors/github/github.ts index 32d7fa9dab..80e9ca3242 100644 --- a/apps/sim/connectors/github/github.ts +++ b/apps/sim/connectors/github/github.ts @@ -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: [ { diff --git a/apps/sim/connectors/gmail/gmail.ts b/apps/sim/connectors/gmail/gmail.ts index 9e29136455..26e66752db 100644 --- a/apps/sim/connectors/gmail/gmail.ts +++ b/apps/sim/connectors/gmail/gmail.ts @@ -291,7 +291,7 @@ 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: [ diff --git a/apps/sim/connectors/google-calendar/google-calendar.ts b/apps/sim/connectors/google-calendar/google-calendar.ts index d64b162091..beaf76b4e6 100644 --- a/apps/sim/connectors/google-calendar/google-calendar.ts +++ b/apps/sim/connectors/google-calendar/google-calendar.ts @@ -237,7 +237,7 @@ 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: [ diff --git a/apps/sim/connectors/google-sheets/google-sheets.ts b/apps/sim/connectors/google-sheets/google-sheets.ts index 089ca78a40..3a094bc965 100644 --- a/apps/sim/connectors/google-sheets/google-sheets.ts +++ b/apps/sim/connectors/google-sheets/google-sheets.ts @@ -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: [ diff --git a/apps/sim/connectors/reddit/reddit.ts b/apps/sim/connectors/reddit/reddit.ts index 1b66e90092..f4449b5442 100644 --- a/apps/sim/connectors/reddit/reddit.ts +++ b/apps/sim/connectors/reddit/reddit.ts @@ -241,6 +241,7 @@ export const redditConnector: ConnectorConfig = { auth: { mode: 'oauth', provider: 'reddit', + requiredScopes: ['read'], }, configFields: [ From 9a31d9a4f5a50df896f9465657e9162bb568102c Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 14 Mar 2026 02:05:19 -0700 Subject: [PATCH 2/7] fix(connectors): restore onCheckedChange for keyboard accessibility --- .../add-connector-modal/add-connector-modal.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index f8f0689a30..90d0ebeb15 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -177,7 +177,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo return ( <> !isCreating && onOpenChange(val)}> - + {step === 'configure' && (
-
+
{filteredEntries.map(([type, config]) => ( {}} + onClick={(e) => e.stopPropagation()} + onCheckedChange={(checked) => { + setDisabledTagIds((prev) => { + const next = new Set(prev) + if (checked) { + next.delete(tagDef.id) + } else { + next.add(tagDef.id) + } + return next + }) + }} /> {tagDef.displayName} From 607b07b10daa77d8315d7dc7ac3eebf7719950e7 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 14 Mar 2026 03:03:04 -0700 Subject: [PATCH 3/7] feat(connectors): add dynamic selectors to knowledge base connector config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/commands/add-connector.md | 138 ++++++++++ .../add-connector-modal.tsx | 249 ++++++++++++++---- .../components/connector-selector-field.tsx | 97 +++++++ apps/sim/connectors/airtable/airtable.ts | 25 ++ apps/sim/connectors/asana/asana.ts | 12 + apps/sim/connectors/confluence/confluence.ts | 13 + apps/sim/connectors/gmail/gmail.ts | 13 + .../google-calendar/google-calendar.ts | 13 + apps/sim/connectors/jira/jira.ts | 13 + apps/sim/connectors/linear/linear.ts | 25 ++ .../microsoft-teams/microsoft-teams.ts | 25 ++ apps/sim/connectors/notion/notion.ts | 12 + apps/sim/connectors/outlook/outlook.ts | 12 + apps/sim/connectors/slack/slack.ts | 13 + apps/sim/connectors/types.ts | 13 +- apps/sim/connectors/webflow/webflow.ts | 12 + 16 files changed, 636 insertions(+), 49 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field.tsx diff --git a/.claude/commands/add-connector.md b/.claude/commands/add-connector.md index 905dbb233f..4635211ffe 100644 --- a/.claude/commands/add-connector.md +++ b/.claude/commands/add-connector.md @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 90d0ebeb15..ab9b0f475a 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -1,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,6 +61,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo const [disabledTagIds, setDisabledTagIds] = useState>(() => new Set()) const [error, setError] = useState(null) const [showOAuthModal, setShowOAuthModal] = useState(false) + const [canonicalModes, setCanonicalModes] = useState>({}) const [apiKeyValue, setApiKeyValue] = useState('') const [apiKeyFocused, setApiKeyFocused] = useState(false) @@ -82,17 +89,119 @@ 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() + const groups = new Map() + for (const field of connectorConfig.configFields) { + if (field.canonicalParamId) { + const existing = groups.get(field.canonicalParamId) + if (existing) { + existing.push(field) + } else { + groups.set(field.canonicalParamId, [field]) + } + } + } + return groups + }, [connectorConfig]) + + const dependentFieldIds = useMemo(() => { + if (!connectorConfig) return new Map() + const map = new Map() + for (const field of connectorConfig.configFields) { + const deps = getDependsOnFields(field.dependsOn) + for (const dep of deps) { + const existing = map.get(dep) ?? [] + existing.push(field.id) + map.set(dep, existing) + } + } + for (const group of canonicalGroups.values()) { + const allDependents = new Set() + for (const field of group) { + for (const dep of map.get(field.id) ?? []) { + allDependents.add(dep) + } + } + 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('') 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 => { + const resolved: Record = {} + const processedCanonicals = new Set() + + if (!connectorConfig) return resolved + + for (const field of connectorConfig.configFields) { + if (field.canonicalParamId) { + if (processedCanonicals.has(field.canonicalParamId)) continue + processedCanonicals.add(field.canonicalParamId) + + const group = canonicalGroups.get(field.canonicalParamId) + if (!group) continue + + const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic' + const activeField = group.find((f) => f.mode === activeMode) ?? group[0] + const value = sourceConfig[activeField.id] + if (value) resolved[field.canonicalParamId] = value + } else { + if (sourceConfig[field.id]) resolved[field.id] = sourceConfig[field.id] + } + } + + return resolved + }, [connectorConfig, canonicalGroups, canonicalModes, sourceConfig]) + const canSubmit = useMemo(() => { if (!connectorConfig) return false if (isApiKeyMode) { @@ -100,20 +209,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( { @@ -163,16 +284,14 @@ 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 ( <> @@ -217,7 +336,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo ))} {filteredEntries.length === 0 && (
- {connectorEntries.length === 0 + {CONNECTOR_ENTRIES.length === 0 ? 'No connectors available.' : `No sources found matching "${searchTerm}"`}
@@ -290,41 +409,75 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo )} {/* Config fields */} - {connectorConfig.configFields.map((field) => ( -
-
+ ) + })} {/* Tag definitions (opt-out) */} {connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field.tsx new file mode 100644 index 0000000000..2cb831f73d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field.tsx @@ -0,0 +1,97 @@ +'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 + configFields: ConnectorConfigField[] + disabled?: boolean +} + +export function ConnectorSelectorField({ + field, + value, + onChange, + credentialId, + sourceConfig, + configFields, + disabled, +}: ConnectorSelectorFieldProps) { + const context = useMemo(() => { + 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 = sourceConfig[depFieldId] + if (depValue && SELECTOR_CONTEXT_FIELDS.has(canonicalId as keyof SelectorContext)) { + ctx[canonicalId as keyof SelectorContext] = depValue + } + } + + return ctx + }, [credentialId, field.dependsOn, sourceConfig, configFields]) + + const depsResolved = useMemo(() => { + if (!field.dependsOn) return true + const deps = Array.isArray(field.dependsOn) ? field.dependsOn : (field.dependsOn.all ?? []) + return deps.every((depId) => Boolean(sourceConfig[depId]?.trim())) + }, [field.dependsOn, sourceConfig]) + + const isEnabled = !disabled && !!credentialId && depsResolved + const { data: options = [], isLoading } = useSelectorOptions(field.selectorKey, { + context, + enabled: isEnabled, + }) + + const comboboxOptions = useMemo( + () => options.map((opt) => ({ label: opt.label, value: opt.id })), + [options] + ) + + if (isLoading && isEnabled) { + return ( +
+ + Loading... +
+ ) + } + + return ( + + ) +} + +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' +} diff --git a/apps/sim/connectors/airtable/airtable.ts b/apps/sim/connectors/airtable/airtable.ts index 39914881b9..84ac42380a 100644 --- a/apps/sim/connectors/airtable/airtable.ts +++ b/apps/sim/connectors/airtable/airtable.ts @@ -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, }, diff --git a/apps/sim/connectors/asana/asana.ts b/apps/sim/connectors/asana/asana.ts index 413a273dca..e6febeb821 100644 --- a/apps/sim/connectors/asana/asana.ts +++ b/apps/sim/connectors/asana/asana.ts @@ -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, }, diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index 300ab690d3..6ae872409e 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -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, }, diff --git a/apps/sim/connectors/gmail/gmail.ts b/apps/sim/connectors/gmail/gmail.ts index 26e66752db..2806664bed 100644 --- a/apps/sim/connectors/gmail/gmail.ts +++ b/apps/sim/connectors/gmail/gmail.ts @@ -295,10 +295,23 @@ export const gmailConnector: ConnectorConfig = { }, 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.', diff --git a/apps/sim/connectors/google-calendar/google-calendar.ts b/apps/sim/connectors/google-calendar/google-calendar.ts index beaf76b4e6..29d97fc7d2 100644 --- a/apps/sim/connectors/google-calendar/google-calendar.ts +++ b/apps/sim/connectors/google-calendar/google-calendar.ts @@ -241,10 +241,23 @@ export const googleCalendarConnector: ConnectorConfig = { }, 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.', diff --git a/apps/sim/connectors/jira/jira.ts b/apps/sim/connectors/jira/jira.ts index 5f382d8292..2cbd084cf6 100644 --- a/apps/sim/connectors/jira/jira.ts +++ b/apps/sim/connectors/jira/jira.ts @@ -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, }, diff --git a/apps/sim/connectors/linear/linear.ts b/apps/sim/connectors/linear/linear.ts index 82e93dc7bf..72333d1c86 100644 --- a/apps/sim/connectors/linear/linear.ts +++ b/apps/sim/connectors/linear/linear.ts @@ -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, }, diff --git a/apps/sim/connectors/microsoft-teams/microsoft-teams.ts b/apps/sim/connectors/microsoft-teams/microsoft-teams.ts index c2e2887fbd..f59add9693 100644 --- a/apps/sim/connectors/microsoft-teams/microsoft-teams.ts +++ b/apps/sim/connectors/microsoft-teams/microsoft-teams.ts @@ -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', diff --git a/apps/sim/connectors/notion/notion.ts b/apps/sim/connectors/notion/notion.ts index dd03782bfb..6062177102 100644 --- a/apps/sim/connectors/notion/notion.ts +++ b/apps/sim/connectors/notion/notion.ts @@ -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', }, diff --git a/apps/sim/connectors/outlook/outlook.ts b/apps/sim/connectors/outlook/outlook.ts index 80adb4b6bb..34db1b1874 100644 --- a/apps/sim/connectors/outlook/outlook.ts +++ b/apps/sim/connectors/outlook/outlook.ts @@ -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' }, diff --git a/apps/sim/connectors/slack/slack.ts b/apps/sim/connectors/slack/slack.ts index 6ea456058f..79e3d34b26 100644 --- a/apps/sim/connectors/slack/slack.ts +++ b/apps/sim/connectors/slack/slack.ts @@ -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', diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index 0371b09a34..79c19b8355 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -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 } /** diff --git a/apps/sim/connectors/webflow/webflow.ts b/apps/sim/connectors/webflow/webflow.ts index 30c13dd72d..ff0b64c47d 100644 --- a/apps/sim/connectors/webflow/webflow.ts +++ b/apps/sim/connectors/webflow/webflow.ts @@ -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, }, From 4b7d9a786c185dd180df721a98a4999b3480bb09 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 14 Mar 2026 03:06:06 -0700 Subject: [PATCH 4/7] updated animated blocks UI --- apps/docs/components/ui/animated-blocks.tsx | 244 +++----------------- 1 file changed, 34 insertions(+), 210 deletions(-) diff --git a/apps/docs/components/ui/animated-blocks.tsx b/apps/docs/components/ui/animated-blocks.tsx index 57ed2abe82..23f1047a60 100644 --- a/apps/docs/components/ui/animated-blocks.tsx +++ b/apps/docs/components/ui/animated-blocks.tsx @@ -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)', - }, - { - opacity: 1, - 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)', - }, + 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: '17.119', - y: '136.962', + x: '51.6188', + y: '16.8626', 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 -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 ( {rects.map((r, i) => ( ))} ) }) -function useGroupState(): [AnimState, (s: AnimState) => void] { - return useState('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((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 (