Skip to content

Commit e0551b3

Browse files
authored
improvement(kb-connectors): multi-select fields + Slack bot/app message extraction (#4711)
* improvement(kb-connectors): multi-select fields + Slack bot/app message extraction Adds multi-value support to KB connector configuration fields and applies it across 8 connectors: Jira (projects), Confluence (spaces), Slack (channels), Microsoft Teams (channels), Google Calendar (calendars), Gmail (labels), Notion (databases), and Linear (teams + projects). Each connector emits byte-identical externalId for legacy single-value configs so existing rows reconcile in place via the sync engine's externalId-keyed matching. Framework changes: - ConnectorConfigField gains `multi?: boolean` - New `parseMultiValue` helper in @/connectors/utils - useConnectorConfigFields state model upgraded to string|string[] - ConnectorSelectorField renders Combobox in multi-select mode when `field.multi` - Add/edit connector modals handle array values end-to-end Per-connector specifics: - Jira: JQL `project in (...)` for 2+ keys, `project = X` for one - Confluence: routes through CQL `space in (...)` when multi; v2 fast path stays for single+no-label; also fixes selector returning space.id instead of space.key - Slack: loops per channel emitting one document each; extracts text from attachments and Block Kit blocks (incl. nested attachment.blocks where GitHub embeds PR bodies); contentHash bumped to slack-v2: to force one-time re-embed - Microsoft Teams: loops per channel within a single team - Google Calendar: compound cursor across calendars; single-calendar keeps legacy externalId/contentHash for zero-churn - Gmail: (label:A OR label:B) with quoted-form for labels with spaces - Notion: sequential walk via JSON compound cursor; single-DB keeps bare cursor - Linear: GraphQL IdComparator.in for multi, eq for single * fix(kb-connectors): valuesEqual treats legacy scalar as equal to multi-array Existing connectors created before multi-select store sourceConfig values as scalars (e.g. projectKey: "ENG"). With the field now declared multi: true, resolveSourceConfig returns an array (["ENG"]), and the original valuesEqual fell through to a strict reference comparison — falsely flagging unsaved changes on open and triggering an unnecessary string→array shape rewrite on save. valuesEqual now normalizes both sides to string[] via CSV-split when either is an array, so persisted scalar and in-memory array of the same content compare equal. Single-value (non-multi) fields keep strict string equality. * fix(kb-connectors): GCal externalId on config downgrade, Slack silent skip, valuesEqual order - google-calendar getDocument: derive isMultiCalendar from the externalId's `:` separator instead of the current config count. Prevents duplicates when a user downgrades from multi to single calendar — previously the returned doc lost its `calendarId:` prefix and was treated as a new row by the sync engine, orphaning the original. - slack listDocuments: throw on unresolvable channel instead of silently skipping. Matches MS Teams behaviour. Silent skip would let the sync engine orphan-delete the previously indexed channel content if a bot was removed or a channel was archived/renamed mid-life. - edit-connector-modal valuesEqual: order-insensitive comparison for multi- select arrays via Set membership. Multi-select UI doesn't guarantee insertion order matches the server-returned order, so `["A","B"]` vs `["B","A"]` would otherwise flag false unsaved changes. * chore(kb-connectors): use emptyValue() fallback in isFieldPopulated for consistency Behavior unchanged — isValuePopulated('') and isValuePopulated([]) both return false — but reading the field-typed fallback inline matches the convention used elsewhere in the hook (coerceForField, handleFieldChange, resolveSourceConfig). * fix(kb-connectors): Linear projects selector loads across all selected teams When the team selector is in multi-select mode, the basic-mode projects dropdown was passing only the first team ID into the linear.projects selector context (via readFirst in resolveDepValue), so projects from other selected teams were invisible. resolveDepValue now joins multi-value parents into a CSV string so dependent selectors receive every selected parent ID. The /api/tools/linear/projects route splits the CSV teamId, fetches projects from each team in parallel, and dedupes by project ID. Single-team configs pass through unchanged (`split(",")` on a bare ID yields a one-element array). The AND-of-filters semantics in buildIssuesQuery is intentional and matches standard GraphQL filter behavior — a user filtering on teams [A,B] and projects [X,Y] gets issues in (A or B) AND (X or Y). With this fix the project dropdown now shows every project under any selected team so the user can compose the right project set. * fix(gmail-connector): always wrap OR-containing custom query, not just unwrapped ones The previous check `!/^\(.*\)$/.test(trimmedCustom)` was supposed to avoid double-wrapping an already-parenthesized expression, but it false-positives on inputs like `(from:alice) OR (from:bob)` where the parens don't bracket the whole string. Those would skip wrapping and the top-level OR would bind across the preceding label / category / date filters instead of the custom clause. Always wrap when an OR is present — double-parens are a no-op in Gmail search syntax, so `((from:a OR from:b))` parses the same as `(from:a OR from:b)`. Simpler than walking parens depth and provably safe.
1 parent 952eb12 commit e0551b3

16 files changed

Lines changed: 1091 additions & 361 deletions

File tree

apps/sim/app/api/tools/linear/projects/route.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,40 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4545
}
4646

