From d06aa1de7e1c8cea55cabc0f53becf8c9f62ba45 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 14 Mar 2026 03:31:07 -0700 Subject: [PATCH] fix(connectors): align connector scopes with oauth config and fix kb modal UX (#3573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * chore: linter class ordering fixes and docs link update Co-Authored-By: Claude Opus 4.6 (1M context) * fix(connectors): reset apiKeyFocused on connector re-selection Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .claude/commands/add-connector.md | 138 +++++++++ apps/docs/components/ui/animated-blocks.tsx | 244 +++------------- .../app/(home)/components/navbar/navbar.tsx | 2 +- .../add-connector-modal.tsx | 267 ++++++++++++++---- .../components/connector-selector-field.tsx | 119 ++++++++ .../connectors-section/connectors-section.tsx | 2 - apps/sim/connectors/airtable/airtable.ts | 25 ++ apps/sim/connectors/asana/asana.ts | 12 + apps/sim/connectors/confluence/confluence.ts | 13 + apps/sim/connectors/github/github.ts | 6 +- apps/sim/connectors/gmail/gmail.ts | 15 +- .../google-calendar/google-calendar.ts | 15 +- .../connectors/google-sheets/google-sheets.ts | 2 +- 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/reddit/reddit.ts | 1 + apps/sim/connectors/slack/slack.ts | 13 + apps/sim/connectors/types.ts | 13 +- apps/sim/connectors/webflow/webflow.ts | 12 + 22 files changed, 717 insertions(+), 269 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/docs/components/ui/animated-blocks.tsx b/apps/docs/components/ui/animated-blocks.tsx index 57ed2abe82..adcd7ba5a2 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)', - }, + 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 -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 (