Skip to content

Commit 7edb738

Browse files
committed
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
1 parent 21c956c commit 7edb738

15 files changed

Lines changed: 1012 additions & 353 deletions

File tree

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: 42 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,22 @@ 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+
const readFirst = (raw: ConfigFieldValue | undefined): string => {
132+
if (Array.isArray(raw)) return raw[0] ?? ''
133+
return raw ?? ''
134+
}
135+
if (!depField?.canonicalParamId) return readFirst(sourceConfig[depFieldId])
103136

104137
const activeMode = canonicalModes[depField.canonicalParamId] ?? 'basic'
105-
if (depField.mode === activeMode) return sourceConfig[depFieldId] ?? ''
138+
if (depField.mode === activeMode) return readFirst(sourceConfig[depFieldId])
106139

107140
const activeField = configFields.find(
108141
(f) => f.canonicalParamId === depField.canonicalParamId && f.mode === activeMode
109142
)
110-
return activeField ? (sourceConfig[activeField.id] ?? '') : (sourceConfig[depFieldId] ?? '')
143+
return activeField ? readFirst(sourceConfig[activeField.id]) : readFirst(sourceConfig[depFieldId])
111144
}
112145

113146
function getDependencyLabel(

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

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import { getSubscriptionAccessState } from '@/lib/billing/client'
2828
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
2929
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
3030
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
31+
import type {
32+
ConfigFieldMap,
33+
ConfigFieldValue,
34+
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
3135
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
3236
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
3337
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
@@ -61,6 +65,28 @@ function readPersistedCanonicalModes(
6165
return result
6266
}
6367

68+
/**
69+
* Deep equality for sourceConfig values (string, string[], or undefined/null).
70+
* Empty string and empty array are treated as equivalent to absence.
71+
*/
72+
function valuesEqual(a: unknown, b: unknown): boolean {
73+
const isEmpty = (v: unknown): boolean => {
74+
if (v == null) return true
75+
if (Array.isArray(v)) return v.length === 0
76+
if (typeof v === 'string') return v === ''
77+
return false
78+
}
79+
if (isEmpty(a) && isEmpty(b)) return true
80+
if (Array.isArray(a) && Array.isArray(b)) {
81+
if (a.length !== b.length) return false
82+
for (let i = 0; i < a.length; i++) {
83+
if (a[i] !== b[i]) return false
84+
}
85+
return true
86+
}
87+
return a === b
88+
}
89+
6490
function didCanonicalModesChange(
6591
current: Record<string, 'basic' | 'advanced'>,
6692
persisted: Record<string, 'basic' | 'advanced'>
@@ -96,19 +122,38 @@ export function EditConnectorModal({
96122
* manual input), both field IDs get the same value so toggling preserves it.
97123
* Captured once on mount; editing state is owned by the hook afterward.
98124
*/
99-
const [initialSourceConfig] = useState<Record<string, string>>(() => {
100-
const config: Record<string, string> = {}
125+
const [initialSourceConfig] = useState<ConfigFieldMap>(() => {
126+
const config: ConfigFieldMap = {}
101127
if (!connectorConfig) {
102128
for (const [key, value] of Object.entries(connector.sourceConfig)) {
103-
if (!INTERNAL_CONFIG_KEYS.has(key)) config[key] = String(value ?? '')
129+
if (INTERNAL_CONFIG_KEYS.has(key)) continue
130+
if (Array.isArray(value)) {
131+
config[key] = value.filter((v): v is string => typeof v === 'string')
132+
} else {
133+
config[key] = String(value ?? '')
134+
}
104135
}
105136
return config
106137
}
107138
for (const field of connectorConfig.configFields) {
108139
const canonicalId = field.canonicalParamId ?? field.id
109140
if (INTERNAL_CONFIG_KEYS.has(canonicalId)) continue
110141
const rawValue = connector.sourceConfig[canonicalId]
111-
if (rawValue !== undefined) config[field.id] = String(rawValue ?? '')
142+
if (rawValue === undefined) continue
143+
if (field.multi) {
144+
if (Array.isArray(rawValue)) {
145+
config[field.id] = rawValue.filter((v): v is string => typeof v === 'string')
146+
} else if (typeof rawValue === 'string') {
147+
config[field.id] = rawValue
148+
.split(',')
149+
.map((s) => s.trim())
150+
.filter(Boolean)
151+
} else {
152+
config[field.id] = []
153+
}
154+
} else {
155+
config[field.id] = String(rawValue ?? '')
156+
}
112157
}
113158
return config
114159
})
@@ -147,7 +192,7 @@ export function EditConnectorModal({
147192
if (didCanonicalModesChange(canonicalModes, persistedCanonicalModes)) return true
148193
const resolved = resolveSourceConfig()
149194
for (const [key, value] of Object.entries(resolved)) {
150-
if (String(connector.sourceConfig[key] ?? '') !== value) return true
195+
if (!valuesEqual(connector.sourceConfig[key], value)) return true
151196
}
152197
return false
153198
}, [
@@ -169,9 +214,9 @@ export function EditConnectorModal({
169214
}
170215

171216
const resolved = resolveSourceConfig()
172-
const changedEntries: Record<string, string> = {}
217+
const changedEntries: Record<string, unknown> = {}
173218
for (const [key, value] of Object.entries(resolved)) {
174-
if (String(connector.sourceConfig[key] ?? '') !== value) changedEntries[key] = value
219+
if (!valuesEqual(connector.sourceConfig[key], value)) changedEntries[key] = value
175220
}
176221

177222
const modesChanged = didCanonicalModesChange(canonicalModes, persistedCanonicalModes)
@@ -276,12 +321,12 @@ export function EditConnectorModal({
276321

277322
interface SettingsTabProps {
278323
connectorConfig: ConnectorConfig | null
279-
sourceConfig: Record<string, string>
324+
sourceConfig: ConfigFieldMap
280325
credentialId: string | null
281326
canonicalGroups: Map<string, ConnectorConfigField[]>
282327
canonicalModes: Record<string, 'basic' | 'advanced'>
283328
onToggleCanonicalMode: (canonicalId: string) => void
284-
onFieldChange: (fieldId: string, value: string) => void
329+
onFieldChange: (fieldId: string, value: ConfigFieldValue) => void
285330
isFieldVisible: (field: ConnectorConfigField) => boolean
286331
syncInterval: number
287332
setSyncInterval: (v: number) => void
@@ -344,8 +389,8 @@ function SettingsTab({
344389
{field.type === 'selector' && field.selectorKey ? (
345390
<ConnectorSelectorField
346391
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}
347-
value={sourceConfig[field.id] || ''}
348-
onChange={(value) => onFieldChange(field.id, value)}
392+
value={sourceConfig[field.id] ?? (field.multi ? [] : '')}
393+
onChange={(value: ConfigFieldValue) => onFieldChange(field.id, value)}
349394
credentialId={credentialId}
350395
sourceConfig={sourceConfig}
351396
configFields={connectorConfig.configFields}
@@ -359,13 +404,21 @@ function SettingsTab({
359404
label: opt.label,
360405
value: opt.id,
361406
}))}
362-
value={sourceConfig[field.id] || undefined}
407+
value={
408+
typeof sourceConfig[field.id] === 'string'
409+
? (sourceConfig[field.id] as string) || undefined
410+
: undefined
411+
}
363412
onChange={(value) => onFieldChange(field.id, value)}
364413
placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
365414
/>
366415
) : (
367416
<Input
368-
value={sourceConfig[field.id] || ''}
417+
value={
418+
Array.isArray(sourceConfig[field.id])
419+
? (sourceConfig[field.id] as string[]).join(', ')
420+
: (sourceConfig[field.id] as string) || ''
421+
}
369422
onChange={(e) => onFieldChange(field.id, e.target.value)}
370423
placeholder={field.placeholder}
371424
/>

0 commit comments

Comments
 (0)