4747
const linearClient = new LinearClient({ accessToken })
48-
let projects: Array<{ id: string; name: string }> = []
4948

50-
const team = await linearClient.team(teamId)
51-
const projectsResult = await team.projects()
52-
projects = projectsResult.nodes.map((project: Project) => ({
53-
id: project.id,
54-
name: project.name,
55-
}))
49+
/**
50+
* teamId may be a single ID or a comma-separated list when the basic-mode
51+
* team selector is in multi-select. Fetch projects from each team in
52+
* parallel and dedupe by project ID (Linear projects can be cross-team).
53+
*/
54+
const teamIds = teamId
55+
.split(',')
56+
.map((s) => s.trim())
57+
.filter(Boolean)
58+
59+
const perTeam = await Promise.all(
60+
teamIds.map(async (id) => {
61+
const team = await linearClient.team(id)
62+
const result = await team.projects()
63+
return result.nodes.map((project: Project) => ({
64+
id: project.id,
65+
name: project.name,
66+
}))
67+
})
68+
)
69+
70+
const seen = new Set<string>()
71+
const projects: Array<{ id: string; name: string }> = []
72+
for (const teamProjects of perTeam) {
73+
for (const project of teamProjects) {
74+
if (seen.has(project.id)) continue
75+
seen.add(project.id)
76+
projects.push(project)
77+
}
78+
}
5679

5780
if (projects.length === 0) {
58-
logger.info('No projects found for team', { teamId })
81+
logger.info('No projects found for team(s)', { teamIds })
5982
}
6083

