Skip to content

Commit 540bda1

Browse files
committed
refactor(search): apply the simplify passes to the Sim Search surface
One availability gate for every members-mode refusal, the shared role gate behind the admin-first message, setup fields precomputed on the catalog, the connect flow owned by the enrollment hook and shared by the strip and the Search page, the latest attempt derived instead of cross-resets, a trailing adornment slot on the chip in place of a loader wrapper, the processor's source access as one object with the connector-row rule in one place, and the first connect's independent lookups run together after its synchronous checks.
1 parent d6968e4 commit 540bda1

22 files changed

Lines changed: 262 additions & 205 deletions

File tree

apps/sim/app/api/files/authorization.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -491,10 +491,13 @@ async function verifyCopilotFileAccess(
491491
* signal only: it reflects whether the file is still part of a live KB, not who
492492
* owns it (ownership comes from the binding).
493493
*/
494+
/** A reader once resolved: a person's or the workspace's tokens, or the system reading its own rows. */
495+
type ResolvedKnowledgeFileAccess = KnowledgeAccessScope | SystemAccessScope
496+
494497
async function hasActiveKbDocumentForKey(
495498
cloudKey: string,
496499
workspaceId: string,
497-
access: KnowledgeAccessScope | SystemAccessScope
500+
access: ResolvedKnowledgeFileAccess
498501
): Promise<boolean> {
499502
const rows = await db
500503
.select({ id: document.id })
@@ -525,13 +528,13 @@ async function hasActiveKbDocumentForKey(
525528
* else — an internal token, a tool running with the workflow owner's id —
526529
* reads as the workspace, never as the person whose id it happens to carry.
527530
*/
528-
export type KnowledgeFileAccess = 'user' | KnowledgeAccessScope | SystemAccessScope
531+
export type KnowledgeFileAccess = 'user' | ResolvedKnowledgeFileAccess
529532

530533
async function resolveKnowledgeFileAccess(
531534
knowledgeAccess: KnowledgeFileAccess | undefined,
532535
userId: string,
533536
workspaceId: string
534-
): Promise<KnowledgeAccessScope | SystemAccessScope> {
537+
): Promise<ResolvedKnowledgeFileAccess> {
535538
if (knowledgeAccess === 'user') return resolveUserKnowledgeAccessScope(userId, workspaceId)
536539
return knowledgeAccess ?? WORKSPACE_ACCESS_SCOPE
537540
}

apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import { useMemo, useState } from 'react'
44
import { Button, Chip } from '@sim/emcn'
55
import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowledge'
6+
import { connectorDisplayName } from '@/lib/sim-search/connectors'
67
import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card'
78
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
89
import { isIndexing } from '@/app/workspace/[workspaceId]/home/components/search-sources'
9-
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
1010
import { useWorkspaceMemberConnectors } from '@/hooks/queries/kb/connectors'
1111
import { useKnowledgeBasesQuery, useWorkspaceKnowledgeSearch } from '@/hooks/queries/kb/knowledge'
1212

@@ -58,18 +58,14 @@ function toSource(result: WorkspaceKnowledgeSearchResult): SourceTagData | null
5858
url: result.sourceUrl,
5959
title: result.documentName ?? undefined,
6060
siteName: result.connectorType
61-
? connectorName(result.connectorType)
61+
? connectorDisplayName(result.connectorType)
6262
: result.knowledgeBaseName || undefined,
6363
connectorType: result.connectorType ?? undefined,
6464
snippet: toSnippet(result.content),
6565
updatedAt: result.sourceModifiedAt ?? undefined,
6666
}
6767
}
6868

69-
function connectorName(connectorType: string): string {
70-
return CONNECTOR_META_REGISTRY[connectorType]?.name ?? connectorType
71-
}
72-
7369
/**
7470
* Arrow keys walk the result links, the way a search page does; Enter on a
7571
* focused link opens it natively. Focus stops at either end.
@@ -134,7 +130,7 @@ export function KnowledgeSearchResults({
134130
...new Set(
135131
memberConnectors
136132
.filter(isIndexing)
137-
.map((connection) => connectorName(connection.connectorType))
133+
.map((connection) => connectorDisplayName(connection.connectorType))
138134
),
139135
]
140136
const documents = useMemo(() => groupResultsByDocument(results ?? []), [results])
@@ -203,7 +199,7 @@ export function KnowledgeSearchResults({
203199
active={sourceFilter === type}
204200
onClick={() => setSourceFilter(sourceFilter === type ? null : type)}
205201
>
206-
{type === 'upload' ? 'Uploads' : connectorName(type)}
202+
{type === 'upload' ? 'Uploads' : connectorDisplayName(type)}
207203
</Chip>
208204
))}
209205
<span className='mx-1 self-center text-[var(--text-muted)] text-caption'>·</span>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { isIndexing, SearchSources, simSearchConnectionsByType } from './search-sources'
1+
export { isIndexing, SearchSources } from './search-sources'

apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx

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

3-
import { useMemo, useState } from 'react'
3+
import { useMemo } from 'react'
44
import { Chip } from '@sim/emcn'
55
import { Loader, Plus } from '@sim/emcn/icons'
66
import {
77
canConnectPersonally,
8-
isSearchConnectorAvailable,
9-
personalSetupFields,
108
SEARCH_CONNECTORS,
119
type SearchConnector,
1210
SIM_SEARCH_KNOWLEDGE_BASE_NAME,
11+
searchConnectorUnavailableReason,
1312
} from '@/lib/sim-search/connectors'
1413
import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal'
1514
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
@@ -23,20 +22,14 @@ import { CONNECTABLE_MEMBERSHIPS, useMemberEnrollment } from '@/hooks/use-member
2322
import { usePermissionConfig } from '@/hooks/use-permission-config'
2423

2524
const EMPTY_MEMBER_CONNECTORS: WorkspaceMemberConnector[] = []
26-
const MEMBER_ACCESS_UNAVAILABLE = 'Per-member access is not available in this workspace'
2725

2826
/** The sources a person can connect themselves, alphabetical. */
2927
const PERSONAL_SEARCH_CONNECTORS = SEARCH_CONNECTORS.filter((connector) =>
3028
canConnectPersonally(connector.meta)
3129
)
3230

33-
/** A chip's trailing icon is a component, so the spinning loader needs a wrapper to carry `animate`. */
34-
function SpinningLoader({ className }: { className?: string }) {
35-
return <Loader className={className} animate />
36-
}
37-
3831
/** The Sim Search connection per source, keyed by connector type. */
39-
export function simSearchConnectionsByType(
32+
function simSearchConnectionsByType(
4033
connectors: readonly WorkspaceMemberConnector[]
4134
): Map<string, WorkspaceMemberConnector> {
4235
const byType = new Map<string, WorkspaceMemberConnector>()
@@ -108,6 +101,7 @@ function SourceChip({
108101
const title =
109102
unavailableReason ??
110103
(connected ? `${connector.meta.name}: ${state}` : `Connect ${connector.meta.name}`)
104+
const busy = waiting || isIndexing(connection)
111105
return (
112106
<Chip
113107
shape='round'
@@ -116,7 +110,10 @@ function SourceChip({
116110
onClick={actionable ? onConnect : undefined}
117111
title={title}
118112
leftAdornment={<BrandIcon icon={connector.meta.icon} className='size-[14px] flex-shrink-0' />}
119-
rightIcon={waiting || isIndexing(connection) ? SpinningLoader : actionable ? Plus : undefined}
113+
rightIcon={!busy && actionable ? Plus : undefined}
114+
rightAdornment={
115+
busy ? <Loader className='size-[14px] text-[var(--text-icon)]' animate /> : undefined
116+
}
120117
>
121118
<span className='flex items-center gap-1.5'>
122119
<span>{connector.meta.name}</span>
@@ -163,50 +160,42 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
163160
[memberConnectors]
164161
)
165162
const membershipQueryKeys = useMemo(() => [memberConnectorKeys.list(workspaceId)], [workspaceId])
166-
const { connect, connectSource, isAwaiting, isPending, error } = useMemberEnrollment({
167-
membershipQueryKeys,
168-
connectedConnectorIds,
169-
})
170-
const [setupConnector, setSetupConnector] = useState<SearchConnector | null>(null)
163+
const {
164+
connectSource,
165+
connectSearchSource,
166+
setupConnector,
167+
closeSetup,
168+
isAwaiting,
169+
isPending,
170+
error,
171+
} = useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds })
171172

172-
const rank = (connector: SearchConnector) =>
173-
connectionByType.get(connector.type)?.viewerMembership === 'connected' ? 0 : 1
174-
const ordered = [...PERSONAL_SEARCH_CONNECTORS].sort(
175-
(a, b) => rank(a) - rank(b) || a.meta.name.localeCompare(b.meta.name)
176-
)
177-
178-
const startConnect = (connector: SearchConnector) => {
179-
const connection = connectionByType.get(connector.type)
180-
if (connection) {
181-
connect(connection.knowledgeBaseId, connection.connectorId)
182-
return
183-
}
184-
if (personalSetupFields(connector.meta).length > 0) {
185-
setSetupConnector(connector)
186-
return
187-
}
188-
connectSource(workspaceId, connector.type)
189-
}
173+
/** Connected sources first; the catalog is already alphabetical, so the partition keeps the order. */
174+
const isConnected = (connector: SearchConnector) =>
175+
connectionByType.get(connector.type)?.viewerMembership === 'connected'
176+
const ordered = [
177+
...PERSONAL_SEARCH_CONNECTORS.filter(isConnected),
178+
...PERSONAL_SEARCH_CONNECTORS.filter((connector) => !isConnected(connector)),
179+
]
190180

191181
return (
192182
<div className='flex flex-col gap-2'>
193183
<div className='flex flex-wrap gap-1.5'>
194184
{ordered.map((connector) => {
195185
const connection = connectionByType.get(connector.type)
196-
const unavailableReason = !isSearchConnectorAvailable(connector, integrationAvailability)
197-
? `${connector.meta.name} is unavailable in this deployment`
198-
: memberAccessAvailable
199-
? null
200-
: MEMBER_ACCESS_UNAVAILABLE
201186
return (
202187
<SourceChip
203188
key={connector.type}
204189
connector={connector}
205190
connection={connection}
206-
unavailableReason={unavailableReason}
191+
unavailableReason={searchConnectorUnavailableReason(
192+
connector,
193+
integrationAvailability,
194+
memberAccessAvailable
195+
)}
207196
waiting={connection ? isAwaiting(connection.connectorId) : false}
208197
disabled={isPending}
209-
onConnect={() => startConnect(connector)}
198+
onConnect={() => connectSearchSource(workspaceId, connector, connection)}
210199
/>
211200
)
212201
})}
@@ -215,10 +204,7 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
215204
{setupConnector && (
216205
<SourceSetupModal
217206
connector={setupConnector}
218-
fields={personalSetupFields(setupConnector.meta)}
219-
onOpenChange={(open) => {
220-
if (!open) setSetupConnector(null)
221-
}}
207+
onClose={closeSetup}
222208
onConnect={(sourceConfig) =>
223209
connectSource(workspaceId, setupConnector.type, sourceConfig)
224210
}

apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,10 @@ import {
99
ChipModalHeader,
1010
} from '@sim/emcn'
1111
import type { SearchConnector } from '@/lib/sim-search/connectors'
12-
import type { ConnectorConfigField } from '@/connectors/types'
1312

1413
interface SourceSetupModalProps {
1514
connector: SearchConnector
16-
fields: readonly ConnectorConfigField[]
17-
onOpenChange: (open: boolean) => void
15+
onClose: () => void
1816
/** Connects the source with the filled-in fields; the caller opens the OAuth tab in this click. */
1917
onConnect: (sourceConfig: Record<string, string>) => void
2018
}
@@ -23,26 +21,26 @@ interface SourceSetupModalProps {
2321
* The few fields a source needs before its first connect, such as a site and
2422
* a space. Everyone after the first person clicks straight through.
2523
*/
26-
export function SourceSetupModal({
27-
connector,
28-
fields,
29-
onOpenChange,
30-
onConnect,
31-
}: SourceSetupModalProps) {
24+
export function SourceSetupModal({ connector, onClose, onConnect }: SourceSetupModalProps) {
25+
const fields = connector.setupFields
3226
const [values, setValues] = useState<Record<string, string>>({})
3327
const complete = fields.every((field) => values[field.id]?.trim())
3428

3529
const submit = () => {
3630
if (!complete) return
3731
onConnect(Object.fromEntries(fields.map((field) => [field.id, values[field.id]?.trim() ?? ''])))
38-
onOpenChange(false)
32+
onClose()
3933
}
4034

4135
return (
42-
<ChipModal open onOpenChange={onOpenChange} srTitle={`Connect ${connector.meta.name}`}>
43-
<ChipModalHeader onClose={() => onOpenChange(false)}>
44-
Connect {connector.meta.name}
45-
</ChipModalHeader>
36+
<ChipModal
37+
open
38+
onOpenChange={(open) => {
39+
if (!open) onClose()
40+
}}
41+
srTitle={`Connect ${connector.meta.name}`}
42+
>
43+
<ChipModalHeader onClose={onClose}>Connect {connector.meta.name}</ChipModalHeader>
4644
<ChipModalBody>
4745
{fields.map((field) =>
4846
field.type === 'dropdown' ? (
@@ -76,7 +74,7 @@ export function SourceSetupModal({
7674
)}
7775
</ChipModalBody>
7876
<ChipModalFooter
79-
onCancel={() => onOpenChange(false)}
77+
onCancel={onClose}
8078
primaryAction={{ label: 'Connect', onClick: submit, disabled: !complete }}
8179
/>
8280
</ChipModal>

apps/sim/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useMemo } from 'react'
44
import { Button } from '@sim/emcn'
5+
import { connectorDisplayName } from '@/lib/sim-search/connectors'
56
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
67
import {
78
RESOURCE_LIST_STACK,
@@ -19,11 +20,6 @@ import {
1920

2021
const SHARED_WITH_YOU_LABEL = 'Shared with you'
2122

22-
/** The name a per-member connector shows, from its registry entry. */
23-
export function memberConnectorName(connector: WorkspaceMemberConnector): string {
24-
return CONNECTOR_META_REGISTRY[connector.connectorType]?.name ?? connector.connectorType
25-
}
26-
2723
interface MemberConnectorsSectionProps {
2824
workspaceId: string
2925
/** The per-member connectors to show, already narrowed by the page's search. */
@@ -59,7 +55,7 @@ export function MemberConnectorsSection({ workspaceId, connectors }: MemberConne
5955
<div className={RESOURCE_LIST_STACK}>
6056
{connectors.map((connector) => {
6157
const meta = CONNECTOR_META_REGISTRY[connector.connectorType]
62-
const name = memberConnectorName(connector)
58+
const name = connectorDisplayName(connector.connectorType)
6359
const waiting = isAwaiting(connector.connectorId)
6460
const state =
6561
describeMembership({

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

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,28 @@ vi.mock('@/lib/sim-search/connectors', () => {
6262
serviceName: name,
6363
serviceIcon: icon,
6464
blockType: type,
65+
setupFields: [],
6566
})
67+
const isSearchConnectorAvailable = (
68+
candidate: { blockType: string },
69+
availability: ReadonlyMap<string, { oauthAvailable: boolean }>
70+
) => availability.get(candidate.blockType)?.oauthAvailable ?? true
6671
return {
6772
SIM_SEARCH_KNOWLEDGE_BASE_NAME: 'Sim Search',
6873
canConnectPersonally: (meta: { permissionScopedListing?: unknown }) =>
6974
Boolean(meta.permissionScopedListing),
70-
isSearchConnectorAvailable: (
71-
candidate: { blockType: string },
72-
availability: ReadonlyMap<string, { oauthAvailable: boolean }>
73-
) => availability.get(candidate.blockType)?.oauthAvailable ?? true,
75+
connectorDisplayName: (connectorType: string) => connectorType,
76+
isSearchConnectorAvailable,
77+
searchConnectorUnavailableReason: (
78+
candidate: { blockType: string; meta: { name: string } },
79+
availability: ReadonlyMap<string, { oauthAvailable: boolean }>,
80+
memberAccessAvailable: boolean
81+
) =>
82+
!isSearchConnectorAvailable(candidate, availability)
83+
? `${candidate.meta.name} is unavailable in this deployment`
84+
: memberAccessAvailable
85+
? null
86+
: 'Per-member access is not available in this workspace',
7487
SEARCH_CONNECTORS: [
7588
connector('google_drive', 'Google Drive', 'Sync Drive files', true),
7689
connector('confluence', 'Confluence', 'Sync Confluence pages', false),
@@ -116,6 +129,16 @@ vi.mock('@/hooks/use-member-enrollment', async () => {
116129
useMemberEnrollment: () => ({
117130
connect: mockConnect,
118131
connectSource: mockConnectSource,
132+
connectSearchSource: (
133+
workspaceId: string,
134+
connector: { type: string },
135+
connection: { knowledgeBaseId: string; connectorId: string } | undefined
136+
) =>
137+
connection
138+
? mockConnect(connection.knowledgeBaseId, connection.connectorId)
139+
: mockConnectSource(workspaceId, connector.type),
140+
setupConnector: null,
141+
closeSetup: () => {},
119142
isAwaiting: () => false,
120143
isPending: false,
121144
error: null,
@@ -167,7 +190,7 @@ describe('Search', () => {
167190
const text = container?.textContent ?? ''
168191
expect(text).toContain('Connected · 12 documents')
169192
expect(text).toContain('Set up by a workspace admin from a knowledge base.')
170-
expect(text).toContain('Unavailable in this deployment. Contact your administrator.')
193+
expect(text).toContain('Slack is unavailable in this deployment')
171194
expect(text).toContain('Sales')
172195
})
173196

@@ -189,9 +212,7 @@ describe('Search', () => {
189212

190213
expect(sectionLabels()).toEqual(['Sim Search Connectors'])
191214
const text = container?.textContent ?? ''
192-
expect(text).toContain(
193-
'Per-member access is not available in this workspace. Contact your administrator.'
194-
)
215+
expect(text).toContain('Per-member access is not available in this workspace')
195216
expect(text).not.toContain('Connected · 12 documents')
196217
expect(buttons().find((button) => button.textContent === 'Connect')).toBeUndefined()
197218
})

0 commit comments

Comments
 (0)