Skip to content

Commit 37db0a2

Browse files
committed
feat(search): show per-member knowledge connectors on the Search tab
The Search tab lists the knowledge bases whose connectors sync per member under "Shared with you", with where the viewer stands on each and the same one-click connect the knowledge base page offers. One hook opens enrollment in a new tab and refreshes the surface until the viewer is connected; the knowledge base banner uses it too.
1 parent 27f03fe commit 37db0a2

4 files changed

Lines changed: 228 additions & 52 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/member-connect-banner/member-connect-banner.tsx‎

Lines changed: 31 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
11
'use client'
22

3-
import { useEffect, useState } from 'react'
3+
import { useMemo } from 'react'
44
import { Button } from '@sim/emcn'
55
import { Loader } from '@sim/emcn/icons'
6-
import { createLogger } from '@sim/logger'
7-
import { useQueryClient } from '@tanstack/react-query'
86
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
97
import {
108
type ConnectorData,
119
connectorKeys,
12-
useStartConnectorMemberEnrollment,
10+
type ViewerConnectorMembership,
1311
} from '@/hooks/queries/kb/connectors'
14-
15-
const logger = createLogger('MemberConnectBanner')
16-
17-
/** How often the connector list is refreshed while a member connects in another tab. */
18-
const AWAITING_CONNECTION_POLL_MS = 4_000
12+
import { useMemberEnrollment } from '@/hooks/use-member-enrollment'
1913

