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)', - }, - { - 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 (