6184
return NextResponse.json({ projects })

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal
2929
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
3030
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
3131
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
32+
import type { ConfigFieldValue } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
3233
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
3334
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
3435
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
@@ -108,6 +109,7 @@ export function AddConnectorModal({
108109
setCanonicalModes,
109110
canonicalGroups,
110111
isFieldVisible,
112+
isFieldPopulated,
111113
handleFieldChange,
112114
toggleCanonicalMode,
113115
resolveSourceConfig,
@@ -150,16 +152,16 @@ export function AddConnectorModal({
150152
for (const field of connectorConfig.configFields) {
151153
if (!field.required) continue
152154
if (!isFieldVisible(field)) continue
153-
if (!sourceConfig[field.id]?.trim()) return false
155+
if (!isFieldPopulated(field)) return false
154156
}
155157
return true
156158
}, [
157159
connectorConfig,
158160
isApiKeyMode,
159161
apiKeyValue,
160162
effectiveCredentialId,
161-
sourceConfig,
162163
isFieldVisible,
164+
isFieldPopulated,
163165
])
164166

165167
const handleSubmit = () => {
@@ -169,7 +171,13 @@ export function AddConnectorModal({
169171

170172
const resolvedConfig: Record<string, unknown> = {}
171173
for (const [key, value] of Object.entries(resolveSourceConfig())) {
172-
if (value) resolvedConfig[key] = value
174+
if (Array.isArray(value)) {
175+
if (value.length > 0) resolvedConfig[key] = value
176+
} else if (typeof value === 'string') {
177+
if (value) resolvedConfig[key] = value
178+
} else if (value !== undefined && value !== null) {
179+
resolvedConfig[key] = value
180+
}
173181
}
174182
if (disabledTagIds.size > 0) {
175183
resolvedConfig.disabledTagIds = Array.from(disabledTagIds)
@@ -370,8 +378,8 @@ export function AddConnectorModal({
370378
{field.type === 'selector' && field.selectorKey ? (
371379
<ConnectorSelectorField
372380
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}
373-
value={sourceConfig[field.id] || ''}
374-
onChange={(value) => handleFieldChange(field.id, value)}
381+
value={sourceConfig[field.id] ?? (field.multi ? [] : '')}
382+
onChange={(value: ConfigFieldValue) => handleFieldChange(field.id, value)}
375383
credentialId={effectiveCredentialId}
376384
sourceConfig={sourceConfig}
377385
configFields={connectorConfig.configFields}
@@ -385,13 +393,21 @@ export function AddConnectorModal({
385393
label: opt.label,
386394
value: opt.id,
387395
}))}
388-
value={sourceConfig[field.id] || undefined}
396+
value={
397+
typeof sourceConfig[field.id] === 'string'
398+
? (sourceConfig[field.id] as string) || undefined
399+
: undefined
400+
}
389401
onChange={(value) => handleFieldChange(field.id, value)}
390402
placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
391403
/>
392404
) : (
393405
<Input
394-
value={sourceConfig[field.id] || ''}
406+
value={
407+
Array.isArray(sourceConfig[field.id])
408+
? (sourceConfig[field.id] as string[]).join(', ')
409+
: (sourceConfig[field.id] as string) || ''
410+
}
395411
onChange={(e) => handleFieldChange(field.id, e.target.value)}
396412
placeholder={field.placeholder}
397413
/>

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@
33
import { useMemo } from 'react'
44
import { Combobox, type ComboboxOption, Loader } from '@/components/emcn'
55
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
6+
import type {
7+
ConfigFieldMap,
8+
ConfigFieldValue,
9+
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
610
import { getDependsOnFields } from '@/blocks/utils'
711
import type { ConnectorConfigField } from '@/connectors/types'
812
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
913
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
1014

1115
interface ConnectorSelectorFieldProps {
1216
field: ConnectorConfigField & { selectorKey: SelectorKey }
13-
value: string
14-
onChange: (value: string) => void
17+
value: ConfigFieldValue
18+
onChange: (value: ConfigFieldValue) => void
1519
credentialId: string | null
16-
sourceConfig: Record<string, string>
20+
sourceConfig: ConfigFieldMap
1721
configFields: ConnectorConfigField[]
1822
canonicalModes: Record<string, 'basic' | 'advanced'>
1923
disabled?: boolean
@@ -29,6 +33,8 @@ export function ConnectorSelectorField({
2933
canonicalModes,
3034
disabled,
3135
}: ConnectorSelectorFieldProps) {
36+
const isMulti = Boolean(field.multi)
37+
3238
const context = useMemo<SelectorContext>(() => {
3339
const ctx: SelectorContext = {}
3440
if (credentialId) ctx.oauthCredential = credentialId
@@ -73,11 +79,34 @@ export function ConnectorSelectorField({
7379
)
7480
}
7581

82+
if (isMulti) {
83+
const multiValues = Array.isArray(value) ? value : value ? [value] : []
84+
return (
85+
<Combobox
86+
multiSelect
87+
options={comboboxOptions}
88+
multiSelectValues={multiValues}
89+
onMultiSelectChange={(values) => onChange(values)}
90+
searchable
91+
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
92+
placeholder={
93+
!credentialId
94+
? 'Connect an account first'
95+
: !depsResolved
96+
? `Select ${getDependencyLabel(field, configFields)} first`
97+
: field.placeholder || `Select ${field.title.toLowerCase()}`
98+
}
99+
disabled={disabled || !credentialId || !depsResolved}
100+
/>
101+
)
102+
}
103+
104+
const singleValue = Array.isArray(value) ? value[0] : value
76105
return (
77106
<Combobox
78107
options={comboboxOptions}
79-
value={value || undefined}
80-
onChange={onChange}
108+
value={singleValue || undefined}
109+
onChange={(next) => onChange(next)}
81110
searchable
82111
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
83112
placeholder={
@@ -96,18 +125,28 @@ function resolveDepValue(
96125
depFieldId: string,
97126
configFields: ConnectorConfigField[],
98127
canonicalModes: Record<string, 'basic' | 'advanced'>,
99-
sourceConfig: Record<string, string>
128+
sourceConfig: ConfigFieldMap
100129
): string {
101130
const depField = configFields.find((f) => f.id === depFieldId)
102-
if (!depField?.canonicalParamId) return sourceConfig[depFieldId] ?? ''
131+
/**
132+
* For multi-value parent fields, pass all selected values to dependent
133+
* selectors as a comma-joined string so the downstream selector can load
134+
* options across every selected parent (e.g. Linear projects across multiple
135+
* selected teams). Single-value parents pass through unchanged.
136+
*/
137+
const readDep = (raw: ConfigFieldValue | undefined): string => {
138+
if (Array.isArray(raw)) return raw.join(',')
139+
return raw ?? ''
140+
}
141+
if (!depField?.canonicalParamId) return readDep(sourceConfig[depFieldId])
103142

104143
const activeMode = canonicalModes[depField.canonicalParamId] ?? 'basic'
105-
if (depField.mode === activeMode) return sourceConfig[depFieldId] ?? ''
144+
if (depField.mode === activeMode) return readDep(sourceConfig[depFieldId])
106145

107146
const activeField = configFields.find(
108147
(f) => f.canonicalParamId === depField.canonicalParamId && f.mode === activeMode
109148
)
110-
return activeField ? (sourceConfig[activeField.id] ?? '') : (sourceConfig[depFieldId] ?? '')
149+
return activeField ? readDep(sourceConfig[activeField.id]) : readDep(sourceConfig[depFieldId])
111150
}
112151

113152
function getDependencyLabel(

0 commit comments

Comments
 (0)