2014
interface MemberConnectBannerProps {
2115
knowledgeBaseId: string
@@ -26,19 +20,18 @@ function connectorName(connector: ConnectorData): string {
2620
return CONNECTOR_META_REGISTRY[connector.connectorType]?.name ?? connector.connectorType
2721
}
2822

23+
/** Memberships the viewer can act on themselves. */
24+
const CONNECTABLE: ReadonlySet<ViewerConnectorMembership> = new Set([
25+
'needs_reauth',
26+
'invited',
27+
'not_enrolled',
28+
])
29+
2930
/**
30-
* What the viewer must do for a per-member connector, if anything. Enrollment
31-
* opens in a new tab — the enrollment page ends by telling the person to close
32-
* it — and the list is polled meanwhile so the row updates on its own.
31+
* What the viewer must do for each per-member connector, if anything, and
32+
* what is happening for them once they have connected.
3333
*/
3434
export function MemberConnectBanner({ knowledgeBaseId, connectors }: MemberConnectBannerProps) {
35-
const queryClient = useQueryClient()
36-
const { mutate: startEnrollment, isPending } = useStartConnectorMemberEnrollment()
37-
const [awaitingConnectorIds, setAwaitingConnectorIds] = useState<ReadonlySet<string>>(
38-
() => new Set()
39-
)
40-
const [error, setError] = useState<string | null>(null)
41-
4235
const rows = connectors.filter(
4336
(connector) =>
4437
connector.accessMode === 'members' &&
@@ -47,44 +40,32 @@ export function MemberConnectBanner({ knowledgeBaseId, connectors }: MemberConne
4740
connector.memberSyncStatus === 'pending' ||
4841
connector.memberSyncStatus === 'running')
4942
)
50-
51-
const awaiting = rows.some(
52-
(connector) =>
53-
awaitingConnectorIds.has(connector.id) && connector.viewerMembership !== 'connected'
43+
const connectedConnectorIds = useMemo(
44+
() =>
45+
new Set(
46+
connectors
47+
.filter((connector) => connector.viewerMembership === 'connected')
48+
.map((connector) => connector.id)
49+
),
50+
[connectors]
5451
)
55-
useEffect(() => {
56-
if (!awaiting) return
57-
const timer = setInterval(() => {
58-
void queryClient.invalidateQueries({ queryKey: connectorKeys.lists(knowledgeBaseId) })
59-
}, AWAITING_CONNECTION_POLL_MS)
60-
return () => clearInterval(timer)
61-
}, [awaiting, knowledgeBaseId, queryClient])
52+
const membershipQueryKeys = useMemo(
53+
() => [connectorKeys.lists(knowledgeBaseId)],
54+
[knowledgeBaseId]
55+
)
56+
const { connect, isAwaiting, isPending, error } = useMemberEnrollment({
57+
membershipQueryKeys,
58+
connectedConnectorIds,
59+
})
6260

6361
if (rows.length === 0) return null
6462

65-
const connect = (connectorId: string) => {
66-
setError(null)
67-
startEnrollment(
68-
{ knowledgeBaseId, connectorId },
69-
{
70-
onSuccess: ({ url }) => {
71-
window.open(url, '_blank', 'noopener')
72-
setAwaitingConnectorIds((current) => new Set([...current, connectorId]))
73-
},
74-
onError: (err) => {
75-
logger.error('Failed to start member enrollment', { error: err.message })
76-
setError(err.message)
77-
},
78-
}
79-
)
80-
}
81-
8263
return (
8364
<div className='mx-6 mt-3 mb-1 flex flex-col gap-2'>
8465
{rows.map((connector) => {
8566
const name = connectorName(connector)
8667
const membership = connector.viewerMembership
87-
const waiting = awaitingConnectorIds.has(connector.id) && membership !== 'connected'
68+
const waiting = isAwaiting(connector.id)
8869
const text =
8970
membership === 'connected'
9071
? `Syncing the ${name} documents shared with you. They appear when the sync completes.`
@@ -97,8 +78,6 @@ export function MemberConnectBanner({ knowledgeBaseId, connectors }: MemberConne
9778
: waiting
9879
? `Finish connecting your ${name} account in the other tab.`
9980
: `Connect your ${name} account to see the documents shared with you.`
100-
const canConnect =
101-
membership === 'needs_reauth' || membership === 'invited' || membership === 'not_enrolled'
10281
return (
10382
<div
10483
key={connector.id}
@@ -110,11 +89,11 @@ export function MemberConnectBanner({ knowledgeBaseId, connectors }: MemberConne
11089
)}
11190
<span>{text}</span>
11291
</p>
113-
{canConnect && (
92+
{membership && CONNECTABLE.has(membership) && (
11493
<Button
11594
variant='primary'
11695
size='sm'
117-
onClick={() => connect(connector.id)}
96+
onClick={() => connect(knowledgeBaseId, connector.id)}
11897
disabled={isPending}
11998
>
12099
{waiting ? 'Open again' : membership === 'needs_reauth' ? 'Reconnect' : 'Connect'}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
'use client'
2+
3+
import { useMemo } from 'react'
4+
import { Button } from '@sim/emcn'
5+
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
6+
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
7+
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
8+
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
9+
import {
10+
memberConnectorKeys,
11+
useWorkspaceMemberConnectors,
12+
type ViewerConnectorMembership,
13+
type WorkspaceMemberConnector,
14+
} from '@/hooks/queries/kb/connectors'
15+
import { useMemberEnrollment } from '@/hooks/use-member-enrollment'
16+
17+
const SHARED_WITH_YOU_LABEL = 'Shared with you'
18+
19+
/** Memberships the viewer can act on themselves. */
20+
const CONNECTABLE: ReadonlySet<ViewerConnectorMembership> = new Set([
21+
'needs_reauth',
22+
'invited',
23+
'not_enrolled',
24+
])
25+
26+
function describe(connector: WorkspaceMemberConnector, waiting: boolean): string {
27+
switch (connector.viewerMembership) {
28+
case 'connected':
29+
return connector.memberSyncStatus === 'pending' || connector.memberSyncStatus === 'running'
30+
? `${connector.knowledgeBaseName} · syncing the documents shared with you`
31+
: `${connector.knowledgeBaseName} · connected`
32+
case 'needs_reauth':
33+
return `${connector.knowledgeBaseName} · reconnect to keep seeing the documents shared with you`
34+
case 'unverified_email':
35+
return `${connector.knowledgeBaseName} · verify your email address to see the documents shared with you`
36+
case 'revoked':
37+
return `${connector.knowledgeBaseName} · a workspace admin removed your access`
38+
default:
39+
return waiting
40+
? `${connector.knowledgeBaseName} · finish connecting in the other tab`
41+
: `${connector.knowledgeBaseName} · connect to see the documents shared with you`
42+
}
43+
}
44+
45+
interface MemberConnectorsSectionProps {
46+
workspaceId: string
47+
/** Lower-cased search text; rows whose knowledge base or connector name lacks it are hidden. */
48+
search: string
49+
}
50+
51+
/**
52+
* The knowledge bases whose connectors sync per member, and where the viewer
53+
* stands with each. Connecting here is the same enrollment the knowledge base
54+
* page offers, so a person can do it from whichever surface they are on.
55+
*/
56+
export function MemberConnectorsSection({ workspaceId, search }: MemberConnectorsSectionProps) {
57+
const { data: connectors = [] } = useWorkspaceMemberConnectors(workspaceId)
58+
const connectedConnectorIds = useMemo(
59+
() =>
60+
new Set(
61+
connectors
62+
.filter((connector) => connector.viewerMembership === 'connected')
63+
.map((connector) => connector.connectorId)
64+
),
65+
[connectors]
66+
)
67+
const membershipQueryKeys = useMemo(() => [memberConnectorKeys.list(workspaceId)], [workspaceId])
68+
const { connect, isAwaiting, isPending, error } = useMemberEnrollment({
69+
membershipQueryKeys,
70+
connectedConnectorIds,
71+
})
72+
73+
const visible = connectors.filter((connector) => {
74+
if (!search) return true
75+
const name = CONNECTOR_META_REGISTRY[connector.connectorType]?.name ?? connector.connectorType
76+
return [name, connector.knowledgeBaseName].some((text) => text.toLowerCase().includes(search))
77+
})
78+
if (visible.length === 0) return null
79+
80+
return (
81+
<IntegrationSection label={SHARED_WITH_YOU_LABEL}>
82+
{visible.map((connector) => {
83+
const meta = CONNECTOR_META_REGISTRY[connector.connectorType]
84+
const waiting = isAwaiting(connector.connectorId)
85+
const connectable = CONNECTABLE.has(connector.viewerMembership)
86+
return (
87+
<SettingsResourceRow
88+
key={connector.connectorId}
89+
iconVariant='custom'
90+
icon={
91+
meta ? (
92+
<IntegrationTile blockType={connector.connectorType} icon={meta.icon} />
93+
) : undefined
94+
}
95+
title={meta?.name ?? connector.connectorType}
96+
description={describe(connector, waiting)}
97+
trailing={
98+
connectable ? (
99+
<Button
100+
variant='primary'
101+
size='sm'
102+
onClick={() => connect(connector.knowledgeBaseId, connector.connectorId)}
103+
disabled={isPending}
104+
>
105+
{waiting
106+
? 'Open again'
107+
: connector.viewerMembership === 'needs_reauth'
108+
? 'Reconnect'
109+
: 'Connect'}
110+
</Button>
111+
) : undefined
112+
}
113+
/>
114+
)
115+
})}
116+
{error && <p className='text-[var(--text-error)] text-caption'>{error}</p>}
117+
</IntegrationSection>
118+
)
119+
}

‎apps/sim/app/workspace/[workspaceId]/search/search.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/c
1717
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
1818
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
1919
import { CONNECTED_LABEL } from '@/app/workspace/[workspaceId]/integrations/search-params'
20+
import { MemberConnectorsSection } from '@/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section'
2021
import { useSearchCredentials } from '@/app/workspace/[workspaceId]/search/hooks/use-search-credentials'
2122
import {
2223
connectorSearchParam,
@@ -150,6 +151,8 @@ export function Search() {
150151
/>
151152

152153
<div className='flex flex-col gap-7'>
154+
<MemberConnectorsSection workspaceId={workspaceId} search={normalizedSearch} />
155+
153156
{visibleCredentials.length > 0 && (
154157
<IntegrationSection label={CONNECTED_LABEL}>
155158
{visibleCredentials.map((credential) => (
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
'use client'
2+
3+
import { useCallback, useEffect, useState } from 'react'
4+
import { createLogger } from '@sim/logger'
5+
import { type QueryKey, useQueryClient } from '@tanstack/react-query'
6+
import { useStartConnectorMemberEnrollment } from '@/hooks/queries/kb/connectors'
7+
8+
const logger = createLogger('MemberEnrollment')
9+
10+
/** How often the given queries are refreshed while a member connects in another tab. */
11+
const AWAITING_CONNECTION_POLL_MS = 4_000
12+
13+
interface UseMemberEnrollmentProps {
14+
/** Queries carrying the viewer's membership, refreshed while a connection is awaited. */
15+
membershipQueryKeys: readonly QueryKey[]
16+
/** Connector ids the viewer is now connected to; awaiting stops for them. */
17+
connectedConnectorIds: ReadonlySet<string>
18+
}
19+
20+
/**
21+
* Lets the viewer connect their own account to a per-member connector.
22+
* Enrollment opens in a new tab — the enrollment page ends by telling the
23+
* person to close it — and the membership queries are polled meanwhile so
24+
* the surface that started it updates on its own once they are connected.
25+
*/
26+
export function useMemberEnrollment({
27+
membershipQueryKeys,
28+
connectedConnectorIds,
29+
}: UseMemberEnrollmentProps) {
30+
const queryClient = useQueryClient()
31+
const { mutate: startEnrollment, isPending } = useStartConnectorMemberEnrollment()
32+
const [awaitingConnectorIds, setAwaitingConnectorIds] = useState<ReadonlySet<string>>(
33+
() => new Set()
34+
)
35+
const [error, setError] = useState<string | null>(null)
36+
37+
const awaiting = [...awaitingConnectorIds].some((id) => !connectedConnectorIds.has(id))
38+
useEffect(() => {
39+
if (!awaiting) return
40+
const timer = setInterval(() => {
41+
for (const queryKey of membershipQueryKeys) {
42+
void queryClient.invalidateQueries({ queryKey })
43+
}
44+
}, AWAITING_CONNECTION_POLL_MS)
45+
return () => clearInterval(timer)
46+
}, [awaiting, membershipQueryKeys, queryClient])
47+
48+
const connect = useCallback(
49+
(knowledgeBaseId: string, connectorId: string) => {
50+
setError(null)
51+
startEnrollment(
52+
{ knowledgeBaseId, connectorId },
53+
{
54+
onSuccess: ({ url }) => {
55+
window.open(url, '_blank', 'noopener')
56+
setAwaitingConnectorIds((current) => new Set([...current, connectorId]))
57+
},
58+
onError: (err) => {
59+
logger.error('Failed to start member enrollment', { error: err.message })
60+
setError(err.message)
61+
},
62+
}
63+
)
64+
},
65+
[startEnrollment]
66+
)
67+
68+
const isAwaiting = useCallback(
69+
(connectorId: string) =>
70+
awaitingConnectorIds.has(connectorId) && !connectedConnectorIds.has(connectorId),
71+
[awaitingConnectorIds, connectedConnectorIds]
72+
)
73+
74+
return { connect, isAwaiting, isPending, error }
75+
}

0 commit comments

Comments
 (0)